class TemplatesEditorView extends EICDomContent { constructor(privileges) { super('/templates', privileges) Object.assign(this, app.helpers.activeAttributes) Object.assign(this, app.helpers.basicDialogs) } DOMContentLoaded(options) { for (let model in options.models) this[model] = options.models[model] this.components = ui.eicfy(this.el) this.templateId = options.id this.decodedHtml = null; this.decodedAltText = null; (new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field')); this.setupRefs(this.components) this.checkAccessAndLoadTemplate() } async checkAccessAndLoadTemplate() { const tplEditorContainer = this.find('.tplEditor') try { const result = await this.templates.getReadableFolders() this.authorizedPaths = result.authorizedPaths || [] if (this.authorizedPaths.length === 0) { tplEditorContainer.innerHTML = `

Access Denied

You are not authorized to access the template editor.

` return } this.loadTemplate() } catch (err) { console.error("Error checking template privileges:", err) tplEditorContainer.innerHTML = `

Error

Unable to check permissions due to a system error.

` } } async loadTemplate() { const fieldSubj = this.find('.template-field.subject .content') this.showLoading(fieldSubj) const tplEditorContainer = this.find('.tplEditor') this.gridHistory = new DataGrid(this.find('.tpl-history'), { headers: [{ label: 'Date', type: 'markup', sortable: true }], actions: [] }) try { const result = await this.templates.getReadableFolders() this.authorizedPaths = result.authorizedPaths || [] if (this.authorizedPaths.length === 0) { tplEditorContainer.innerHTML = `

Access Denied

You are not authorized to access the template editor.

` return } const currentTemplate = await this.templates.get(this.templateId) if (!currentTemplate) { ui.growl.append('Template not found. Please select a valid template.') return } const templatePath = currentTemplate.path || '/' const isAuthorized = this.authorizedPaths.some(authPath => templatePath.startsWith(authPath)) if (!isAuthorized) { tplEditorContainer.innerHTML = `

Access Denied

You do not have permission to access this template.

` return } this.editedTemplate = new TemplatesModel(currentTemplate) const templateStatus = currentTemplate.status if (templateStatus === 'submitted' || templateStatus === 'prod') { const tplFields = this.findAll('.template-field, .tpl-inputMeta') tplFields.forEach(field => { if (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA') { field.disabled = true } else { field.setAttribute('contenteditable', 'false') } field.classList.add('disabled') }) } if (this.templates.hasPrivilege('edit')) { this.toggleButton('onTestSend', ['draft', 'submitted'].includes(templateStatus)) this.toggleButton('saveHtml', ['draft', 'created'].includes(templateStatus)) this.toggleButton('onRequestPub', templateStatus === 'draft') this.toggleButton('onUploadImage', ['draft', 'created'].includes(templateStatus)) this.toggleButton('onPathSearch', ['draft', 'created'].includes(templateStatus)) } if (this.templates.hasPrivilege('approved')) { this.toggleButton('onApproved', templateStatus === 'submitted') this.toggleButton('onRejected', templateStatus === 'submitted') } this.currentPath = templatePath this.find('.tplPath').textContent = this.currentPath this.decodeHtmlBin(currentTemplate.html, currentTemplate.meta) this.find('.tplName').value = currentTemplate.name || ' ' const currentDate = new Date() const oneYearLater = new Date() oneYearLater.setFullYear(currentDate.getFullYear() + 1) const startDate = currentTemplate.startDate ? new Date(currentTemplate.startDate) : currentDate const endDate = currentTemplate.endDate ? new Date(currentTemplate.endDate) : oneYearLater this.find('.tpl-formattedDateStart').value = this.formatDateToDDMMYYYYHHMM(startDate) this.find('.tpl-formattedDateEnd').value = this.formatDateToDDMMYYYYHHMM(endDate) const headerContent = this.extractSection(this.decodedHtml, 'header') const mainContent = this.extractSection(this.decodedHtml, 'main') const footerContent = this.extractSection(this.decodedHtml, 'footer') this.tplSubj = this.find('.template-field.subject') this.tplHeader = this.find('.template-field.header') this.tplMain = this.find('.template-field.main') this.tplFooter = this.find('.template-field.footer') this.tplAltText = this.find('.template-field.altText') this.tplSubj.innerHTML = this.replaceMustaches(currentTemplate.meta.subject || ' ') this.tplHeader.innerHTML = this.replaceMustaches(headerContent || ' ') this.tplMain.innerHTML = this.replaceMustaches(mainContent || ' ') this.tplFooter.innerHTML = this.replaceMustaches(footerContent || ' ') this.tplAltText.innerText = this.decodedAltText || ' ' this.updatePreview() const tplEditor = this.find('.tplEditor') const images = await this.templates.getImages() const tokens = await this.templates.getTechDDTokens(currentTemplate) const tId = this.templateId this.htmlEditor = new HtmlEditor(tplEditor, this, { images, tokens, templateStatus, tId, externalHandlers: { onTestSend: this.onTestSend.bind(this), onUploadImage: this.onUploadImage.bind(this, templateStatus), onRequestPub: async () => { if (this.isDialogOpen) return this.isDialogOpen = true await this.confirmDialog({ title: 'Request review ?', message: `You're about to request a review for this template.
During the review process, the template will no longer be editable.
`, promptsClass: 'prompts', okLabel: 'Request review', severity: 'warning', okSeverity: 'primary', okPromise: async (data) => { try { await this.saveHtml('submitted', data.comments) await this.loadTemplate() return true } finally { this.isDialogOpen = false } } }).finally(() => { this.isDialogOpen = false }) }, onApproved: async () => { if (this.isDialogOpen) return this.isDialogOpen = true await this.confirmDialog({ title: 'Template approval ?', message: `Approve this template ?
Remarks or comments for the reviewer:
`, promptsClass: 'prompts', okLabel: 'Approve', severity: 'warning', okSeverity: 'primary', okPromise: async (data) => { try { await this.saveHtml('prod', data.comments) await this.loadTemplate() return true } finally { this.isDialogOpen = false } } }).finally(() => { this.isDialogOpen = false }) }, onRejected: async () => { if (this.isDialogOpen) return this.isDialogOpen = true await this.confirmDialog({ title: 'Template rejection ?', message: `FeedBack
`, promptsClass: 'prompts', okLabel: 'Reject', severity: 'warning', okSeverity: 'primary', okPromise: async (data) => { try { await this.saveHtml('draft', data.comments) this.loadTemplate() return true } finally { this.isDialogOpen = false } } }).finally(() => { this.isDialogOpen = false }) }, onPathSearch: this.onPathSearch.bind(this), onStartDateClicked: this.onStartDateClicked.bind(this), onEndDateClicked: this.onEndDateClicked.bind(this), saveHtml: this.saveHtml.bind(this, templateStatus) } }) // Re-attach tab listeners after reload if (typeof Tab === 'function') { (new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field')) } // Restore the last active tab if available (with a short delay to ensure listeners are attached) if (typeof this._lastActiveTabIndex === 'number' && this._lastActiveTabIndex >= 0) { const tabs = Array.from(this.findAll('menu li')) if (tabs[this._lastActiveTabIndex]) { setTimeout(() => { tabs[this._lastActiveTabIndex].click() // Force toolbar rebuild for the restored tab const fieldTypes = ['subject', 'header', 'main', 'footer', 'altText', 'preview'] const fieldType = fieldTypes[this._lastActiveTabIndex] || 'subject' if (this.htmlEditor && typeof this.htmlEditor.buildToolButtons === 'function') { this.htmlEditor.buildToolButtons(fieldType) } }, 150) } } this.gridHistory.clear() const history = currentTemplate.statusHistory.sort((a, b) => new Date(b.dateTime) - new Date(a.dateTime)) history.forEach(item => { const statusColor = { draft: 'info', created: 'secondary', submitted: 'warning', archived: 'primary', prod: 'success' }[item.value] || 'danger' const oldestDate = new Date(item.dateTime).toLocaleDateString('fr-FR') const fullName = `By ${item.changedBy.firstname} ${item.changedBy.lastname}` const status = `${item.value}` const comment = item.changedBy.comment?.trim() ? `comment: ${item.changedBy.comment}` : '' const details = `
${oldestDate} - ${status}
${fullName} ${comment}
` const row = this.gridHistory.addRow(item.id, [details]) row.addEventListener('click', () => { const content = row.querySelector('.collapsible-content') content.style.display = content.style.display === 'none' || content.style.display === '' ? 'block' : 'none' }) }) } catch (error) { console.error("Error loading template:", error) tplEditorContainer.innerHTML = `

Error

Unable to load the template due to a system error.

` } finally { this.hideLoading(fieldSubj) } const toggleBtn = document.querySelector('.toggle-history') const historyPanel = document.querySelector('.tpl-history') toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); historyPanel.classList.toggle('collapsed') if (historyPanel.classList.contains('collapsed')) { toggleBtn.classList.remove('icon-close') toggleBtn.classList.add('icon-history') } else { toggleBtn.classList.remove('icon-history') toggleBtn.classList.add('icon-close') } }) document.addEventListener('click', (e) => { if (!historyPanel.contains(e.target) && !toggleBtn.contains(e.target)) { historyPanel.classList.add('collapsed') toggleBtn.classList.remove('icon-close') toggleBtn.classList.add('icon-history') } }) } updatePreview() { if (!this.tplHeader || !this.tplMain || !this.tplFooter) return const previewContent = `
${this.tplHeader.innerHTML}
${this.tplMain.innerHTML}
${this.tplFooter.innerHTML}
` this.find('.template-field.preview .content').innerHTML = previewContent } onStartDateClicked() { const startDateInput = this.find('.tplStartDate'); startDateInput.showPicker(); this.syncDateInputs(this.find('.tpl-formattedDateStart'), this.find('.tplStartDate')); } onEndDateClicked() { const endDateInput = this.find('.tplEndDate'); endDateInput.showPicker(); this.syncDateInputs(this.find('.tpl-formattedDateEnd'), this.find('.tplEndDate')); } syncDateInputs(textInput, dateInput) { dateInput.addEventListener('input', () => { textInput.value = this.formatDateToDDMMYYYYHHMM(dateInput.value); }); textInput.addEventListener('click', () => { dateInput.showPicker(); }); } formatDateToDDMMYYYYHHMM(dateString) { const date = new Date(dateString); const dateFormatter = new Intl.DateTimeFormat('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false // 24h format }); const formattedDate = dateFormatter.format(date); return formattedDate; } reformatDateToISO(dateInput) { let dateObj; if (typeof dateInput === 'string') { const parts = dateInput.split(/[\s/:]/); if (parts.length >= 5) { const [day, month, year, hours, minutes] = parts; dateObj = new Date(`${year}-${month}-${day}T${hours}:${minutes}:00`); } else { console.error('Invalid date format:', dateInput); return ''; } } else if (dateInput instanceof Date) { dateObj = dateInput; } else { console.error('Expected a Date object or valid date string but received:', dateInput); return ''; } if (isNaN(dateObj.getTime())) { console.error('Invalid Date object:', dateObj); return ''; } const day = String(dateObj.getDate()).padStart(2, '0') const month = String(dateObj.getMonth() + 1).padStart(2, '0') const year = dateObj.getFullYear() const hours = String(dateObj.getHours()).padStart(2, '0') const minutes = String(dateObj.getMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}:00.000Z` } toggleButton(trigger, isVisible) { const button = this.components.find(c => c.el.dataset.trigger === trigger) if (button) { requestAnimationFrame(() => { button.el.classList.toggle('hidden', !isVisible) }) } } async onTestSend() { const button = this.find('.tpl-sendTest') // Deactivate button and add spinner this.showLoading(button) try { let result = await this.openDialog( await this.loadContent( 'comms/templates/dialogs/TemplatesMailTestDialog', { title: `Send a test mail...` } ) ) if (result) { await this.templates.testMail(this.templateId, result.email) ui.growl.append('Test mail sent successfully', 'success') } } catch (error) { console.error("Error on dialog UploadImage loading :", error) ui.growl.append('Error sending test mail', 'error') } finally { // Reactivate button this.hideLoading(button, 'Test mail') } } async onUploadImage(templateStatus) { const button = this.find('.he-uplImg'); const tabs = Array.from(this.findAll('menu li')) const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active')) this._lastActiveTabIndex = activeTabIndex this.showLoading(button) let dialogInstance = null try { dialogInstance = await this.openDialog( await this.loadContent( 'comms/templates/dialogs/TemplatesUplImgDialog', { title: 'Upload your own image...' } ) ) if (!dialogInstance) { return } if (dialogInstance.showDialogSpinner) dialogInstance.showDialogSpinner(true) const response = await this.templates.saveImage(dialogInstance) if (response?.success) { ui.growl.append('Image saved successfully', 'success') await this.saveHtml(templateStatus) } else { ui.growl.append('Error saving image', 'error') } } catch (error) { console.error("Error on dialog UploadImage loading:", error) } finally { // Always hide the spinner and close dialog if needed if (dialogInstance?.showDialogSpinner) { dialogInstance.showDialogSpinner(false) } if (dialogInstance?.cancel) { dialogInstance.cancel() } this.hideLoading(button, 'Upload image') } } // Decod html binary64 fron DB decodeHtmlBin(html, meta) { this.decodedHtml = html ? this.decodeBase64ToUTF8(html) : ''; this.decodedAltText = meta.altText ? this.decodeBase64ToUTF8(meta.altText) : '' } decodeBase64ToUTF8(base64Str) { const binaryString = atob(base64Str) const uint8Array = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { uint8Array[i] = binaryString.charCodeAt(i) } const decoder = new TextDecoder('utf-8') return decoder.decode(uint8Array) } async onPathSearch() { const button = this.find('.tpl-btn-path') this.showLoading(button) try { let result = await this.templates.getReadableFolders() if (!result || typeof result !== 'object') { console.error("getReadableFolders returned invalid result", result) return } let { templates, authorizedPaths, foldersWithFiles, rights } = result if (!Array.isArray(authorizedPaths)) authorizedPaths = [] if (!authorizedPaths.includes('/templates')) authorizedPaths.unshift('/templates') this.templates.ffs.authorizedPaths = authorizedPaths this.templates.ffs.foldersWithFiles = foldersWithFiles || [] this.templates.ffs.rights = rights || [] if (!templates?.templates) { templates = { templates } } const fileList = Array.isArray(templates.templates) ? templates.templates : [] this.templates.ffs.loadStructure(templates || {}, fileList) this.templates.ffs.currentPath = '/templates' this.templates.ffs.authorizedPaths = authorizedPaths this.currentPath = this.find('.tplPath').textContent let dialogResult = await this.openDialog( await this.loadContent( 'templates/Ffs/dialogs/FileBrowserDialog', { title: `Select a path...` }, { ffs: this.templates.ffs, editable: false, startPath: this.currentPath, canManage: true, templates: this.templates, context: 'templates' } ) ) if (dialogResult) { this.editedTemplate.path = dialogResult this.find('.tplPath').textContent = dialogResult } } catch (error) { console.error("Error on dialog PathSearch loading :", error) } finally { // Reactivate button this.hideLoading(button) } } extractSection(html, section) { const regex = new RegExp(`<${section}.*?>([\\s\\S]*?)<\\/${section}>`, 'i') const match = html.match(regex) const extractedText = match ? match[1].trim() : '' const normStr = this.normalizeText(extractedText) return normStr } //Normalize special char. from Word copy/paste normalizeText(str) { return str .replace(/[\u2018\u2019]/g, "'") //Replace ‘ ’ by ' .replace(/[\u201C\u201D]/g, '"') // Replace “ ” by " .replace(/\u00A0/g, ' ') // Replace space } toBase64UTF8(str) { const encoder = new TextEncoder() const uint8Array = encoder.encode(str) let binaryString = '' uint8Array.forEach(byte => { binaryString += String.fromCharCode(byte) }); return btoa(binaryString) } fromBase64UTF8(base64) { const binaryString = atob(base64) const uint8Array = new Uint8Array([...binaryString].map(char => char.charCodeAt(0))) const decoder = new TextDecoder() return decoder.decode(uint8Array) } async saveHtml(tplStatus, comment = null) { const button = this.find('.tpl-save') // Deactivate button and add spinner this.showLoading(button) // Save the current active tab index before reload const tabs = Array.from(this.findAll('menu li')) const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active')) this._lastActiveTabIndex = activeTabIndex try { const newTplStatus = (tplStatus === 'created') ? 'draft' : tplStatus const tplName = this.find('.tplName').value const tplPath = this.find('.tplPath').textContent const tplStartDate = this.reformatDateToISO(this.find('.tpl-formattedDateStart').value) const tplEndDate = this.reformatDateToISO(this.find('.tpl-formattedDateEnd').value) const curYear = new Date().getFullYear() const tplType = 'mail' const path = (tplPath ? (tplPath.startsWith('/') ? tplPath : '/' + tplPath) : '/Common/' + curYear) // keep all inputs content const tplSubject = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.subject').innerHTML)) const headerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.header').innerHTML)) const mainContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.main').innerHTML)) const footerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.footer').innerHTML)) const altTextContent = this.toBase64UTF8(this.find('.template-field.altText').innerText) const newHtml = `
${headerContent}
${mainContent}
` this.updatePreview() const refImgIds = this.extractImageIds(newHtml) const data = { id: this.templateId, type: 'templateInfo', name: tplName, path: path, html: this.toBase64UTF8(newHtml), status: newTplStatus, meta: { subject: tplSubject, type: tplType, altText: altTextContent, }, statusHistory: { changedBy: { euLoginId: '', mail: '', role: '', comment: comment || '', }, dateTime: '', value: newTplStatus }, startDate: tplStartDate, endDate: tplEndDate, imgRefs: refImgIds || '', comment: comment || '', } const response = await this.editedTemplate.save(data) if (response.success) { ui.growl.append('Template saved successfully', 'success') } else { ui.growl.append('Error saving template', 'error') } } catch (error) { console.error("Error saving template :", error) } finally { // Reactivate button this.hideLoading(button, 'Save') } } cleanInvisibleChars(str) { return str .replace(/\u200B/g, '') // Unicode invisibles chars .replace(/​/gi, '') // HTML entity for zero-width space .normalize('NFC') // Normalisation Unicode .replace(/[\u200B-\u200D\uFEFF]/g, '') // zero-width & BOM .replace(/​/gi, '') // HTML entity .replace(/[\u2018\u2019\u02BC]/g, "'") // smart apostrophes → ' .replace(/[\u201C\u201D\u00AB\u00BB]/g, '"') // smart quotes → " .replace(/[\u2013\u2014\u2212]/g, '-') // dashes → - .replace(/\u2026/g, '...') // ellipsis → ... .replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ') // weird spaces → normal space .replace(/[\u0000-\u001F\u007F]/g, '') // control chars .replace(/\s{2,}/g, ' ') // double spaces .replace(/[ \t]+\n/g, '\n') // strip trailing space before newlines .replace(/\n{3,}/g, '\n\n') // max 2 line breaks .trim() } extractImageIds(htmlContent) { const tempElement = document.createElement('div') tempElement.innerHTML = htmlContent const imgElements = tempElement.querySelectorAll('img') const ids = []; imgElements.forEach(img => { if (img.id) { ids.push(img.id) } }) return ids.join(',') } async onDecision(status) { if (status === 'prod') { let result = await this.confirmDialog({ title: 'APPROVE this template ?', message: `

This approval will be final.

Are you sure?

`, okLabel: 'Approve', severity: 'warning', okPromise: () => this.saveHtml(status, '') }) if (result) { ui.growl.append("Template approved !", 'success') } else { ui.growl.append("Error approval!", 'error') } } else { let result = await this.openDialog( await this.loadContent( 'comms/templates/dialogs/TemplatesDecisionDialog', { title: `Comment your decision...` } ) ) if (result) { await this.saveHtml(status, result.comment) } } } async addImage(data) { const response = await this.editedTemplate.saveImage(data) if (response.success) { ui.growl.append('Image saved successfully', 'success') this.loadTemplate(this.templateId) } else { ui.growl.append('Error saving image', 'error') } } //Manage tokens mustaches replaceMustaches(html) { return html.replace(/\{\{(.*?)\}\}/g, function (match, p1) { return `${p1.trim()}` }); } restoreMustaches(html) { return html.replace(/]*data-original="(.*?)"[^>]*>.*?<\/span>/g, '{{$1}}') } } app.registerClass('TemplatesEditorView', TemplatesEditorView);