if (!crypto.randomUUID) { crypto.randomUUID = function() { return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) ); }; } function cqInstallHtmxInlineScriptGuard() { if (!window.__cqHtmxInlineScriptGuardInstalled) { window.__cqHtmxInlineScriptGuardInstalled = true; document.addEventListener("htmx:beforeSwap", function (e) { let response = e && e.detail ? e.detail.serverResponse : null; if (typeof response !== "string" || (response.indexOf("cqParentBackUrl") === -1 && response.indexOf("cqParentBackdropUrl") === -1)) { return; } e.detail.serverResponse = response.replace( /\b(?:const|let)\s+(cqParentBackUrl|cqParentBackdropUrl)\s*=/g, "window.$1 =" ); }); } } cqInstallHtmxInlineScriptGuard(); function addHTMXListeners() { cqInstallHtmxInlineScriptGuard(); document.body.addEventListener("htmx:sendError", function (e) { let errorMessage = e.detail.error; errorMessage = "Failed to send request to server - " + errorMessage; let htmlFragment = '

' + errorMessage.replace(/'; let bodyElement = document.querySelector('body'); bodyElement.insertAdjacentHTML('afterbegin', htmlFragment); }); document.body.addEventListener("htmx:responseError", function (e) { let errorMessage = e.detail.xhr.response; alert(errorMessage); let htmlFragment = '

' + errorMessage.replace(/'; let bodyElement = document.querySelector('body'); bodyElement.insertAdjacentHTML('afterbegin', htmlFragment); }); } async function getFile(fileEntry) { return new Promise((resolve, reject) => { fileEntry.file(resolve, reject); }); } async function processDirectory(entry, path = '', files = []) { path = path ? path + "/" + entry.name : entry.name; let dirReader = entry.createReader(); const readEntries = async () => { return new Promise((resolve, reject) => { dirReader.readEntries(resolve, reject); }); }; while (true) { let entries; try { entries = await readEntries(); } catch (error) { console.error("Error reading directory entries", error); return; } if (!entries || !entries.length) { break; } for (let entry of entries) { if (entry.isDirectory) { await processDirectory(entry, path, files); } else { let fileEntry = await getFile(entry); fileEntry._relativefilePath = path + "/" + entry.name; files.push(fileEntry); } } } } window.ValidationController = window.ValidationController || class { constructor(formId, translations) { this.translations = translations; this.formId = formId; this.form = document.getElementById(formId); if (!this.form) { console.warn('ValidationController: form not found:', formId); return; } this.fields = this.form.querySelectorAll('input, select, textarea'); const submitSelector = 'button[type="submit"][data-validation-submit]'; const inFormButtons = Array.from(this.form.querySelectorAll(submitSelector)); const externalFormButtons = Array.from( document.querySelectorAll(`${submitSelector}[form="${this.formId}"]`) ); this.submitButtons = Array.from(new Set([...inFormButtons, ...externalFormButtons])); this.setupEventListeners(); this.validateForm(); } translate(s) { return this.translations[s] || s; } setupEventListeners() { this.form.addEventListener('submit', (e) => this.handleSubmit(e)); this.fields.forEach(field => { if (this.shouldValidateField(field)) { field.addEventListener('blur', () => this.validateForm()); field.addEventListener('input', () => this.validateForm()); field.addEventListener('change', () => this.validateForm()); // For select and date inputs } }); } handleSubmit(e) { e.preventDefault(); if (this.validateForm()) { // You can submit the form here if needed // this.form.submit(); } } shouldValidateField(field) { return field.hasAttribute('required') || field.hasAttribute('data-validation-type'); } async validateField(field) { if (!this.shouldValidateField(field)) { return true; // Field doesn't need validation } const isRequired = field.hasAttribute('required'); const minLength = parseInt(field.getAttribute('data-min-length')) || 0; const maxLength = parseInt(field.getAttribute('data-max-length')) || Infinity; let isValid = true; let errorMessage = ''; if (isRequired) { if (field.tagName.toLowerCase() === 'select') { if (!field.value || field.value === "") { isValid = false; errorMessage = 'Bitte wählen Sie eine Option.'; } } else if (field.type === 'date') { if (!field.value) { isValid = false; errorMessage = 'Kein gültiges Datum'; } } else if (!field.value.trim()) { isValid = false; errorMessage = 'Dies ist ein Pflichtfeld.'; } } if (isValid && field.value.trim()) { if (field.type !== 'date' && field.tagName.toLowerCase() !== 'select') { if (field.value.trim().length < minLength) { isValid = false; errorMessage = `Minimum length is ${minLength} characters.`; } else if (field.value.trim().length > maxLength) { isValid = false; errorMessage = `Maximum length is ${maxLength} characters.`; } } } // Additional date validation if (isValid && field.type === 'date') { const minDate = field.getAttribute('min'); const maxDate = field.getAttribute('max'); const selectedDate = new Date(field.value); if (minDate && selectedDate < new Date(minDate)) { isValid = false; errorMessage = `Date must be on or after ${minDate}.`; } else if (maxDate && selectedDate > new Date(maxDate)) { isValid = false; errorMessage = `Date must be on or before ${maxDate}.`; } } // Money validation if (field.getAttribute('data-validation-type') === 'money') { //console.log(field); const fieldValue = field.value.trim(); const isValidNumber = /^-?\d+(\.\d{1,2})?$/.test(fieldValue); if (!isValidNumber) { isValid = false; errorMessage = 'Bitte geben Sie einen gültigen Betrag ein (z. B. 123.45).'; } else { const fieldLength = fieldValue.length; if (typeof minLength === 'number' && fieldLength < minLength) { isValid = false; errorMessage = `Der Wert muss mindestens ${minLength} Charakter lang sein`; } else if (typeof maxLength === 'number' && fieldLength > maxLength) { isValid = false; errorMessage = `Der Wert muss lönger als ${minLength} Charakter lang sein`; } } } // Year validation if (field.getAttribute('data-validation-type') === 'year') { const fieldValue = field.value.trim(); const isValidYear = /^\d{4}$/.test(fieldValue); if (!isValidYear) { isValid = false; errorMessage = 'Bitte geben Sie einen gültigen Betrag ein (z. B. 123.45).'; } else { const year = parseInt(fieldValue, 10); const currentYear = new Date().getFullYear(); const minYear = 1900; const maxYear = currentYear + 10; if (year < minYear || year > maxYear) { isValid = false; errorMessage = `The year must be between ${minYear} and ${maxYear}.`; } } } // Email validation if (field.getAttribute('data-validation-type') === 'email') { const fieldValue = field.value.trim(); const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fieldValue); if (!isValidEmail) { isValid = false; errorMessage = 'Bitte geben Sie eine gültige E-Mail-Adresse ein (z. B. example@example.com).'; } } // Iban validation if (field.getAttribute('data-validation-type') === 'iban') { const fieldValue = field.value.trim(); const iban = fieldValue.replace(/\s+/g, '').toUpperCase(); // Basic IBAN structure check if (iban && !/^[A-Z0-9]{15,34}$/.test(iban)) { isValid = false; errorMessage = 'IBAN muss zwischen 15 und 34 Charakter lang sein'; this.updateFieldStatus(field, isValid, errorMessage); return isValid; } // Move the first four characters to the end of the string const rearrangedIban = iban.slice(4) + iban.slice(0, 4); // Replace each letter in the string with two digits, expanding the string as needed const expandedIban = rearrangedIban.replace(/[A-Z]/g, function(match) { return match.charCodeAt(0) - 55; }); // Perform Modulo 97 operation on the expanded IBAN const ibanAsNumber = BigInt(expandedIban); isValid = !ibanAsNumber || ibanAsNumber % 97n === 1n; //const isValidIban = /^[A-Z0-9]{15,34}$/.test(iban); if (!isValid) { errorMessage = 'Bitte geben Sie eine gültige IBAN an (z.B. CH6330000004400005700).'; } } this.updateFieldStatus(field, isValid, errorMessage); return isValid; } async validateForm() { let isValid = true; for (const field of this.fields) { if (this.shouldValidateField(field) && !(await this.validateField(field))) { isValid = false; } }; this.formValidated(); this.updateSubmitButtons(isValid); return isValid; } formValidated() {} updateFieldStatus(field, isValid, errorMessage) { if (!field) { console.warn('updateFieldStatus: Kein Feld übergeben.'); return; } const feedbackElement = field.nextElementSibling; if (isValid) { field.classList.remove('is-invalid'); field.classList.add('is-valid'); if (feedbackElement && feedbackElement.classList.contains('invalid-feedback')) { feedbackElement.textContent = ''; } else if (!feedbackElement) { console.warn(`Kein Feedback-Element für Feld "${field.id}" gefunden.`); } } else { field.classList.remove('is-valid'); field.classList.add('is-invalid'); const translatedMessage = this.translate(errorMessage || 'Fehlerhafte Eingabe'); if (feedbackElement && feedbackElement.classList.contains('invalid-feedback')) { feedbackElement.textContent = translatedMessage; } else if (!feedbackElement) { console.warn(`Kein Feedback-Element für Feld "${field.id}" gefunden.`); } } } updateSubmitButtons(isValid) { this.submitButtons.forEach(button => { button.disabled = !isValid; if (isValid) { button.classList.remove('disabled'); } else { button.classList.add('disabled'); } }); } } class DirectUploadHandler { constructor(config) { this.filesToUpload = []; this.ticket = config.ticket; this.elem = config.elem; this.parent = config.parent; this.file_properties = config.file_properties; this.uploadURL = config.uploadURL; this.callbackActions = config.callbackActions; this.callbackAfterEachUpload = !!config.callbackAfterEachUpload; this.sequentialUpload = !!config.sequentialUpload; this.debugUpload = !!config.debugUpload; this.skipDropHandlers = !!config.skipDropHandlers; this.dragOverTimeout = null; this.dragDepth = 0; this.isUploading = false; this.batchExpected = 0; this.batchCompleted = 0; this.lastBatchResponse = null; this.chunkSize = config.chunkSize ? config.chunkSize : 3 * 1024 * 1024; this.progress = config.progress; if (!this.elem) { return; } this._boundDragOverHandler = this.dragOverHandler.bind(this); this._boundDropHandler = this.dropHandler.bind(this); this._boundDragLeaveHandler = this.dragLeaveHandler.bind(this); // Avoid duplicate handlers after HTMX swaps/re-inits. const prev = this.elem.__cqDirectUploadHandler; if (prev && typeof prev.destroy === 'function') { prev.destroy(); } this.elem.__cqDirectUploadHandler = this; if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG handler-init', { elem: this.elem && this.elem.id, uploadURL: this.uploadURL, callbackAfterEachUpload: this.callbackAfterEachUpload, callbackActions: this.callbackActions }); } if (!this.skipDropHandlers) { this.elem.addEventListener('dragover', this._boundDragOverHandler); this.elem.addEventListener('drop', this._boundDropHandler); this.elem.addEventListener('dragleave', this._boundDragLeaveHandler); } this.originalBackgroundColor = this.elem.style.backgroundColor; } destroy() { if (!this.elem) return; if (!this.skipDropHandlers) { this.elem.removeEventListener('dragover', this._boundDragOverHandler); this.elem.removeEventListener('drop', this._boundDropHandler); this.elem.removeEventListener('dragleave', this._boundDragLeaveHandler); } if (this.elem.__cqDirectUploadHandler === this) { delete this.elem.__cqDirectUploadHandler; } this.elem.classList.remove('drag-over'); this.dragDepth = 0; } async dropHandler(ev) { ev.preventDefault(); ev.stopPropagation(); if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG drop-event', { items: ev.dataTransfer && ev.dataTransfer.items ? ev.dataTransfer.items.length : null, files: ev.dataTransfer && ev.dataTransfer.files ? ev.dataTransfer.files.length : null }); } this.dragDepth = 0; this.elem.classList.remove('drag-over'); const versionDropRoot = this.elem.closest ? this.elem.closest('[data-cq-version-upload="1"]') : null; if (versionDropRoot) { versionDropRoot.classList.remove('drag-over'); } this.elem.style.backgroundColor = this.originalBackgroundColor; if (this.isUploading) { if (this.progress) { this.progress.innerText = 'uploading...'; } return; } if (ev.dataTransfer.items) { this.filesToUpload = []; let items = ev.dataTransfer.items; for (let i = 0; i < items.length; i++) { let item = items[i]; if (item.webkitGetAsEntry) { let entry = item.webkitGetAsEntry(); if (entry && entry.isDirectory) { await processDirectory(entry, item.name, this.filesToUpload); } else if (entry && entry.isFile) { let file = item.getAsFile(); if (file) { this.filesToUpload.push(file); } } } else if (item.kind === 'file') { let file = item.getAsFile(); if (file) { this.filesToUpload.push(file); } } } } else { this.filesToUpload = ev.dataTransfer.files; } this.filesToUpload = Array.from(this.filesToUpload || []).filter(file => !!file); if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG drop-files', this.filesToUpload.map(file => ({ name: file.name, size: file.size, type: file.type }))); } await this.upload(); } dragOverHandler(ev) { ev.preventDefault(); ev.stopPropagation(); try { ev.dataTransfer.dropEffect = 'copy'; } catch (_) {} this.dragDepth = Math.max(1, this.dragDepth + 1); this.elem.classList.add('drag-over'); const versionDropRoot = this.elem.closest ? this.elem.closest('[data-cq-version-upload="1"]') : null; if (versionDropRoot) { versionDropRoot.classList.add('drag-over'); } this.elem.style.backgroundColor = this.originalBackgroundColor; clearTimeout(this.dragOverTimeout); this.dragOverTimeout = setTimeout(() => { if (!this.dragDepth && !this.isUploading) { this.elem.classList.remove('drag-over'); const versionDropRoot = this.elem.closest ? this.elem.closest('[data-cq-version-upload="1"]') : null; if (versionDropRoot) { versionDropRoot.classList.remove('drag-over'); } this.elem.style.backgroundColor = this.originalBackgroundColor; } }, 300); } dragLeaveHandler(ev) { ev.preventDefault(); ev.stopPropagation(); this.dragDepth = Math.max(0, this.dragDepth - 1); if (this.dragDepth === 0 && !this.isUploading) { this.elem.classList.remove('drag-over'); const versionDropRoot = this.elem.closest ? this.elem.closest('[data-cq-version-upload="1"]') : null; if (versionDropRoot) { versionDropRoot.classList.remove('drag-over'); } this.elem.style.backgroundColor = this.originalBackgroundColor; } } onprogress(event) { let percent = (event.loaded / event.total) * 100; let elem = this.progress; if (elem) { const current = this.batchCompleted + 1; const total = this.batchExpected || 1; elem.innerText = 'uploading... ' + current.toString() + '/' + total.toString() + ' - ' + Math.round(percent).toString() + " %"; } } async callback(response) { if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG callback-enter', response); } if (typeof response === 'string') { response = JSON.parse(response); } response = response || {}; let elem = this.progress; if (elem) { elem.innerText = 'uploaded'; } if (!this.callbackActions || !this.callbackActions.url) { if (this.debugUpload) { console.warn('CQ-UPLOAD-DEBUG callback-no-actions', response); } return; } this.lastBatchResponse = response; this.batchCompleted += 1; if (!this.callbackAfterEachUpload && this.batchExpected > 1 && this.batchCompleted < this.batchExpected) { return; } const req = Object.assign({}, this.callbackActions); let url = String(req.url || ''); try { const abs = new URL(url, window.location.origin); abs.searchParams.set('action', 'uploaded'); if (response['obj_id']) abs.searchParams.set('obj_id', String(response['obj_id'])); if (response['obj_content_hash']) abs.searchParams.set('obj_content_hash', String(response['obj_content_hash'])); // Keep relative URLs relative for consistency with existing code. url = abs.origin === window.location.origin ? (abs.pathname + abs.search + abs.hash) : abs.toString(); } catch (_) { const sep = url.includes('?') ? '&' : '?'; url = url + sep + 'action=uploaded' + (response['obj_id'] ? ('&obj_id=' + encodeURIComponent(String(response['obj_id']))) : '') + (response['obj_content_hash'] ? ('&obj_content_hash=' + encodeURIComponent(String(response['obj_content_hash']))) : ''); } req.url = url; delete req.values; if (!req.target && this.elem) { req.target = this.elem; } if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG htmx-refresh-start', { url: req.url, target: req.target, select: req.select, swap: req.swap }); } const scheduler = typeof req.scheduler === 'function' ? req.scheduler : (typeof req.scheduler === 'string' ? window[req.scheduler] : null); if (typeof scheduler === 'function') { scheduler(req.url, req); return; } htmx.ajax('GET', req.url, req); } async doUploadFile(file, obj_text, add_to_clipboard, fileIndex) { if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG upload-file-start', { name: file && file.name, size: file && file.size }); } let dateObj = new Date(file.lastModified); // Create a Date object let isoDate = dateObj.toISOString(); // Convert to ISO date string let incomingProps = typeof this.file_properties === 'function' ? this.file_properties(file, fileIndex || 0) : this.file_properties; if (typeof incomingProps === 'string') { try { incomingProps = JSON.parse(incomingProps); } catch (e) { incomingProps = {}; } } const effectiveProfile = (incomingProps && incomingProps._obj_profile) ? incomingProps._obj_profile : "cq_file"; let properties = { "_obj_profile": effectiveProfile, "_obj_name": file.name, "_obj_title": file.name, "_obj_date": isoDate, "_obj_text": obj_text, "_obj_source": 'webui', "_cq_doctype": "document", "_obj_type": "document", "_obj_lifecycle": "document", "_obj_lifecycle_state": "00 uploaded", "_add_to_clipboard": add_to_clipboard }; Object.assign(properties, incomingProps); properties['_relative_path'] = file._relativefilePath ? file._relativefilePath : (file.webkitRelativePath ? file.webkitRelativePath : ""); let uploadUrl = this.uploadURL ? this.uploadURL : "/dms/upload?type=file&profile=" + encodeURIComponent(effectiveProfile); if (this.parent) { uploadUrl = uploadUrl + "&parent=" + this.parent; } uploadUrl = uploadUrl + "&ticket=" + this.ticket; try { const t0 = performance.now(); const response = await uploadFile(uploadUrl, file, this.onprogress.bind(this), properties, {}, this.chunkSize); if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG upload-file-response', { name: file && file.name, elapsedMs: Math.round(performance.now() - t0), response: response }); } await this.callback(response); } catch (error) { console.error('Upload failed:', error); await this.callback({ status: 'error', error: error && error.message ? error.message : String(error || 'Upload failed'), file_name: file && file.name ? file.name : '' }); } } async upload() { const files = Array.from(this.filesToUpload || []).filter(file => !!file); if (!files.length) { return; } if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG batch-start', { count: files.length, parallel: !this.sequentialUpload, names: files.map(file => file.name) }); } this.isUploading = true; this.dragDepth = 0; this.batchExpected = files.length; this.batchCompleted = 0; this.lastBatchResponse = null; this.elem.classList.remove('drag-over'); try { if (this.sequentialUpload) { for (let idx = 0; idx < files.length; idx++) { await this.doUploadFile(files[idx], '', undefined, idx); } } else { await Promise.all(files.map((file, idx) => this.doUploadFile(file, '', undefined, idx))); } } finally { if (this.debugUpload) { console.info('CQ-UPLOAD-DEBUG batch-finish', { expected: this.batchExpected, completed: this.batchCompleted, lastBatchResponse: this.lastBatchResponse }); } this.isUploading = false; this.filesToUpload = []; this.batchExpected = 0; this.batchCompleted = 0; this.lastBatchResponse = null; this.elem.classList.remove('drag-over'); const versionDropRoot = this.elem.closest ? this.elem.closest('[data-cq-version-upload="1"]') : null; if (versionDropRoot) { versionDropRoot.classList.remove('drag-over'); } this.elem.style.backgroundColor = this.originalBackgroundColor; } } async uploadFiles(files) { this.filesToUpload = Array.from(files || []).filter(file => !!file); await this.upload(); } } function cqInitVersionUpload(root) { const scope = root && root.querySelectorAll ? root : document; const versionRoots = []; if (scope.matches && scope.matches('[data-cq-version-upload="1"]')) { versionRoots.push(scope); } scope.querySelectorAll('[data-cq-version-upload="1"]').forEach(el => versionRoots.push(el)); versionRoots.forEach(versionsRoot => { const dropzone = versionsRoot.querySelector('#versions_table_dropzone'); if (!dropzone || typeof DirectUploadHandler === 'undefined') return; const ticket = versionsRoot.getAttribute('data-cq-ticket') || ''; const oid = versionsRoot.getAttribute('data-cq-oid') || ''; const id = versionsRoot.getAttribute('data-cq-id') || oid; const nextVersion = versionsRoot.getAttribute('data-cq-next-version') || '2.0'; const detailsUrl = versionsRoot.getAttribute('data-cq-details-url') || '/webui/details'; if (!ticket || !oid) return; const nextVersionNumber = parseFloat(String(nextVersion).replace(',', '.')) || 2; function versionForUploadIndex(index) { return (nextVersionNumber + (index || 0)).toFixed(1); } function refreshVersions(url, req) { const target = document.getElementById(versionsRoot.id) || versionsRoot; if (!target || !window.htmx) return; req.target = target; req.select = '#versions'; req.swap = 'outerHTML'; htmx.ajax('GET', url, req); } const progress = versionsRoot.querySelector('[data-cq-version-upload-status]') || versionsRoot.querySelector('#spinner') || document.getElementById('spinner'); const handler = new DirectUploadHandler({ ticket: ticket, elem: dropzone, progress: progress, file_properties: function (file, index) { return {'_obj_version': versionForUploadIndex(index)}; }, uploadURL: '/dms/upload?id=' + encodeURIComponent(oid), chunkSize: 10 * 1024 * 1024, sequentialUpload: true, callbackActions: { url: detailsUrl + '?id=' + encodeURIComponent(id) + '&tab=2', scheduler: refreshVersions, select: '#versions', swap: 'outerHTML' } }); const uploadButton = versionsRoot.querySelector('[data-cq-version-upload-button]'); const fileInput = versionsRoot.querySelector('[data-cq-version-file-input]'); if (uploadButton && fileInput && !uploadButton.__cqVersionUploadButtonBound) { uploadButton.__cqVersionUploadButtonBound = true; uploadButton.addEventListener('click', function () { fileInput.click(); }); } if (fileInput && !fileInput.__cqVersionUploadInputBound) { fileInput.__cqVersionUploadInputBound = true; fileInput.addEventListener('change', async function () { const files = Array.from(fileInput.files || []); if (!files.length) return; try { if (progress) { progress.textContent = 'uploading...'; } await handler.uploadFiles(files); } finally { fileInput.value = ''; } }); } }); } window.cqInitVersionUpload = cqInitVersionUpload; function cqInitCheckoutUpload(root) { const scope = root && root.querySelectorAll ? root : document; const uploadRoots = []; if (scope.matches && scope.matches('[data-cq-checkout-upload="1"]')) { uploadRoots.push(scope); } scope.querySelectorAll('[data-cq-checkout-upload="1"]').forEach(el => uploadRoots.push(el)); uploadRoots.forEach(uploadRoot => { if (typeof DirectUploadHandler === 'undefined') return; const ticket = uploadRoot.getAttribute('data-cq-ticket') || ''; const id = uploadRoot.getAttribute('data-cq-id') || ''; const refreshUrl = uploadRoot.getAttribute('data-cq-refresh-url') || ('/webui/checkout?id=' + encodeURIComponent(id) + '&action=uploaded'); const returnUrl = uploadRoot.getAttribute('data-cq-return-url') || ''; const returnDelay = parseInt(uploadRoot.getAttribute('data-cq-return-delay') || '900', 10); if (!ticket || !id) return; function refreshCheckout(url, req) { if (!window.htmx) return; const refreshReq = Object.assign({}, req); delete refreshReq.scheduler; delete refreshReq.url; delete refreshReq.values; const shouldReturn = !!returnUrl; refreshReq.target = '#main'; refreshReq.select = '#main'; refreshReq.swap = 'outerHTML'; const result = htmx.ajax('GET', url, refreshReq); if (!shouldReturn || uploadRoot.__cqCheckoutReturnScheduled) return; uploadRoot.__cqCheckoutReturnScheduled = true; Promise.resolve(result).finally(function () { setTimeout(function () { const archiveReq = { target: '#main', select: '#main', swap: 'outerHTML', headers: { 'HX-Request': 'true' } }; const archiveResult = htmx.ajax('GET', returnUrl, archiveReq); Promise.resolve(archiveResult).finally(function () { try { window.history.pushState({}, '', returnUrl); } catch (_) {} }); }, Number.isFinite(returnDelay) ? returnDelay : 900); }); } const progress = uploadRoot.querySelector('[data-cq-checkout-upload-status]'); const handler = new DirectUploadHandler({ ticket: ticket, elem: uploadRoot, progress: progress, file_properties: {}, uploadURL: '/dms/upload?id=' + encodeURIComponent(id), chunkSize: 10 * 1024 * 1024, sequentialUpload: true, callbackActions: { url: refreshUrl, scheduler: refreshCheckout, select: '#main', swap: 'outerHTML' } }); const uploadButton = uploadRoot.querySelector('[data-cq-checkout-upload-button]'); const fileInput = uploadRoot.querySelector('[data-cq-checkout-file-input]'); if (uploadButton && fileInput && !uploadButton.__cqCheckoutUploadButtonBound) { uploadButton.__cqCheckoutUploadButtonBound = true; uploadButton.addEventListener('click', function () { fileInput.click(); }); } if (fileInput && !fileInput.__cqCheckoutUploadInputBound) { fileInput.__cqCheckoutUploadInputBound = true; fileInput.addEventListener('change', async function () { const files = Array.from(fileInput.files || []); if (!files.length) return; try { if (progress) { progress.textContent = 'uploading...'; } await handler.uploadFiles(files.slice(0, 1)); } finally { fileInput.value = ''; } }); } }); } window.cqInitCheckoutUpload = cqInitCheckoutUpload; function cqVersionDragHasFiles(event) { const types = event && event.dataTransfer && event.dataTransfer.types; if (!types) return false; try { return Array.from(types).indexOf('Files') !== -1; } catch (_) { return false; } } function cqVersionRootFromPoint(event) { if (!event) return null; const directRoot = event.target && event.target.closest ? event.target.closest('[data-cq-version-upload="1"]') : null; if (directRoot) return directRoot; const roots = Array.from(document.querySelectorAll('[data-cq-version-upload="1"]')); for (const root of roots) { const rect = root.getBoundingClientRect(); if ( event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom ) { return root; } } return null; } function cqSetVersionDragVisual(activeRoot) { document.querySelectorAll('[data-cq-version-upload="1"]').forEach(root => { const isActive = !!activeRoot && root === activeRoot; root.classList.toggle('drag-over', isActive); const dropzone = root.querySelector('#versions_table_dropzone'); if (dropzone) { dropzone.classList.toggle('drag-over', isActive); } }); } function cqInstallVersionDragVisuals() { if (window.__cqVersionDragVisualsInstalled) return; window.__cqVersionDragVisualsInstalled = true; document.addEventListener('dragenter', function (event) { if (!cqVersionDragHasFiles(event)) return; const root = cqVersionRootFromPoint(event); if (root) cqSetVersionDragVisual(root); }, true); document.addEventListener('dragover', function (event) { if (!cqVersionDragHasFiles(event)) return; const root = cqVersionRootFromPoint(event); cqSetVersionDragVisual(root); }, true); document.addEventListener('dragleave', function (event) { if (!cqVersionDragHasFiles(event)) return; const next = event.relatedTarget; if (next && next.closest && next.closest('[data-cq-version-upload="1"]')) return; setTimeout(function () { const hovered = document.querySelector('[data-cq-version-upload="1"]:hover'); if (!hovered) cqSetVersionDragVisual(null); }, 40); }, true); document.addEventListener('drop', function () { cqSetVersionDragVisual(null); }, true); document.addEventListener('dragend', function () { cqSetVersionDragVisual(null); }, true); } window.cqInstallVersionDragVisuals = cqInstallVersionDragVisuals; cqInstallVersionDragVisuals(); document.addEventListener('DOMContentLoaded', function () { cqInitVersionUpload(document); cqInitCheckoutUpload(document); cqInstallVersionDragVisuals(); }); document.addEventListener('htmx:afterSwap', function (event) { cqInitVersionUpload(document); cqInitCheckoutUpload(document); }); document.addEventListener('htmx:afterSettle', function (event) { cqInitVersionUpload(document); cqInitCheckoutUpload(document); }); function dropHandler(ev, folderId) { ev.preventDefault(); if (ev.dataTransfer.items) { let files = []; let items = ev.dataTransfer.items; for (let i = 0; i < items.length; i++) { let item = items[i]; if (item.webkitGetAsEntry) { let entry = item.webkitGetAsEntry(); if (entry && entry.isDirectory) { processDirectory(entry, item.name, files); } else if (entry && entry.isFile) { let file = item.getAsFile(); if (file) { files.push(file); } } } else if (item.kind === 'file') { let file = item.getAsFile(); if (file) { files.push(file); } } } document._filesToUpload = files; htmx.ajax('GET', `upload.html?id=${folderId}`, {target: '#body'}); } else { document._filesToUpload = ev.dataTransfer.files; htmx.ajax('GET', `upload.html?id=${folderId}`, {target: '#body'}); } } function dragOverHandler(ev) { ev.preventDefault(); } function submitOnEnter(event, id) { if (event.shiftKey && event.key == 'Enter') { event.preventDefault(); event.stopPropagation(); htmx.trigger(id, 'submit', {}); } } function submitOnEnter2(event, id) { if (event.shiftKey && event.key == 'Enter') { event.preventDefault(); event.stopPropagation(); document.getElementById(id).click(); } } function cqEnsureModalRoot() { // Prefer a dedicated modal root outside #main (survives HTMX swaps / detail overlay) return document.getElementById('modal-root') || document.getElementById('dialog') || document.body; } // Robust Bootstrap modal stacking (supports modal-in-modal) (function cqModalStacking(){ if (window.__cqModalStackingInstalled) return; window.__cqModalStackingInstalled = true; function getBaseZ() { // IMPORTANT: // Avoid "near int32 max" z-index values (2147483xxx). // Chrome clamps around 2147483647, and then backdrop/modal ordering can flip. // We keep a sane ladder: // cq-detail-backdrop ~ 1990 // cq-detail-layer ~ 2000 // bootstrap backdrops/modals start at 3000+ const detailLayer = document.getElementById('detail_layer'); const detailHasContent = !!(detailLayer && (detailLayer.innerHTML || '').trim()); return (document.body.classList.contains('cq-detail-open') || detailHasContent) ? 3000 : 1055; } function getTopModalZ(ignoreModal) { let top = getBaseZ(); document.querySelectorAll('.modal.show').forEach(m => { if (ignoreModal && m === ignoreModal) return; const z = parseInt(window.getComputedStyle(m).zIndex || '0', 10); if (!Number.isNaN(z)) top = Math.max(top, z); }); return top; } window.cqRaiseModal = function (modal) { if (!modal || !modal.classList || !modal.classList.contains('modal')) return; const z = getTopModalZ(modal) + 20; modal.style.zIndex = String(z); const root = cqEnsureModalRoot(); if (root && modal.parentElement !== root) { root.appendChild(modal); } const backdrops = Array.from(document.querySelectorAll('.modal-backdrop')); const bd = backdrops.length ? backdrops[backdrops.length - 1] : null; if (bd) bd.style.zIndex = String(z - 10); }; document.addEventListener('show.bs.modal', function (e) { const modal = e.target; if (!modal || !modal.classList || !modal.classList.contains('modal')) return; window.cqRaiseModal(modal); [0, 50, 150].forEach(function (delay) { setTimeout(function () { window.cqRaiseModal(modal); }, delay); }); }); // When hiding one modal but others remain open, keep body.modal-open so scrolling doesn't break document.addEventListener('hidden.bs.modal', function () { setTimeout(function () { const anyOpen = !!document.querySelector('.modal.show'); if (anyOpen) document.body.classList.add('modal-open'); }, 0); }); })(); function showDialog(url, cssSelector) { const root = cqEnsureModalRoot(); if (!root || !window.htmx || typeof window.htmx.ajax !== 'function') { return Promise.resolve(null); } try { root.querySelectorAll(cssSelector).forEach(function (existingEl) { if (existingEl.classList && existingEl.classList.contains('show')) return; const staleSlot = existingEl.closest('[data-cq-modal-slot="1"]'); if (staleSlot && staleSlot.parentNode) { staleSlot.parentNode.removeChild(staleSlot); } else if (existingEl.parentNode) { existingEl.parentNode.removeChild(existingEl); } }); } catch (_) {} window.__cqDialogInflight = window.__cqDialogInflight || new Set(); const inflightKey = String(cssSelector || '#viewerModal') + '::' + String(url || ''); if (window.__cqDialogInflight.has(inflightKey)) { return Promise.resolve(null); } window.__cqDialogInflight.add(inflightKey); const slot = document.createElement('div'); slot.setAttribute('data-cq-modal-slot', '1'); root.appendChild(slot); function cqSanitizeDialogHtml(html) { let out = String(html || ''); // Old portal edit templates could render bytes tab ids as b'main'. htmx // builds selectors from response ids before swap and crashes on quotes. out = out.replace(/(cq-edit-tab-)(?:\\?['"])?b\\?['"]([^'"\\<>]+)\\?['"]b(?:\\?['"])?/g, '$1$2'); out = out.replace(/(cq-edit-tab-)(?:&(?:#x27|#39|apos);)?b(?:&(?:#x27|#39|apos);)([^&"'<>\s]+)(?:&(?:#x27|#39|apos);)b(?:&(?:#x27|#39|apos);)?/g, '$1$2'); return out; } function cqSanitizeInlineScriptSource(source) { return String(source || '').replace( /\b(?:const|let)\s+(cqParentBackUrl|cqParentBackdropUrl)\s*=/g, 'window.$1 =' ); } function cqExecuteDialogScripts(container) { try { Array.from(container.querySelectorAll('script')).forEach(function (oldScript) { const script = document.createElement('script'); Array.from(oldScript.attributes || []).forEach(function (attr) { script.setAttribute(attr.name, attr.value); }); script.text = cqSanitizeInlineScriptSource(oldScript.textContent); oldScript.parentNode.insertBefore(script, oldScript); oldScript.parentNode.removeChild(oldScript); }); } catch (_) {} } const useRawFetch = String(url || '').indexOf('/portal/contract-documents') !== -1; const loadDialog = useRawFetch ? fetch(url, { credentials: 'same-origin', headers: { 'HX-Request': 'true', 'X-Requested-With': 'XMLHttpRequest' } }).then(function (response) { if (!response.ok) throw new Error('Dialog request failed'); return response.text(); }).then(function (html) { slot.innerHTML = cqSanitizeDialogHtml(html); cqExecuteDialogScripts(slot); if (window.htmx && typeof window.htmx.process === 'function') { window.htmx.process(slot); } }) : htmx.ajax('GET', url, { target: slot, swap: 'innerHTML' }); return loadDialog.then(() => { const el = slot.querySelector(cssSelector); if (!el) { if (slot.parentNode) slot.parentNode.removeChild(slot); return null; } const cleanupAfterHide = function () { try { if (window.bootstrap && bootstrap.Modal && typeof bootstrap.Modal.getInstance === 'function') { const inst = bootstrap.Modal.getInstance(el); if (inst) inst.dispose(); } } catch (_) {} try { if (slot.parentNode) { slot.parentNode.removeChild(slot); } else if (el.parentNode) { el.parentNode.removeChild(el); } } catch (_) {} setTimeout(function () { const anyOpen = !!document.querySelector('.modal.show'); if (!anyOpen) { document.querySelectorAll('.modal-backdrop').forEach(function (bd) { bd.remove(); }); document.body.classList.remove('modal-open'); document.body.style.removeProperty('padding-right'); } }, 0); }; try { el.addEventListener('hidden.bs.modal', cleanupAfterHide, { once: true }); } catch (_) {} // Bootstrap API preferred; fall back to jQuery if present. // Value assistance can open from the detail drawer; the drawer already owns // the page backdrop, so a second Bootstrap backdrop may cover the picker. const isValueAssistance = cssSelector === '#valueAssistanceModal'; if (isValueAssistance) { el.setAttribute('data-bs-backdrop', 'false'); } const modalOptions = { backdrop: isValueAssistance ? false : true, focus: true }; try { const inst = bootstrap.Modal.getOrCreateInstance(el, modalOptions); if (window.cqRaiseModal) window.cqRaiseModal(el); inst.show(); if (window.cqRaiseModal) { [0, 50, 150, 300].forEach(function (delay) { setTimeout(function () { window.cqRaiseModal(el); }, delay); }); } try { if (window.cqInitInvoiceForms) { setTimeout(function () { window.cqInitInvoiceForms(el); }, 0); } } catch (_) {} return inst; } catch (_) { if (window.$ && $(el).modal) { $(el).one('hidden.bs.modal', cleanupAfterHide); if (window.cqRaiseModal) window.cqRaiseModal(el); $(el).modal(isValueAssistance ? modalOptions : 'show'); if (window.cqRaiseModal) { [0, 50, 150, 300].forEach(function (delay) { setTimeout(function () { window.cqRaiseModal(el); }, delay); }); } } try { if (window.cqInitInvoiceForms) { setTimeout(function () { window.cqInitInvoiceForms(el); }, 0); } } catch (_) {} return null; } }).catch(function () { try { if (slot.parentNode) slot.parentNode.removeChild(slot); } catch (_) {} return null; }).finally(function () { try { window.__cqDialogInflight.delete(inflightKey); } catch (_) {} }); } // Ensure modal-window links always open as Bootstrap modal, even after HTMX table swaps. (function cqModalLinkInterceptor() { if (window.__cqModalLinkInterceptorInstalled) return; window.__cqModalLinkInterceptorInstalled = true; document.addEventListener('click', function (ev) { const trigger = ev.target && ev.target.closest ? ev.target.closest('[hx-get]') : null; if (!trigger) return; if (trigger.getAttribute('data-cq-no-modal-intercept') === '1') return; const hxGet = (trigger.getAttribute('hx-get') || '').trim(); if (!hxGet) return; const low = hxGet.toLowerCase(); const explicit = trigger.getAttribute('data-cq-open-modal') === '1'; const isModalUrl = low.indexOf('modal_window.html') !== -1 || low.indexOf('invoice_modal_window.html') !== -1; if (!explicit && !isModalUrl) return; const selector = trigger.getAttribute('data-cq-modal-selector') || '#viewerModal'; ev.preventDefault(); ev.stopPropagation(); showDialog(hxGet, selector); }, true); })(); // Portal contract lists keep their legacy document URL as fallback, but normal clicks // should open the linked contract documents in the same modal pattern as admin pages. (function cqPortalContractDocumentsInterceptor() { if (window.__cqPortalContractDocumentsInterceptorInstalled) return; window.__cqPortalContractDocumentsInterceptorInstalled = true; function getPortalContractDocumentsUrl(link) { if (!link) return ''; const href = (link.getAttribute('href') || '').trim(); if (!href) return ''; const url = new URL(href, window.location.href); const isPortalView = url.pathname === '/portal/view' || (url.pathname === '/view' && window.location.pathname.indexOf('/portal/') === 0); if (!isPortalView) return ''; const listId = (url.searchParams.get('listx') || url.searchParams.get('list') || '').trim(); const objId = (url.searchParams.get('id') || '').trim(); if (!objId || !/^list_cm_/i.test(listId)) return ''; return '/portal/contract-documents?id=' + encodeURIComponent(objId) + '&listx=' + encodeURIComponent(listId); } function normalizePortalContractDocumentLinks(root) { try { const scope = root && root.querySelectorAll ? root : document; scope.querySelectorAll('a[href]').forEach(function (link) { const modalUrl = getPortalContractDocumentsUrl(link); if (!modalUrl) return; link.setAttribute('href', modalUrl); link.removeAttribute('target'); link.setAttribute('data-cq-contract-documents-modal', '1'); if (!link.getAttribute('title') || link.getAttribute('title') === 'Document') { link.setAttribute('title', 'Documents'); } if (!link.getAttribute('aria-label') || link.getAttribute('aria-label') === 'Document') { link.setAttribute('aria-label', 'Documents'); } }); } catch (_) {} } function installPortalContractDocumentsInterceptor() { normalizePortalContractDocumentLinks(document); if (document.body) { document.body.addEventListener('htmx:afterSwap', function (ev) { normalizePortalContractDocumentLinks(ev && ev.target ? ev.target : document); }); } document.addEventListener('draw.dt', function (ev) { normalizePortalContractDocumentLinks(ev && ev.target ? ev.target : document); }); } if (document.body) { installPortalContractDocumentsInterceptor(); } else { document.addEventListener('DOMContentLoaded', installPortalContractDocumentsInterceptor, { once: true }); } document.addEventListener('click', function (ev) { try { const link = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null; if (!link || ev.defaultPrevented) return; if (ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; let modalUrl = link.getAttribute('data-cq-contract-documents-modal') === '1' ? (link.getAttribute('href') || '').trim() : getPortalContractDocumentsUrl(link); if (!modalUrl) return; if (typeof window.showDialog !== 'function') return; ev.preventDefault(); ev.stopPropagation(); if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation(); window.showDialog(modalUrl, '#viewerModal'); } catch (_) {} }, true); })(); function getVAFilterFields(fields, contextEl) { const data = {}; const ids = Array.isArray(fields) ? fields : []; const roots = []; const evtTarget = (function () { try { const ev = window.event; return ev && (ev.target || ev.srcElement) ? (ev.target || ev.srcElement) : null; } catch (_) { return null; } })(); const pushRoot = (node) => { if (!node || !node.querySelector || roots.includes(node)) return; roots.push(node); }; const getClosest = (node, selector) => { try { return node && node.closest ? node.closest(selector) : null; } catch (_) { return null; } }; const active = document.activeElement; pushRoot(getClosest(evtTarget, 'form')); pushRoot(getClosest(evtTarget, '[data-cq-tab-host]')); pushRoot(getClosest(evtTarget, '#detail_layer')); pushRoot(getClosest(contextEl, 'form')); pushRoot(getClosest(active, 'form')); pushRoot(getClosest(contextEl, '.modal.show')); pushRoot(getClosest(active, '.modal.show')); pushRoot(document.querySelector('.modal.show')); pushRoot(getClosest(contextEl, '#detail_layer')); pushRoot(getClosest(active, '#detail_layer')); pushRoot(document.getElementById('detail_layer')); const pickElementById = (id) => { const selector = `[id="${String(id).replace(/"/g, '\\"')}"]`; for (const root of roots) { const found = root.querySelector(selector); if (found) return found; } const all = Array.from(document.querySelectorAll(selector)); if (!all.length) return null; const visible = all.find((el) => { try { return !!(el.offsetParent || (el.getClientRects && el.getClientRects().length)); } catch (_) { return false; } }); return visible || all[0]; }; ids.forEach((id) => { const element = pickElementById(id); if (!element) return; const name = element.name; if (!name) return; data[name] = element.value; }); return encodeURIComponent(JSON.stringify(data)); } var ValidationController = window.ValidationController; function showDrawer(url, cssSelector) { htmx.ajax('GET', url, {target: '#dialog', swap: 'innerHTML'}).then(() => { $(cssSelector).modal('show'); }); } function closeDialog(cssSelector) { $(cssSelector).modal('hide'); } function closeDialog2(cssSelector) { let dialog = document.querySelector(cssSelector); dialog.style.display = 'none'; let body = document.querySelector('body'); body.style = ''; body.className = ''; let backdrop = document.querySelector('.modal-backdrop'); if (backdrop) { backdrop.remove(); } } function _showPopup(pageX, pageY) { const popup = document.querySelector('#popup'); window.addEventListener('click', function (event) { if (!popup.contains(event.target) && event.target !== popup) { closePopup(); } }, {once: true}); const menuWidth = popup.offsetWidth; const menuHeight = popup.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let top = pageY; let left = pageX; if (left + menuWidth + 5 > viewportWidth) { left -= menuWidth; } if (top + menuHeight + 5 > viewportHeight) { top -= menuHeight; } if (left < 5) left = 5; if (top < 5) top = 5; popup.style.left = left + 'px'; popup.style.top = top + 'px'; popup.style.display = 'block'; } function showPopup(e, url) { htmx.ajax('GET', url, {target: '#popup', swap: 'innerHTML'}).then(() => { _showPopup(e.pageX, e.pageY); }); } function closePopup() { const popup = document.querySelector('#popup'); popup.style.display = 'none'; } function splitPanel(container) { let children = container.children; let leftPanel = children[0]; let divider = children[1]; let isResizing = false; const stopResize = (e) => { isResizing = false; document.removeEventListener('pointerup', stopResize, false); document.removeEventListener('pointermove', doResize, false); } const doResize = (e) => { let containerRect = container.getBoundingClientRect(); let leftWidth = e.clientX - containerRect.left; leftPanel.style.flexBasis = `${leftWidth}px`; } divider.addEventListener('pointerdown', (e) => { isResizing = true; document.addEventListener('pointerup', stopResize); document.addEventListener('pointermove', doResize); }); } function toggle_full_screen() { if (!document.fullscreenElement) { if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen(); } else if (document.documentElement.mozRequestFullScreen) { document.documentElement.mozRequestFullScreen(); } else if (document.documentElement.webkitRequestFullscreen) { document.documentElement.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); } else if (document.documentElement.msRequestFullscreen) { document.documentElement.msRequestFullscreen(); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.webkitCancelFullScreen) { document.webkitCancelFullScreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } } } function copyToClipboard(copyText) { navigator.clipboard.writeText(copyText); } /* Updated initTagify to prevent double initialization and avoid undefined whitelist errors */ function initTagify(querySelector, whiteList = [], enforceWhiteList = false) { // Find the input element const inputElement = document.querySelector(querySelector); if (!inputElement) return; // Prevent double initialization: reuse existing instance if present if (inputElement._tagify) { // Optionally update the whitelist settings inputElement._tagify.settings.whitelist = Array.isArray(whiteList) ? whiteList : []; inputElement._tagify.settings.enforceWhitelist = enforceWhiteList; if (inputElement._tagify.dropdown) { inputElement._tagify.settings.dropdown = Object.assign({}, inputElement._tagify.settings.dropdown || {}, { enabled: 0, maxItems: 200, closeOnSelect: false, highlightFirst: true }); } return inputElement._tagify; } // Remove any leftover container if (inputElement.previousSibling && inputElement.previousSibling.tagName === 'TAGS') { inputElement.previousSibling.remove(); } // Initialize Tagify with a safe default whitelist const tagify = new Tagify(inputElement, { whitelist: Array.isArray(whiteList) ? whiteList : [], enforceWhitelist: enforceWhiteList, dropdown: { enabled: 0, maxItems: 200, closeOnSelect: false, highlightFirst: true } }); // Store the instance to avoid re-init inputElement._tagify = tagify; inputElement.classList.add('cq-tagify-picker-source'); if (tagify.DOM && tagify.DOM.scope) { tagify.DOM.scope.classList.add('cq-tagify-picker'); tagify.DOM.scope.setAttribute('title', 'Click to choose values'); } function showAllTagifyOptions() { try { tagify.dropdown.show.call(tagify, ''); } catch (e) { // ignore } } if (tagify.DOM && tagify.DOM.scope) { tagify.DOM.scope.addEventListener('click', showAllTagifyOptions); } if (tagify.DOM && tagify.DOM.input) { tagify.DOM.input.addEventListener('focus', showAllTagifyOptions); tagify.DOM.input.addEventListener('click', showAllTagifyOptions); } // Setup drag-and-drop reordering function onDragEnd() { tagify.updateValueByDOMTags(); } new DragSort(tagify.DOM.scope, { selector: '.' + tagify.settings.classNames.tag, callbacks: { dragEnd: onDragEnd } }); return tagify; } function initTypeahead(selector, dataUrl) { // Initialize Bloodhound suggestion engine var bloodhound = new Bloodhound({ /* datumTokenizer: function(datum) { return Bloodhound.tokenizers.whitespace(datum.value + ' ' + datum.display); }, queryTokenizer: Bloodhound.tokenizers.whitespace, */ datumTokenizer: function(datum) { return [datum.value]; // Return full value as single token }, queryTokenizer: function(query) { return [query]; // Return full query as single token }, remote: { url: dataUrl, wildcard: '%QUERY', rateLimitBy: 'throttle', rateLimitWait: 300 } }); // Initialize typeahead $(selector).typeahead({ minLength: 2, highlight: true, hint: true }, { name: 'suggestions', display: 'value', source: bloodhound, limit: 10, templates: { empty: [ '
', 'No matches found', '
' ].join('\n'), suggestion: function(data) { return '
' + data.display + '
'; } } }); // Event handlers $(selector) .on('typeahead:select', function(ev, suggestion) { $(this).typeahead('val', suggestion.value); ev.target.dispatchEvent(new Event('input')); }) .on('typeahead:asyncrequest', function() { }) .on('typeahead:asyncreceive', function() { }) .on('typeahead:asynccancel', function() { }); } var tableSelection = window.tableSelection || (window.tableSelection = {}); function cqSelectionKeysToIds(keys) { if (window.CQDatatable && typeof window.CQDatatable.keysToIds === 'function') { return window.CQDatatable.keysToIds(keys); } return (keys || []) .filter(key => typeof key === 'string' && key.startsWith('chk_')) .map(key => key.replace(/^chk_/, '')); } function cqGetCheckedSelectionKeysFromDom() { if (window.CQDatatable && typeof window.CQDatatable.getCheckedKeys === 'function') { return window.CQDatatable.getCheckedKeys(document); } return Array.from(document.querySelectorAll('.row-checkbox:checked')) .map(el => el && el.id) .filter(Boolean); } function cqPruneTableSelectionToDom() { if (window.CQDatatable && typeof window.CQDatatable.pruneToDom === 'function') { window.CQDatatable.pruneToDom(document); return; } const checkedKeys = new Set(cqGetCheckedSelectionKeysFromDom()); Object.keys(tableSelection).forEach(function(key) { if (!checkedKeys.has(key)) { delete tableSelection[key]; } }); } function cqGetSelectedIds() { if (window.CQDatatable && typeof window.CQDatatable.getSelectedIds === 'function') { return window.CQDatatable.getSelectedIds({ scope: document, preferDom: true }); } // DOM is the single source of truth for destructive/export actions. cqPruneTableSelectionToDom(); return cqSelectionKeysToIds(Object.keys(tableSelection)); } function cqClearTableSelection(uncheckDom = true) { if (window.CQDatatable && typeof window.CQDatatable.clearSelection === 'function') { window.CQDatatable.clearSelection({ scope: document, uncheckDom: !!uncheckDom, clearStore: true }); return; } Object.keys(tableSelection).forEach(function(key) { delete tableSelection[key]; }); if (uncheckDom) { document.querySelectorAll('.row-checkbox:checked').forEach(function(cb) { cb.checked = false; }); document.querySelectorAll('input[type="checkbox"][id^="all_"], input[type="checkbox"][id*="all_"]').forEach(function(cb) { cb.checked = false; }); } showHideDropdownMenu(false); } if (!window.__cqSelectionGuardInstalled) { window.__cqSelectionGuardInstalled = true; const bindSelectionGuard = function() { const root = document.body || document; if (!root || !root.addEventListener) return; root.addEventListener('htmx:beforeSwap', function(evt) { const t = evt && evt.detail && evt.detail.target; if (t && t.id === 'main') { cqClearTableSelection(false); } }); root.addEventListener('htmx:afterSwap', function(evt) { const t = evt && evt.detail && evt.detail.target; if (t && t.id === 'main') { cqClearTableSelection(true); } }); }; if (document.body) { bindSelectionGuard(); } else { document.addEventListener('DOMContentLoaded', bindSelectionGuard); } } if (!window.__cqRefreshJobs) { window.__cqRefreshJobs = {}; } if (!window.cqScheduleRefresh) { window.cqScheduleRefresh = function(key, runner, opts) { if (!key || typeof runner !== 'function') return; const options = opts || {}; const minIntervalMs = Math.max(0, Number(options.minIntervalMs || 0)); const delayMs = Math.max(0, Number(options.delayMs || 0)); const jobs = window.__cqRefreshJobs || (window.__cqRefreshJobs = {}); const job = jobs[key] || (jobs[key] = { timer: null, inFlight: false, pending: false, lastRunTs: 0, runner: null, opts: {} }); job.runner = runner; job.opts = options; if (job.inFlight) { job.pending = true; return; } if (job.timer) { window.clearTimeout(job.timer); job.timer = null; } const now = Date.now(); const elapsed = Math.max(0, now - Number(job.lastRunTs || 0)); const waitMs = Math.max(delayMs, minIntervalMs - elapsed); job.timer = window.setTimeout(function() { job.timer = null; if (job.inFlight) { job.pending = true; return; } job.inFlight = true; Promise.resolve(job.runner()) .catch(function() { }) .finally(function() { job.inFlight = false; job.lastRunTs = Date.now(); if (job.pending) { job.pending = false; window.cqScheduleRefresh(key, job.runner, job.opts); } }); }, waitMs); }; } function toggleAllCheckboxes(source) { const checkboxes = document.querySelectorAll('.row-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = source.checked; updateTableSelection(checkbox.id, source.checked); }); } function checkboxChanged(objId, evt) { const isChecked = evt.target.checked; updateTableSelection(objId, isChecked); } function showHideDropdownMenu(hasSelections) { const targetDiv = document.querySelector('#header_dropdown_menu > span > div'); if (targetDiv) { if (hasSelections) { targetDiv.classList.add('inline-block'); targetDiv.classList.remove('d-none'); } else { targetDiv.classList.remove('inline-block'); targetDiv.classList.add('d-none'); } } } function updateTableSelection(objId, isChecked) { if (window.CQDatatable && typeof window.CQDatatable.syncCheckbox === 'function') { window.CQDatatable.syncCheckbox({ id: objId, checked: !!isChecked }, !!isChecked); } else if (isChecked) { tableSelection[objId] = true; } else { delete tableSelection[objId]; } cqPruneTableSelectionToDom(); const hasSelections = Object.keys(tableSelection).length > 0; showHideDropdownMenu(hasSelections); } async function addToFavorites(overview_url) { let selection = cqGetSelectedIds(); for (let id of selection) { try { await fetch(`{overview_url}?action=add_favorite&obj_link={details_url}?id=${id}&obj_id=${id}&obj_name=${id}`); } catch (error) { console.error(`Error adding id ${id} to favorites:`, error); } } htmx.ajax('GET', overview_url, {target: "#main", swap: "outerHTML", select: "#main"}); } function cqEnsureDeleteIndicator() { let el = document.getElementById('cq-delete-job-indicator'); if (el) return el; el = document.createElement('div'); el.id = 'cq-delete-job-indicator'; el.style.position = 'fixed'; el.style.right = '1rem'; el.style.bottom = '1rem'; el.style.zIndex = '2147483000'; el.style.minWidth = '260px'; el.style.maxWidth = '360px'; el.style.padding = '10px 12px'; el.style.borderRadius = '10px'; el.style.background = 'rgba(20,20,24,0.95)'; el.style.color = '#fff'; el.style.boxShadow = '0 10px 24px rgba(0,0,0,0.35)'; el.style.fontSize = '12px'; el.style.lineHeight = '1.35'; el.style.display = 'none'; el.innerHTML = '
' + ' ' + ' Löschen...' + '
' + '
0/0
' + '
' + '
' + '
'; document.body.appendChild(el); return el; } function cqUpdateDeleteIndicator(done, total, failed) { const root = cqEnsureDeleteIndicator(); if (!root) return; root.style.display = 'block'; const text = root.querySelector('#cq-delete-job-text'); const bar = root.querySelector('#cq-delete-job-bar'); const title = root.querySelector('#cq-delete-job-title'); const safeTotal = Math.max(Number(total || 0), 1); const pct = Math.max(0, Math.min(100, Math.round((Number(done || 0) / safeTotal) * 100))); if (title) title.textContent = failed ? `Löschen... (${failed} Fehler)` : 'Löschen...'; if (text) text.textContent = `${done}/${total} verarbeitet`; if (bar) bar.style.width = `${pct}%`; } function cqHideDeleteIndicator() { const root = document.getElementById('cq-delete-job-indicator'); if (!root) return; root.style.display = 'none'; } async function cqRunBulkDelete(selection, overview_url, deleteFn) { const amount = selection.length; if (!amount) return; if (window.__cqBulkDeleteRunning) { if (window.cqToast && window.cqToast.warning) { window.cqToast.warning('Loeschvorgang laeuft bereits.', { delay: 2200 }); } return; } window.__cqBulkDeleteRunning = true; document.dispatchEvent(new CustomEvent('cq:bulk-delete:start', { detail: { total: amount } })); let done = 0; let failed = 0; cqUpdateDeleteIndicator(done, amount, failed); try { for (const id of selection) { try { const resp = await deleteFn(id); if (!resp || !resp.ok) failed += 1; } catch (error) { failed += 1; console.error(`Error deleting entry with id ${id}:`, error); } finally { done += 1; cqUpdateDeleteIndicator(done, amount, failed); document.dispatchEvent(new CustomEvent('cq:bulk-delete:progress', { detail: { done: done, total: amount, failed: failed } })); await new Promise(resolve => window.setTimeout(resolve, 0)); } } htmx.ajax('GET', overview_url, { target: "#main", swap: "outerHTML", select: "#main" }); } finally { window.__cqBulkDeleteRunning = false; document.dispatchEvent(new CustomEvent('cq:bulk-delete:end', { detail: { done: done, total: amount, failed: failed } })); window.setTimeout(cqHideDeleteIndicator, 900); } } function cqBuildActionUrl(baseUrl, params) { const url = new URL(baseUrl || window.location.href, window.location.origin); Object.entries(params || {}).forEach(function(entry) { const key = entry[0]; const value = entry[1]; if (value !== undefined && value !== null && String(value) !== '') { url.searchParams.set(key, value); } }); return url.toString(); } async function deleteEntries(overview_url) { const selection = cqGetSelectedIds(); const amount = selection.length; // Get the number of selected entries if (!amount) { alert("Bitte waehle mindestens einen Eintrag aus."); return; } const confirmDeletion = window.confirm(`Are you sure that you want to delete the selected ${amount} entries?`); if (!confirmDeletion) { return; } await cqRunBulkDelete(selection, overview_url, function(id) { return fetch(cqBuildActionUrl(overview_url, { action: 'set_delete', oid: id }), { method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'HX-Request': 'true' } }); }); } async function undeleteEntries(overview_url) { let selection = cqGetSelectedIds(); let amount = selection.length; // Get the number of selected entries if (!amount) { alert("Bitte waehle mindestens einen Eintrag aus."); return; } let confirmDeletion = window.confirm(`Are you sure that you want to undelete the selected ${amount} entries?`); if (!confirmDeletion) { return; } for (let id of selection) { try { await fetch(cqBuildActionUrl(overview_url, { action: 'set_undelete', oid: id })); } catch (error) { console.error(`Error undeleting entry with id ${id}:`, error); } } // After all deletions, refresh the page htmx.ajax('GET', overview_url, {target: "#main", swap: "outerHTML", select: "#main"}); } async function mergeEntries(overview_url) { // Gather selected IDs (removing any 'chk_' prefix) const selection = cqGetSelectedIds(); const amount = selection.length; if (!amount) { alert("Bitte waehle mindestens einen Eintrag aus."); return; } // Confirm the merge action const confirm = window.confirm(`Are you sure that you want to merge the selected ${amount} entries?`); if (!confirm) { // If the user cancels, exit early return; } try { await fetch(`${overview_url}?action=merge_pdf&selection=` + selection); } catch (error) { console.error(`Error merging entry with selection ${selection}:`, error); } } async function splitEntries(overview_url) { // 1) Selektion sammeln const selection = cqGetSelectedIds(); const amount = selection.length; if (!amount) { alert("No entries selected."); return; } // 2) Split-Modus wählen const mode = window.prompt( "Split mode:\n" + "1 = Splitte jede N Seite\n" + "2 = nach Seitenbereichen aufgeteilt (z. B. 1-3, 4-6)\n\n" + "Geben Sie 1 oder 2 ein:", "1" ); if (!mode) return; let params = new URLSearchParams(); params.set("action", "split_pdf"); params.set("selection", selection.join(",")); if (mode === "1") { // 3a) Split alle N Seiten const everyN = window.prompt( "Alle wie viele Seiten teilen?", "1" ); if (!everyN || isNaN(everyN) || Number(everyN) <= 0) { alert("Ungültige Seitenanzahl."); return; } params.set("mode", "every_n_pages"); params.set("every_n_pages", everyN); } else if (mode === "2") { // 3b) Split nach expliziten Bereichen const ranges = window.prompt( "Geben Sie Seitenbereiche ein (beginnend mit 1), z. B.\n" + "1-3,4-6\n\n" + "Jeder Bereich erstellt eine PDF-Datei.:", "" ); if (!ranges) return; params.set("mode", mode); params.set("ranges", ranges); params.set("parts", ranges); } else { alert("Ungültiger Modus."); return; } // 4) Bestätigung const confirm = window.confirm( `Are you sure you want to split ${amount} selected entr${amount === 1 ? "y" : "ies"}?` ); if (!confirm) return; // 5) Backend-Aufruf try { const response = await fetch(`${overview_url}?${params.toString()}`, { method: "POST" }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } // Optional: reload / refresh // location.reload(); } catch (error) { console.error("Error splitting entries:", error); alert("Fehler beim Teilen von PDF-Dateien. Details finden Sie in der Konsole."); } } function sendResetPassword(id) { try { let url = `/webui/user?id=${id}&action=send_reset_password`; fetch(url); alert('E-Mail versendet'); } catch (error) { console.error(`Error deleting entry with id ${id}:`, error); } } async function showVersionDialog(obj_id, version_comment) { const referer = window.location.href; const url = `/invoice/includes/modal_window.html?incl=change_version_comment&referer=${encodeURIComponent(referer)}&obj_id=${obj_id}&obj_version_comment=${encodeURIComponent(version_comment)}&title=Change version comment`; const response = await showDialog(url, '#viewerModal'); // Pass the URL into showDialog if (response.ok) { window.location.reload(); } else { console.error("Server returned error:", response.status); } } async function changeProfile() { const profileEl = document.getElementById('obj_profile'); if (!profileEl) return; const profile = profileEl.value; const urlParams = new URLSearchParams(window.location.search); function readFirstValue(selectors) { for (const s of selectors) { const el = document.querySelector(s); if (!el) continue; const v = (el.value || el.getAttribute('value') || '').trim(); if (v) return v; } return ''; } function parseParamFromUrl(raw, key) { if (!raw) return ''; try { const u = new URL(raw, window.location.origin); return (u.searchParams.get(key) || '').trim(); } catch (_) { return ''; } } // IMPORTANT: // In drawer mode window.location often still points to the parent list URL. // Therefore prefer IDs from current detail form / current detail link. const detailHref = (window._cqDetailHref || '').trim(); const id = readFirstValue([ '#_obj_id', 'input[name="_obj_id"]', '#obj_id', 'input[name="obj_id"]', 'input[name="id"]' ]) || parseParamFromUrl(detailHref, 'id') || (urlParams.get("id") || '').trim(); const parent = readFirstValue([ '#_parent', 'input[name="_parent"]', 'input[name="parent"]' ]) || parseParamFromUrl(detailHref, 'parent') || (urlParams.get("parent") || '').trim(); if (!id) return; try { // 1) Persist profile change using archive action (known backend save path) const saveUrl = `/webui/archive?id=${encodeURIComponent(id)}&action=change_profile&profile=${encodeURIComponent(profile)}`; const saveResponse = await fetch(saveUrl, { method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'HX-Request': 'true' } }); if (!saveResponse.ok) { console.error("Profile change save failed:", saveResponse.status); return; } // 2) Reload detail mask for the same object (dialog stays open) const detailUrl = `/webui/details?id=${encodeURIComponent(id)}${parent ? `&parent=${encodeURIComponent(parent)}` : ''}&profile=${encodeURIComponent(profile)}&profile_changed=1`; if (window.htmx && typeof window.htmx.ajax === 'function') { const detailLayer = document.getElementById('detail_layer'); const modalBody = document.querySelector('#viewerModal .modal-body'); const isDetailOpen = document.body.classList.contains('cq-detail-open') || (!!detailLayer && String(detailLayer.innerHTML || '').trim().length > 0); if (isDetailOpen && detailLayer) { window.htmx.ajax('GET', detailUrl, { target: '#detail_layer', swap: 'innerHTML' }); return; } if (modalBody) { window.htmx.ajax('GET', detailUrl, { target: '#viewerModal .modal-body', swap: 'innerHTML' }); return; } window.htmx.ajax('GET', detailUrl, { target: '#main', select: '#main', swap: 'outerHTML' }); return; } // Fallback only if htmx is not available window.location.assign(detailUrl); } catch (error) { console.error(`Error changing profile for entry with id ${id}:`, error); } } async function deleteTableEntries(overview_url, aspect = null) { // Auswahl extrahieren let selection = cqGetSelectedIds(); let amount = selection.length; // Wenn keine Checkbox ausgewaehlt wurde if (amount === 0) { alert("Bitte waehle mindestens einen Eintrag aus, den du loeschen moechtest."); return; } // Bestaetigungsdialog let confirmDeletion = window.confirm( `Are you sure that you want to delete the selected ${amount} entries?` ); if (!confirmDeletion) return; await cqRunBulkDelete(selection, overview_url, function(id) { return fetch(cqBuildActionUrl(overview_url, { id: id, action: 'delete', aspect: aspect }), { method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'HX-Request': 'true' } }); }); } /* function safeName(s){ return (s||'').replace(/[\\/:*?"<>|]+/g,'_').trim(); } await fetch(`${overview_url}?action=ai&id=${id}&selection=${selection}`); htmx.ajax('GET', overview_url, {target: "#main", select: "#main", swap: "outerHTML"}); } */ async function downloadAsZip() { const zip = new JSZip(); const ids = cqGetSelectedIds(); if (!ids.length) { alert('Keine Einträge ausgewählt'); return; } // Optional: Titel aus der Tabelle holen (data-title), sonst ID const titleById = new Map( Array.from(document.querySelectorAll('a[data-id][data-title]')) .map(a => [a.dataset.id, a.dataset.title]) ); // data-id/data-title sind in den Zellen vorhanden :contentReference[oaicite:5]{index=5} for (const id of ids) { try { const url = `/dms/content?id=${encodeURIComponent(id)}`; // echter Download :contentReference[oaicite:6]{index=6} const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) { console.error('Download failed', id, res.status); continue; } const blob = await res.blob(); const titleRaw = titleById.get(id) || id; const title = String(titleRaw).replace(/[\\/:*?"<>|]+/g, '_').trim(); const ct = (res.headers.get('content-type') || '').toLowerCase(); const ext = ct.includes('pdf') ? 'pdf' : ct.includes('jpeg') ? 'jpg' : ct.includes('png') ? 'png' : 'bin'; zip.file(`${title}.${ext}`, blob); } catch (e) { console.error('Error', id, e); } } const stamp = new Date().toISOString().replace(/[-:T]/g, '_').split('.')[0]; const content = await zip.generateAsync({ type: 'blob' }); const a = document.createElement('a'); a.href = URL.createObjectURL(content); a.download = `selected_${stamp}.zip`; a.click(); URL.revokeObjectURL(a.href); } async function createProjectEntries(overview_url) { let selection = cqGetSelectedIds(); let amount = selection.length; // Get the number of selected entries if (selection.length === 0) { alert('Bitte wählen Sie zuerst mindestens einen Eintrag aus'); return; } let confirmCreation = window.confirm('Möchten Sie die Einträge wirklich erstellen??'); if (!confirmCreation) { return; } for (let id of selection) { try { await fetch(overview_url +`?action=create_new_project_entries&id=${id}&references=projects`); } catch (error) { console.error(`was not able to create project with id ${id}:`, error); } } // After all deletions, refresh the page htmx.ajax('GET', overview_url + '?id=projects', {target: "#main", swap: "outerHTML", select: "#main"}); } async function addAddYearForProjectEntries(overview_url) { let selection = cqGetSelectedIds(); if (selection.length === 0) { alert('Bitte wählen Sie zuerst mindestens einen Eintrag aus'); return; } // Jahr abfragen let year = prompt('Bitte geben Sie das Jahr ein (z. B. 2026):'); if (!year) { return; } year = year.trim(); // einfache Validierung (4-stelliges Jahr) if (!/^\d{4}$/.test(year)) { alert('Bitte geben Sie ein gültiges Jahr im Format YYYY ein'); return; } let confirmCreation = window.confirm( `Möchten Sie die Einträge wirklich für das Jahr ${year} erstellen?` ); if (!confirmCreation) { return; } for (let id of selection) { try { await fetch( `${overview_url}?action=add_add_year_for_project` + `&id=${id}` + `&references=${id}` + `&year=${year}` ); } catch (error) { console.error( `was not able to create new year for project with id ${id}:`, error ); } } // Nach Abschluss neu laden htmx.ajax( 'GET', overview_url + '?id=projects', { target: "#main", swap: "outerHTML", select: "#main" } ); } async function changeDefaultTenant(overview_url) { let tenant = document.getElementById('tenant'); if (!tenant) { return; } try { const tenantValue = tenant.value; const url = overview_url + (overview_url.includes("?") ? `&action=change_default_tenant&tenant=${tenantValue}` : `?action=change_default_tenant&tenant=${tenantValue}` ); const url1 = url //.replace('http:','https:'); const response = await fetch(url1); if (response.ok) { // Seite neu laden nach Erfolg window.location.reload(); } else { console.error("Server returned error:", response.status); } } catch (error) { console.error("Error changing client accounting:", error); } } async function changeClientAccounting(overview_url) { const client = document.getElementById('client'); if (!client) { return; } try { const url = new URL(overview_url, window.location.origin); url.searchParams.set('client', client.value || ''); url.searchParams.set('action', 'change_default_client_accounting'); window.location.href = url.toString(); } catch (error) { console.error("Error changing client accounting:", error); } } class WebSocketClient { constructor(config = {}) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const path = (config && config.path) ? config.path : '/ws/jsonrpc'; const singleton = !(config && config.singleton === false); // Reuse one WS client per endpoint in the same browser tab. if (singleton) { window.__cqWsClients = window.__cqWsClients || {}; const key = `${proto}//${host}${path}`; if (window.__cqWsClients[key]) { return window.__cqWsClients[key]; } this._wsClientKey = key; } // Default configuration this.config = { host: host, protocol: proto, path: path, reconnectMaxAttempts: 8, reconnectBaseDelay: 1000, reconnectJitterMs: 400, maxDelay: 30000, reconnectRetryForever: true, connectionWarningEnabled: true, connectionWarningDelayMs: 12000, debug: false, ...config }; this.socket = null; this.reconnectAttempt = 0; this.reconnectTimeout = null; this.messageHandler = null; this.subscriptions = []; this._closedByUser = false; this._maxAttemptsNotified = false; this._connectionWarningTimer = null; this._connectionWarningShown = false; this._boundOnlineHandler = this._handleOnline.bind(this); this._boundVisibilityHandler = this._handleVisibilityChange.bind(this); window.addEventListener('online', this._boundOnlineHandler); document.addEventListener('visibilitychange', this._boundVisibilityHandler); if (singleton && this._wsClientKey) { window.__cqWsClients[this._wsClientKey] = this; } } connect() { this._closedByUser = false; if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; } const wsUrl = `${this.config.protocol}//${this.config.host}${this.config.path}`; this.socket = new WebSocket(wsUrl); this.attachEventListeners(); } setMessageHandler(handler) { this.messageHandler = handler; } addSubscription(topic) { this.subscriptions.push(topic); if (this.isConnected()) { this.sendSubscription(topic); } } sendSubscription(topic) { const data = { 'id': crypto.randomUUID(), 'method': 'server/subscribe', 'params': { 'topic': topic } }; this.send(data); this.log(`Sent subscription for topic: ${topic}`); } // Send message send(data) { if (this.isConnected()) { this.socket.send(JSON.stringify(data)); } else { this.log('Cannot send message - socket is not connected', 'error'); } } // Check if socket is connected isConnected() { return this.socket && this.socket.readyState === WebSocket.OPEN; } // Attach WebSocket event listeners attachEventListeners() { this.socket.addEventListener('open', () => { this.log('Connected to WebSocket server'); this.reconnectAttempt = 0; this._maxAttemptsNotified = false; this._clearStaleConnectionWarning(); this._clearConnectionWarningTimer(); // Send all subscriptions this.subscriptions.forEach(topic => { this.sendSubscription(topic); }); }); this.socket.addEventListener('message', (event) => { this.log('Message received:', 'info', event.data); try { const data = JSON.parse(event.data); if (this.messageHandler) { this.messageHandler(data); } } catch (error) { this.log('Error processing message:', 'error', error); } }); this.socket.addEventListener('close', () => { this.log('Disconnected from WebSocket server'); this._scheduleConnectionWarning(); this.handleReconnection(); }); this.socket.addEventListener('error', () => { const state = this.socket ? this.socket.readyState : -1; this.log(`WebSocket error (readyState=${state}, path=${this.config.path})`, 'warn'); }); } // Handle reconnection logic handleReconnection() { if (this._closedByUser) return; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); } const maxAttempts = Number.isFinite(this.config.reconnectMaxAttempts) ? this.config.reconnectMaxAttempts : 0; const withinAttempts = this.reconnectAttempt < maxAttempts; const canRetry = withinAttempts || !!this.config.reconnectRetryForever; if (!canRetry) { this.log('Maximum reconnection attempts reached', 'warn'); if (this.config.onMaxReconnectAttemptsReached) { this.config.onMaxReconnectAttemptsReached(); } return; } if (!withinAttempts && !this._maxAttemptsNotified) { this._maxAttemptsNotified = true; this.log('Reconnect attempts exceeded, continuing with slow retry mode', 'warn'); // Keep legacy callback only when retry is finite. if (!this.config.reconnectRetryForever && this.config.onMaxReconnectAttemptsReached) { this.config.onMaxReconnectAttemptsReached(); } } const baseDelay = Math.min( this.config.reconnectBaseDelay * Math.pow(2, Math.min(this.reconnectAttempt, maxAttempts || 8)), this.config.maxDelay ); const jitter = Math.floor(Math.random() * Math.max(0, this.config.reconnectJitterMs || 0)); const delay = baseDelay + jitter; this.log( `Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1}${maxAttempts ? ('/' + maxAttempts) : ''})` ); this.reconnectTimeout = setTimeout(() => { this.reconnectAttempt++; this.connect(); }, delay); } // Logging utility log(message, level = 'info', ...args) { if (this.config.debug) { const timestamp = new Date().toISOString(); const prefix = `[WebSocketClient ${timestamp}]`; switch (level) { case 'error': console.error(prefix, message, ...args); break; case 'warn': console.warn(prefix, message, ...args); break; default: } } } // Cleanup and close connection disconnect() { this._closedByUser = true; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); } this._clearConnectionWarningTimer(); if (this.socket) { this.socket.close(); } window.removeEventListener('online', this._boundOnlineHandler); document.removeEventListener('visibilitychange', this._boundVisibilityHandler); } _handleOnline() { if (this._closedByUser) return; if (!this.isConnected()) { this.log('Network online event: retrying websocket connect'); this.connect(); } } _handleVisibilityChange() { if (this._closedByUser) return; if (document.visibilityState !== 'visible') return; if (!this.isConnected()) { this.log('Tab visible again: retrying websocket connect'); this.connect(); } } _clearStaleConnectionWarning() { try { const n = document.getElementById('notification'); if (!n) return; const txt = String(n.textContent || '').toLowerCase(); if ( txt.indexOf('connection lost') !== -1 || txt.indexOf('connection unstable') !== -1 || txt.indexOf('reconnecting') !== -1 ) { n.innerHTML = ''; } } catch (_) {} this._connectionWarningShown = false; } _clearConnectionWarningTimer() { if (this._connectionWarningTimer) { clearTimeout(this._connectionWarningTimer); this._connectionWarningTimer = null; } } _scheduleConnectionWarning() { if (!this.config.connectionWarningEnabled) return; if (this._closedByUser) return; this._clearConnectionWarningTimer(); this._connectionWarningTimer = setTimeout(() => { if (this._closedByUser) return; if (this.isConnected()) return; this._renderConnectionWarning(); }, Math.max(0, Number(this.config.connectionWarningDelayMs) || 0)); } _renderConnectionWarning() { if (this._connectionWarningShown) return; try { const n = document.getElementById('notification'); if (!n) return; const current = String(n.textContent || '').trim(); // Do not overwrite active status/progress notifications. const currentLower = current.toLowerCase(); const isConnWarning = currentLower.indexOf('connection lost') !== -1 || currentLower.indexOf('connection unstable') !== -1 || currentLower.indexOf('reconnecting') !== -1; if (current && !isConnWarning) return; n.innerHTML = ''; this._connectionWarningShown = true; } catch (_) {} } } async function addFavorite(linkEl) { const id = linkEl.dataset.id; const parent = linkEl.dataset.parent; const oid = linkEl.dataset.oid; const objLink = linkEl.dataset.objLink; const objName = linkEl.dataset.objName || ""; const objDescription = linkEl.dataset.objDescription || ""; const objCategory = linkEl.dataset.objCategory || ""; const params = new URLSearchParams({ id: id, parent: parent, action: "add_favorite", oid: oid, obj_link: objLink, obj_name: objName, obj_description: objDescription, obj_category: objCategory, }); const url = "/webui/archive?" + params.toString(); try { const response = await fetch(url, { method: "POST", // Wenn du CSRF brauchst, hier ergänzen: // headers: { "X-CSRFToken": "..."}, }); if (!response.ok) { throw new Error("Server-Fehler: " + response.status); } // Verhalten wie hx-on="htmx:afterRequest: window.location.reload()" window.location.reload(); } catch (err) { console.error("Fehler beim Hinzufügen zu den Favoriten:", err); alert("Der Favorit konnte nicht gespeichert werden."); } } async function addClipboard(linkEl) { const id = linkEl.dataset.id; const parent = linkEl.dataset.parent; const oid = linkEl.dataset.oid; const objLink = linkEl.dataset.objLink; const objName = linkEl.dataset.objName || ""; const objDescription = linkEl.dataset.objDescription || ""; const objCategory = linkEl.dataset.objCategory || ""; const params = new URLSearchParams({ id: id, parent: parent, action: "add_clipboard", oid: oid, obj_link: objLink, obj_name: objName, obj_description: objDescription, obj_category: objCategory, }); const url = "/webui/archive?" + params.toString(); try { const response = await fetch(url, { method: "POST", // Wenn du CSRF brauchst, hier ergänzen: // headers: { "X-CSRFToken": "..."}, }); if (!response.ok) { throw new Error("Server-Fehler: " + response.status); } // Verhalten wie hx-on="htmx:afterRequest: window.location.reload()" window.location.reload(); } catch (err) { console.error("Fehler beim Hinzufügen zumr Zwischenablage", err); alert("Der Eintrag konnte nicht gespeichert werden."); } } /** * MultiValue "chips" field (Vuexy-like) without Tagify. * * Expects markup: *
*
... ... ... ...
* * *
* * Backend expects FIELD_ID to contain a list string (JSON list is fine; convert_multivalue_params uses eval()). */ function initMultiValueField(container, whiteList = []) { if (!container) return null; // Prevent double init if (container._mvInit) { // refresh whitelist if needed container._mvWhiteList = Array.isArray(whiteList) ? whiteList : []; return container._mvInstance || null; } container._mvInit = true; const chips = container.querySelector('.mv-chips'); const input = container.querySelector('.mv-input'); const hiddenList = container.querySelector('input.mv-list[type="hidden"]'); const fieldName = container.getAttribute('data-name'); if (!chips || !input || !hiddenList || !fieldName) return null; container._mvWhiteList = Array.isArray(whiteList) ? whiteList : []; function syncHiddenList() { const values = Array.from(chips.querySelectorAll('.mv-chip .mv-label')) .map(el => (el.textContent || '').trim()) .filter(v => v.length > 0); // JSON list string; safe for eval() on backend hiddenList.name = fieldName; hiddenList.value = JSON.stringify(values); } function createChip(value) { const chip = document.createElement('span'); chip.className = 'mv-chip badge rounded-pill bg-label-primary d-inline-flex align-items-center gap-1'; chip.innerHTML = ` `; chip.querySelector('.mv-label').textContent = value; return chip; } function addValue(raw) { if (input.hasAttribute('readonly') || input.disabled) return; const value = (raw ?? '').toString().trim(); if (!value) return; const chip = createChip(value); chips.appendChild(chip); syncHiddenList(); } // Expose for Value Assistance selection (table row click) container._mvAddValue = addValue; // Enter adds chip (comma is just a character) input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addValue(input.value); input.value = ''; } }); // If value assistance sets a value and triggers change, allow adding on blur/change as well input.addEventListener('change', (e) => { // Only auto-add if the value assistance inserted a full value (common UX) // and user did not press Enter. const v = (input.value || '').trim(); if (v) { addValue(v); input.value = ''; } }); // Click remove chips.addEventListener('click', (e) => { const btn = e.target.closest('.mv-remove'); if (!btn) return; e.preventDefault(); const chip = btn.closest('.mv-chip'); if (chip) chip.remove(); syncHiddenList(); }); // Drag & drop reorder (DragSort already exists in your stack) try { const canDragSort = typeof DragSort === 'function' && chips && chips.nodeType === 1 && chips.querySelector && chips.querySelector('.mv-chip'); if (canDragSort) { new DragSort(chips, { selector: '.mv-chip', callbacks: { dragEnd: function () { syncHiddenList(); } } }); } } catch (err) { // DragSort optional; keep field functional if missing console && console.warn && console.warn('DragSort init failed for multivalue', err); } // Initial sync (in case server values changed or DOM edited) syncHiddenList(); // expose a small API for external callers if needed container._mvInstance = { addValue, sync: syncHiddenList }; return container._mvInstance; } /* MV Value Assistance -> add row to active multivalue - The VA modal shows a table; clicking a row should add the value to the currently active mv-field. - The active field id is set via window._mvActiveFieldId (set in the VA button hx-on). */ (function () { if (window._mvVAHookInstalled) return; window._mvVAHookInstalled = true; document.addEventListener('click', function (e) { const modal = document.getElementById('valueAssistanceModal'); if (!modal) return; // only handle clicks inside the VA modal if (!modal.contains(e.target)) return; const row = e.target.closest('tr'); if (!row) return; // ignore header rows if (row.closest('thead')) return; const activeId = window._mvActiveFieldId; if (!activeId) return; // Try to get the selected value from data-attribute or first cell text let value = row.getAttribute('data-value') || row.getAttribute('data-val') || ''; if (!value) { const td = row.querySelector('td'); value = td ? (td.textContent || '').trim() : ''; } if (!value) return; const container = document.querySelector('.mv-field[data-name="' + activeId + '"]'); if (!container) return; // ensure initialized if (typeof initMultiValueField === 'function') { initMultiValueField(container); } if (typeof container._mvAddValue === 'function') { container._mvAddValue(value); } // close modal if bootstrap is available try { if (window.bootstrap && typeof bootstrap.Modal?.getInstance === 'function') { const inst = bootstrap.Modal.getInstance(modal); if (inst) inst.hide(); } } catch (err) { // ignore } }); })(); // --- Value Assistance -> MultiValue bridge (row click inserts directly as chip) --- (function installMultiValueVAListener() { if (window._mvVAListenerInstalled) return; window._mvVAListenerInstalled = true; window.addEventListener('mv:vaSelected', function (e) { try { const d = (e && e.detail) ? e.detail : {}; const fieldId = d.fieldId; const value = d.value; if (!fieldId || value === undefined || value === null) return; const container = document.querySelector('.mv-field[data-name="' + fieldId + '"]'); if (!container) return; // Ensure initialized if (typeof initMultiValueField === 'function' && !container._mvInit) { initMultiValueField(container); } if (container && typeof container._mvAddValue === 'function') { container._mvAddValue(String(value)); } } catch (err) { console && console.warn && console.warn('mv:vaSelected handler failed', err); } }); })();