unclean SPARC

This commit is contained in:
STEINNI
2025-08-27 07:03:09 +00:00
commit f308460931
430 changed files with 54426 additions and 0 deletions
@@ -0,0 +1,421 @@
<style>
.templateEditor header {
background: url('/app/assets/images/cards/templitorHeader.png') center/cover no-repeat;
}
.templateEditor header h1 {
align-items: center;
border-bottom: 1px solid var(--eicui-base-color-grey-15);
margin-bottom: -1px;
min-height: calc(3 * var(--eicui-base-spacing-m));
padding: calc(var(--eicui-base-spacing-3xl) * 2) var(--eicui-base-spacing-m) var(--eicui-base-spacing-s) var(--eicui-base-spacing-m);
display: block;
color: var(--eicui-base-color-white) !important;
text-shadow: 0 0 4px black;
font-size: x-large !important;
background-size: cover;
transition: all 0.4s;
}
article.templateEditor section{
padding-top: 0;
flex: 1;
}
.eicui-input-container.sticky {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: white; /* ou autre fond pour lisibilité */
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.templateEditor .template-field {
border: 1px solid var(--eicui-base-color-grey-10);
overflow: auto;
padding: 0.5em;
background-color: #fff;
}
.templateEditor .tplOpStartDate,
.templateEditor .tplOpEndDate {
width: 15em;
}
.tpl-editor .tplActions {
text-align: center;
}
.tpl-editor [eicapp] [eicrichtext] {
padding: var(--eicui-base-spacing-xs);
border: 1px solid var(--eicui-base-color-grey-10);
}
.tpl-editor .template-field.subject label {
font-style: italic;
}
.tpl-editor .file-input {
display: none;
}
.tpl-editor .tpl-edit-area {
width: 60vw;
}
.tpl-editor .path-container {
display: flex;
flex-direction: column;
gap: 5px;
margin-left: 5em;
align-items: flex-start;
}
.tpl-editor .input-button-wrapper {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.tpl-editor .tplPath {
flex: 1;
min-width: 200px;
}
.tpl-editor .tpl-btn-path {
display: flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
}
.tpl-editor .hidden {
display: none;
}
.tpl-editor .icon-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tpl-editor .tpl-spinButton{
border: 0;
}
.tpl-editor .hiddenInput {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
}
@keyframes dots {
0% { content: "Loading"; }
33% { content: "Loading ."; }
66% { content: "Loading .."; }
100% { content: "Loading ..."; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.tpl-editor input[eicinput]{
height: 2em;
padding-bottom: 0.5em;
}
.tpl-editor .tpl-loadingTools {
font-size: 16px;
font-weight: bold;
color: #555;
animation: blink 1s infinite;
}
.tpl-editor .tpl-loadingTools::after {
content: "Loading";
animation: dots 1.5s infinite steps(1);
display: inline-block;
}
.tpl-editor .delete-img [eicbadge]{
position: relative;
}
.tpl-editor .he-imgSelectContainer{
width: 20%;
}
.tpl-editor .tpl-resizer {
resize: both;
overflow: hidden;
display: inline-block;
max-width: 100%;
}
.tpl-editor .he-imgItem.selected {
border: 2px solid blue;
background-color: lightgray;
}
.tpl-editor .he-imgItem {
transition: background-color 0.3s ease;
}
.tpl-editor .he-imgItem:hover {
background-color: #f0f0f0;
}
.tpl-editor .he-color-palette {
display: grid;
grid-template-columns: repeat(3, 1.5em);
gap: 0.5em;
position: fixed;
border: 1px solid #ccc;
padding: 2px;
background-color: #fff;
z-index: 1000;
}
.tpl-editor .he-colorOption {
width: 1.5em;
height: 1.5em;
cursor: pointer;
pointer-events: auto;
}
.tpl-editor .he-colorOption:hover {
border: 2px solid #000;
}
.tpl-editor .tpl-table {
border-collapse: collapse;
}
.tpl-editor .tpl-table td {
border: 1px solid #ccc;
padding: 10px;
position: relative;
}
.tpl-editor .he-listItems{
min-height: 100px;
}
.tpl-editor .tpl-history {
max-height: 12em;
overflow-y: auto;
}
.tpl-editor .tpl-history .data-set .row {
display: inline-block;
line-height: 1px;
}
.tpl-editor .template-history .footer {
font-size: x-small;
}
.tpl-editor .tpl-fullName,
.tpl-editor .tpl-comment {
display: grid;
}
.tpl-editor .tpl-comment {
white-space: normal;
word-wrap: break-word;
}
.tpl-editor .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.tpl-editor .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
.tpl-edit-area {
width: 100%;
position: relative;
}
.eicui-input-container {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.tpl-edit-area {
position: relative;
width: 100%;
}
.tpl-history-wrapper {
position: absolute;
top: 0;
right: 0;
z-index: 10;
}
.tpl-history-wrapper .toggle-history {
margin-left: auto;
}
.tpl-history {
position: absolute;
top: 2.5em;
right: 0;
width: 15em;
overflow-y: auto;
background: white;
border: 1px solid var(--eicui-base-color-grey-10);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
opacity: 0;
transform: translateX(100%);
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.tpl-history:not(.collapsed) {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.tpl-edit-area .template-field {
max-height: 45vh;
}
@media (max-height: 700px) {
.tpl-edit-area .template-field {
max-height: 55vh;
}
}
@media (min-height: 900px) {
.tpl-edit-area .template-field {
max-height: 65vh;
}
}
.tpl-edit-area .template-field.preview{
max-height: unset;
}
</style>
<article eiccard class="templateEditor">
<header>
<h1>Template Designer</h1>
</header>
<section>
<div class="tpl-editor">
<article eiccard class="tplEditor" tplEditor>
<section>
<div class="cols-3">
<div>
<label small>Template name</label>
<input eicinput class="tplName tpl-inputMeta" name="tplName"
placeholder="your template name..." />
</div>
<div class="cols-2 right">
<div class="tplOpStartDate">
<label small>Operating start date</label>
<input eicinput type="text" class="tpl-formattedDateStart tpl-inputMeta" data-trigger="onStartDateClicked" />
<input type="datetime-local" class="tplStartDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
<div class="tplOpEndDate">
<label small>Operating end date</label>
<input eicinput primary type="text" class="tpl-formattedDateEnd tpl-inputMeta" data-trigger="onEndDateClicked"/>
<input type="datetime-local" class="tplEndDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
</div>
<div class="path-container">
<label small>Template path</label>
<div class="input-button-wrapper">
<span eicchip secondary><label class="tplPath tpl-inputMeta"
name="tplPath"></label></span>
<button eicbutton xsmall rounded primary class="tpl-btn-path"
data-trigger="onPathSearch" icon="icon-folder">
<i class="icon-folder"></i>
</button>
</div>
</div>
</div>
<div class="cols-2 left fields">
<div class="cols-1 left">
<menu small eictab vertical>
<li data-trigger="buildToolButtons" data-value="subject" title="Subject line - Mandatory for email"><label>Subject</label></li>
<li data-trigger="buildToolButtons" data-value="header"><label>Header</label></li>
<li data-trigger="buildToolButtons" data-value="main"><label>Main</label></li>
<li data-trigger="buildToolButtons" data-value="footer"><label>Footer</label></li>
<li data-trigger="buildToolButtons" data-value="altText" title="Plain-text version for email clients that don't support HTML"><label>Text (Alt.)</label></li>
<li data-trigger="buildToolButtons" data-value="preview" class="icon-preview" title="Preview the full template (header + main + footer)"><label>Preview</label></li>
</menu>
</div>
<div class="tpl-edit-area">
<div class="eicui-input-container">
<div class="tpl-loadingTools">Tools</div>
<menu small eictab horizontal class="tpl-toolButtons"></menu>
<div class="tpl-history-wrapper">
<button eicbutton xsmall class="toggle-history icon-history" title="Template history"></button>
<div class="tpl-history collapsed"></div>
</div>
</div>
<div class="template-field subject">
<label>Subject is required for sending mail</label>
<div class="content" contenteditable="true" aria-placeholder=""></div>
</div>
<div class="template-field header">
<label>Header</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field main">
<label>Main</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field footer">
<label>Footer</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field altText">
<label>Text (Alt.)</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field preview" contenteditable="false">
<label> Preview</label>
<div eicrichtext class="content" contenteditable="false"></div>
</div>
</div>
</section>
<footer>
<div class="cols-1 right">
<div class="tplActions">
<button eicbutton small primary class="tool-button he-uplImg hidden"
data-trigger="onUploadImage" title="Upload your own image to insert into your template">
<span>Upload image</span>
</button>
<button eicbutton small primary class="tpl-save hidden" data-trigger="saveHtml">
<span>Save</span>
</button>
<button eicbutton small primary class="tpl-sendTest hidden" data-trigger="onTestSend">
<span>Test mail</span>
</button>
<button eicbutton small primary class="tpl-request hidden" data-trigger="onRequestPub">
<span>Request publishing</span>
</button>
<button eicbutton small primary class="tpl-approved hidden" data-trigger="onApproved">
<span>Approve Template</span>
</button>
<button eicbutton small primary class="tpl-rejected hidden" data-trigger="onRejected">
<span>Reject Template</span>
</button>
</div>
</div>
</footer>
</article>
</div>
</section>
</article>
@@ -0,0 +1,788 @@
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 = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
return
}
this.loadTemplate()
} catch (err) {
console.error("Error checking template privileges:", err)
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to check permissions due to a system error.</p>
</div>
`
}
}
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 = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
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 = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You do not have permission to access this template.</p>
</div>
`
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.<br>
During the review process, the template will no longer be editable.
<div class="prompts">
<label small>Remarks or comments for the reviewer:</label>
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
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 ?<br>
Remarks or comments for the reviewer:
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
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<br>
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
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 = `<span xsmall class="tpl-fullName">By ${item.changedBy.firstname} ${item.changedBy.lastname}</span>`
const status = `<span class="tpl-status" ${statusColor}>${item.value}</span>`
const comment = item.changedBy.comment?.trim()
? `<span xsmall info class="tpl-comment"><u>comment:</u> ${item.changedBy.comment}</span>`
: ''
const details = `
<div eiccard collapsable collapsed>
<span xsmall class="tpl-details">${oldestDate} - ${status}</span>
<div class="collapsible-content">
${fullName}
${comment}
</div>
</div>
`
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 = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to load the template due to a system error.</p>
</div>
`
} 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 = `
<div class="tpl-preview-block">${this.tplHeader.innerHTML}</div>
<div class="tpl-preview-block">${this.tplMain.innerHTML}</div>
<div class="tpl-preview-block">${this.tplFooter.innerHTML}</div>
`
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, '<span>Test mail</span>')
}
}
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, '<span>Upload image</span>')
}
}
// 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 = `<header>${headerContent}</header>
<main>${mainContent}</main>
<footer>${footerContent}</footer>`
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, '<span>Save</span>')
}
}
cleanInvisibleChars(str) {
return str
.replace(/\u200B/g, '') // Unicode invisibles chars
.replace(/&ZeroWidthSpace;/gi, '') // HTML entity for zero-width space
.normalize('NFC') // Normalisation Unicode
.replace(/[\u200B-\u200D\uFEFF]/g, '') // zero-width & BOM
.replace(/&ZeroWidthSpace;/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: `<p>This approval will be final.</p>
<p>Are you sure?</p>`,
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 `<span class="tpl-token" eicchip="" success="" data-original="${p1.trim()}">${p1.trim()}</span>`
});
}
restoreMustaches(html) {
return html.replace(/<span class="tpl-token"[^>]*data-original="(.*?)"[^>]*>.*?<\/span>/g, '{{$1}}')
}
}
app.registerClass('TemplatesEditorView', TemplatesEditorView);