@arsedizioni/ars-utils 22.0.15 → 22.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/arsedizioni-ars-utils-core.mjs +462 -311
- package/fesm2022/arsedizioni-ars-utils-core.mjs.map +1 -1
- package/package.json +1 -1
- package/types/arsedizioni-ars-utils-clipper.ui.d.ts +1 -1
- package/types/arsedizioni-ars-utils-core.d.ts +27 -43
- package/types/arsedizioni-ars-utils-ui.application.d.ts +2 -2
- package/types/arsedizioni-ars-utils-ui.d.ts +1 -1
|
@@ -2,7 +2,6 @@ import * as i0 from '@angular/core';
|
|
|
2
2
|
import { Pipe, inject, makeEnvironmentProviders, Injectable, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, EventEmitter, signal, computed, Service, RendererFactory2 } from '@angular/core';
|
|
3
3
|
import { parseISO, parse, format, getYear, getMonth, getDate, getDay, getDaysInMonth, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
|
|
4
4
|
import { it } from 'date-fns/locale';
|
|
5
|
-
import { HttpParams } from '@angular/common/http';
|
|
6
5
|
import { TZDate } from '@date-fns/tz';
|
|
7
6
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
8
7
|
import { DateAdapter, MAT_DATE_LOCALE, MAT_DATE_FORMATS } from '@angular/material/core';
|
|
@@ -12,6 +11,376 @@ import { debounceTime } from 'rxjs/operators';
|
|
|
12
11
|
import { NG_VALIDATORS } from '@angular/forms';
|
|
13
12
|
import { SelectionModel } from '@angular/cdk/collections';
|
|
14
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Zero-dependency Markdown to HTML converter.
|
|
16
|
+
*
|
|
17
|
+
* Design goals:
|
|
18
|
+
* - Single line-based pass over blocks (no repeated full-string regex passes).
|
|
19
|
+
* - Inline formatting never touches code spans / code blocks (stash & restore).
|
|
20
|
+
* - No HTML entity escaping by default in normal text: the source passes as-is.
|
|
21
|
+
* Exception: the content of code spans / code blocks is ALWAYS escaped, so
|
|
22
|
+
* things like List<string> render correctly (the browser displays the
|
|
23
|
+
* original characters; entities never reach the user or the clipboard).
|
|
24
|
+
* Opt-in full escaping via { escapeHtml: true } for untrusted input.
|
|
25
|
+
* - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
|
|
26
|
+
* - Bounded result cache for repeated renders (Angular change detection friendly).
|
|
27
|
+
*
|
|
28
|
+
* Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
|
|
29
|
+
* (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
|
|
30
|
+
* (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
|
|
31
|
+
* task lists, ordered start offset), GFM tables with alignment.
|
|
32
|
+
*
|
|
33
|
+
* Known simplifications (documented, by design):
|
|
34
|
+
* - A blank line terminates a list.
|
|
35
|
+
* - Setext headings (=== / ---) are not supported, use # syntax.
|
|
36
|
+
* - Reference-style links [text][ref] are not supported.
|
|
37
|
+
*/
|
|
38
|
+
class MarkdownUtils {
|
|
39
|
+
// #region Cache
|
|
40
|
+
static { this.cache = new Map(); }
|
|
41
|
+
static { this.CACHE_MAX = 200; }
|
|
42
|
+
/** Clears the internal result cache. */
|
|
43
|
+
static clearCache() {
|
|
44
|
+
this.cache.clear();
|
|
45
|
+
}
|
|
46
|
+
// #endregion
|
|
47
|
+
// #region Public API
|
|
48
|
+
/**
|
|
49
|
+
* Convert markdown to HTML.
|
|
50
|
+
* @param markdown : the markdown source
|
|
51
|
+
* @param options : conversion options
|
|
52
|
+
* @returns : the HTML string ('' when input is falsy)
|
|
53
|
+
*/
|
|
54
|
+
static toHtml(markdown, options) {
|
|
55
|
+
if (!markdown)
|
|
56
|
+
return '';
|
|
57
|
+
const opts = { escapeHtml: false, breaks: true, ...options };
|
|
58
|
+
const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
|
|
59
|
+
const hit = this.cache.get(key);
|
|
60
|
+
if (hit !== undefined)
|
|
61
|
+
return hit;
|
|
62
|
+
// Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco")
|
|
63
|
+
const source = markdown
|
|
64
|
+
.replace(/\r\n?/g, '\n')
|
|
65
|
+
.replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
|
|
66
|
+
const html = this.parseBlocks(source.split('\n'), opts).trim();
|
|
67
|
+
if (this.cache.size >= this.CACHE_MAX) {
|
|
68
|
+
this.cache.delete(this.cache.keys().next().value); // FIFO eviction
|
|
69
|
+
}
|
|
70
|
+
this.cache.set(key, html);
|
|
71
|
+
return html;
|
|
72
|
+
}
|
|
73
|
+
// #endregion
|
|
74
|
+
// #region Block parser (single pass)
|
|
75
|
+
static parseBlocks(lines, opts) {
|
|
76
|
+
const out = [];
|
|
77
|
+
let paragraph = [];
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (paragraph.length > 0) {
|
|
80
|
+
const sep = opts.breaks ? '<br>' : ' ';
|
|
81
|
+
out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
|
|
82
|
+
paragraph = [];
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i];
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
// --- Fenced code block -------------------------------------------------
|
|
89
|
+
const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
|
|
90
|
+
if (fence) {
|
|
91
|
+
flush();
|
|
92
|
+
const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
|
|
93
|
+
const code = [];
|
|
94
|
+
i++;
|
|
95
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
|
|
96
|
+
code.push(lines[i]);
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// --- Blank line --------------------------------------------------------
|
|
103
|
+
if (!trimmed) {
|
|
104
|
+
flush();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// --- ATX heading -------------------------------------------------------
|
|
108
|
+
const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
109
|
+
if (heading) {
|
|
110
|
+
flush();
|
|
111
|
+
const level = heading[1].length;
|
|
112
|
+
out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// --- Horizontal rule ---------------------------------------------------
|
|
116
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
117
|
+
flush();
|
|
118
|
+
out.push('<hr>');
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// --- Blockquote (consecutive lines, one level stripped, recursive) ------
|
|
122
|
+
if (/^>\s?/.test(trimmed)) {
|
|
123
|
+
flush();
|
|
124
|
+
const quoted = [];
|
|
125
|
+
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
|
126
|
+
quoted.push(lines[i].trim().replace(/^>\s?/, ''));
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
i--;
|
|
130
|
+
out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// --- Table (header row + separator row) ---------------------------------
|
|
134
|
+
if (trimmed.includes('|') &&
|
|
135
|
+
i + 1 < lines.length &&
|
|
136
|
+
lines[i + 1].includes('-') &&
|
|
137
|
+
/^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
|
|
138
|
+
flush();
|
|
139
|
+
const aligns = this.tableAligns(lines[i + 1].trim());
|
|
140
|
+
const head = this.tableRow(trimmed, 'th', aligns, opts);
|
|
141
|
+
const body = [];
|
|
142
|
+
i += 2;
|
|
143
|
+
while (i < lines.length && lines[i].trim().includes('|')) {
|
|
144
|
+
body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
i--;
|
|
148
|
+
out.push(`<table>\n<thead>\n${head}\n</thead>` +
|
|
149
|
+
(body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
|
|
150
|
+
`\n</table>`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// --- List ---------------------------------------------------------------
|
|
154
|
+
if (MarkdownUtils.listItemRe.test(line)) {
|
|
155
|
+
flush();
|
|
156
|
+
const [html, last] = this.parseList(lines, i, opts);
|
|
157
|
+
out.push(html);
|
|
158
|
+
i = last;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// --- Plain text: accumulate into current paragraph ----------------------
|
|
162
|
+
paragraph.push(trimmed);
|
|
163
|
+
}
|
|
164
|
+
flush();
|
|
165
|
+
return out.join('\n');
|
|
166
|
+
}
|
|
167
|
+
// #endregion
|
|
168
|
+
// #region Lists
|
|
169
|
+
/** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
|
|
170
|
+
static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
|
|
171
|
+
/**
|
|
172
|
+
* Gathers the contiguous list block starting at `start`, builds it (with
|
|
173
|
+
* nesting) and returns [html, indexOfLastConsumedLine].
|
|
174
|
+
*/
|
|
175
|
+
static parseList(lines, start, opts) {
|
|
176
|
+
const block = [];
|
|
177
|
+
let i = start;
|
|
178
|
+
while (i < lines.length) {
|
|
179
|
+
const l = lines[i];
|
|
180
|
+
if (!l.trim())
|
|
181
|
+
break; // blank line ends the list
|
|
182
|
+
if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
|
|
183
|
+
block.push(l);
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
else
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return [this.buildList(block, opts), i - 1];
|
|
190
|
+
}
|
|
191
|
+
static buildList(block, opts) {
|
|
192
|
+
const first = block[0].match(this.listItemRe);
|
|
193
|
+
const baseIndent = first[1].length;
|
|
194
|
+
const ordered = /^\d/.test(first[2]);
|
|
195
|
+
const startNum = ordered ? parseInt(first[2], 10) : 1;
|
|
196
|
+
const items = [];
|
|
197
|
+
for (const l of block) {
|
|
198
|
+
const m = l.match(this.listItemRe);
|
|
199
|
+
if (m && m[1].length <= baseIndent) {
|
|
200
|
+
items.push({ text: m[3], childLines: [] });
|
|
201
|
+
}
|
|
202
|
+
else if (items.length > 0) {
|
|
203
|
+
// Continuation or nested content: strip one nesting level of indent
|
|
204
|
+
items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const lis = items.map(item => {
|
|
208
|
+
let inner;
|
|
209
|
+
// GFM task list: - [ ] / - [x]
|
|
210
|
+
const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
211
|
+
if (task) {
|
|
212
|
+
const checked = task[1] !== ' ' ? ' checked' : '';
|
|
213
|
+
inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
inner = this.inline(item.text, opts);
|
|
217
|
+
}
|
|
218
|
+
if (item.childLines.length > 0) {
|
|
219
|
+
inner += '\n' + this.parseBlocks(item.childLines, opts);
|
|
220
|
+
}
|
|
221
|
+
return `<li>${inner}</li>`;
|
|
222
|
+
});
|
|
223
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
224
|
+
const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
|
|
225
|
+
return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
|
|
226
|
+
}
|
|
227
|
+
// #endregion
|
|
228
|
+
// #region Tables
|
|
229
|
+
static tableAligns(separator) {
|
|
230
|
+
return separator
|
|
231
|
+
.replace(/^\||\|$/g, '')
|
|
232
|
+
.split('|')
|
|
233
|
+
.map(cell => {
|
|
234
|
+
const t = cell.trim();
|
|
235
|
+
if (t.startsWith(':') && t.endsWith(':'))
|
|
236
|
+
return 'center';
|
|
237
|
+
if (t.endsWith(':'))
|
|
238
|
+
return 'right';
|
|
239
|
+
if (t.startsWith(':'))
|
|
240
|
+
return 'left';
|
|
241
|
+
return undefined;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
static tableRow(row, tag, aligns, opts) {
|
|
245
|
+
const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
|
246
|
+
const cellsHtml = cells
|
|
247
|
+
.map((cell, j) => {
|
|
248
|
+
const align = aligns[j] ? ` align="${aligns[j]}"` : '';
|
|
249
|
+
return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
|
|
250
|
+
})
|
|
251
|
+
.join('');
|
|
252
|
+
return `<tr>${cellsHtml}</tr>`;
|
|
253
|
+
}
|
|
254
|
+
// #endregion
|
|
255
|
+
// #region Inline formatting
|
|
256
|
+
/**
|
|
257
|
+
* Applies inline markdown to a single text segment.
|
|
258
|
+
* Generated HTML (code, links, images, autolinks) is stashed behind \u0000
|
|
259
|
+
* placeholders so later regex passes can never corrupt it.
|
|
260
|
+
*/
|
|
261
|
+
static inline(text, opts) {
|
|
262
|
+
const store = [];
|
|
263
|
+
const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
|
|
264
|
+
// 1. Inline code: stash first so later passes can't touch it.
|
|
265
|
+
// Content is ALWAYS escaped so <, >, & inside code survive in the DOM
|
|
266
|
+
// (the browser renders entities back to the original characters).
|
|
267
|
+
let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
|
|
268
|
+
// 2. Escape raw HTML as entities (opt-in only)
|
|
269
|
+
if (opts.escapeHtml)
|
|
270
|
+
s = this.escape(s);
|
|
271
|
+
// 3. Images: 
|
|
272
|
+
s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, alt, src, title) => {
|
|
273
|
+
const url = this.safeUrl(src);
|
|
274
|
+
if (!url)
|
|
275
|
+
return alt;
|
|
276
|
+
const t = title ? ` title="${title}"` : '';
|
|
277
|
+
return stash(`<img src="${url}" alt="${alt}"${t} loading="lazy">`);
|
|
278
|
+
});
|
|
279
|
+
// 4. Links: [text](url "title")
|
|
280
|
+
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, txt, href, title) => {
|
|
281
|
+
const url = this.safeUrl(href);
|
|
282
|
+
if (!url)
|
|
283
|
+
return txt;
|
|
284
|
+
const t = title ? ` title="${title}"` : '';
|
|
285
|
+
return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
|
|
286
|
+
});
|
|
287
|
+
// 5. Autolink remaining bare URLs (markdown links are already stashed)
|
|
288
|
+
s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
|
|
289
|
+
// 6. Bold / italic / strikethrough.
|
|
290
|
+
// Delimiters must hug the text; underscore emphasis only at word
|
|
291
|
+
// boundaries so identifiers like snake_case are left untouched.
|
|
292
|
+
s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
293
|
+
s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
|
|
294
|
+
s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
|
|
295
|
+
s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
|
|
296
|
+
s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
|
|
297
|
+
s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
|
298
|
+
s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
299
|
+
// 7. Restore stashed HTML
|
|
300
|
+
return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
|
|
301
|
+
}
|
|
302
|
+
// #endregion
|
|
303
|
+
// #region Security helpers
|
|
304
|
+
/** Escapes &, <, > and " for safe HTML interpolation. */
|
|
305
|
+
static escape(s) {
|
|
306
|
+
return s
|
|
307
|
+
.replace(/&/g, '&')
|
|
308
|
+
.replace(/</g, '<')
|
|
309
|
+
.replace(/>/g, '>')
|
|
310
|
+
.replace(/"/g, '"');
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Returns a sanitized URL or undefined when the scheme is dangerous.
|
|
314
|
+
* Blocks javascript:, vbscript: and data: (also when obfuscated with
|
|
315
|
+
* whitespace/control characters, e.g. "java\tscript:").
|
|
316
|
+
*/
|
|
317
|
+
static safeUrl(url) {
|
|
318
|
+
const compact = url.replace(/[\s\u0000-\u001f]/g, '');
|
|
319
|
+
if (/^(javascript|vbscript|data):/i.test(compact))
|
|
320
|
+
return undefined;
|
|
321
|
+
return url.replace(/"/g, '%22');
|
|
322
|
+
}
|
|
323
|
+
// #endregion
|
|
324
|
+
// #region Clipboard
|
|
325
|
+
/**
|
|
326
|
+
* Copies markdown content to the clipboard in two flavors:
|
|
327
|
+
* - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
|
|
328
|
+
* - text/plain : the original markdown source (paste into editors, IDEs...)
|
|
329
|
+
* @param markdown : the markdown source
|
|
330
|
+
* @param options : conversion options for the HTML flavor
|
|
331
|
+
* @returns : true on success
|
|
332
|
+
*/
|
|
333
|
+
static async copyToClipboard(markdown, options) {
|
|
334
|
+
if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
335
|
+
return false;
|
|
336
|
+
try {
|
|
337
|
+
if (typeof ClipboardItem !== 'undefined') {
|
|
338
|
+
const html = this.toHtml(markdown, options);
|
|
339
|
+
await navigator.clipboard.write([
|
|
340
|
+
new ClipboardItem({
|
|
341
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
342
|
+
'text/plain': new Blob([markdown], { type: 'text/plain' }),
|
|
343
|
+
}),
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// Older engines: plain text only
|
|
348
|
+
await navigator.clipboard.writeText(markdown);
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Clipboard API requires a secure context and user activation
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Copies plain text (e.g. the content of a single code block) to the clipboard.
|
|
359
|
+
* @param text : the text to copy
|
|
360
|
+
* @returns : true on success
|
|
361
|
+
*/
|
|
362
|
+
static async copyText(text) {
|
|
363
|
+
if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
364
|
+
return false;
|
|
365
|
+
try {
|
|
366
|
+
await navigator.clipboard.writeText(text);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Extracts the visible plain text from a rendered element
|
|
375
|
+
* (what the user sees, entities already decoded by the browser).
|
|
376
|
+
* @param element : the element hosting the rendered markdown
|
|
377
|
+
* @returns : the plain text
|
|
378
|
+
*/
|
|
379
|
+
static elementToText(element) {
|
|
380
|
+
return element.innerText ?? element.textContent ?? '';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
15
384
|
var DateFormat;
|
|
16
385
|
(function (DateFormat) {
|
|
17
386
|
DateFormat[DateFormat["Short"] = 1] = "Short";
|
|
@@ -28,6 +397,8 @@ var DateFormat;
|
|
|
28
397
|
DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
|
|
29
398
|
})(DateFormat || (DateFormat = {}));
|
|
30
399
|
class SystemUtils {
|
|
400
|
+
/** Shared collator for locale-aware, case-insensitive string comparison. */
|
|
401
|
+
static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
|
|
31
402
|
/**
|
|
32
403
|
* Array find by key
|
|
33
404
|
* @param array : the array to scan
|
|
@@ -108,21 +479,23 @@ class SystemUtils {
|
|
|
108
479
|
* Comparator factory for sorting arrays of objects by a given property key.
|
|
109
480
|
* @param key - Name of the property to sort by.
|
|
110
481
|
* @param order - Sort direction: `'asc'` (default) or `'desc'`.
|
|
111
|
-
* @returns A comparator function that returns
|
|
482
|
+
* @returns A comparator function that returns a negative, zero, or positive number.
|
|
112
483
|
*/
|
|
113
484
|
static arraySortCompare(key, order = 'asc') {
|
|
114
|
-
|
|
115
|
-
|
|
485
|
+
const dir = order === 'desc' ? -1 : 1;
|
|
486
|
+
return (a, b) => {
|
|
487
|
+
const varA = a[key];
|
|
488
|
+
const varB = b[key];
|
|
489
|
+
if (varA === varB)
|
|
116
490
|
return 0;
|
|
491
|
+
if (varA === undefined || varA === null)
|
|
492
|
+
return -1 * dir;
|
|
493
|
+
if (varB === undefined || varB === null)
|
|
494
|
+
return 1 * dir;
|
|
495
|
+
if (typeof varA === 'string' && typeof varB === 'string') {
|
|
496
|
+
return SystemUtils.collator.compare(varA, varB) * dir;
|
|
117
497
|
}
|
|
118
|
-
|
|
119
|
-
const varB = typeof b[key] === 'string' ? b[key].toUpperCase() : b[key];
|
|
120
|
-
let comparison = 0;
|
|
121
|
-
if (varA > varB)
|
|
122
|
-
comparison = 1;
|
|
123
|
-
else if (varA < varB)
|
|
124
|
-
comparison = -1;
|
|
125
|
-
return order === 'desc' ? comparison * -1 : comparison;
|
|
498
|
+
return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
|
|
126
499
|
};
|
|
127
500
|
}
|
|
128
501
|
/**
|
|
@@ -153,17 +526,13 @@ class SystemUtils {
|
|
|
153
526
|
return `${bytes} byte`;
|
|
154
527
|
}
|
|
155
528
|
/**
|
|
156
|
-
* Compare two
|
|
529
|
+
* Compare two strings (case-insensitive, locale-aware, accent-insensitive).
|
|
157
530
|
* @param a : string a
|
|
158
531
|
* @param b : string b
|
|
159
532
|
* @returns : 0 if equals, 1 if bigger, -1 if lower
|
|
160
533
|
*/
|
|
161
534
|
static compareString(a, b) {
|
|
162
|
-
|
|
163
|
-
return 1;
|
|
164
|
-
if ((a ?? "").toLowerCase() < (b ?? "").toLowerCase())
|
|
165
|
-
return -1;
|
|
166
|
-
return 0;
|
|
535
|
+
return this.collator.compare(a ?? '', b ?? '');
|
|
167
536
|
}
|
|
168
537
|
/**
|
|
169
538
|
* Capitalize a string
|
|
@@ -191,7 +560,7 @@ class SystemUtils {
|
|
|
191
560
|
return b;
|
|
192
561
|
}
|
|
193
562
|
/**
|
|
194
|
-
* Truncate a string
|
|
563
|
+
* Truncate a string at the last word boundary before `max`.
|
|
195
564
|
* @param s : the string to truncate
|
|
196
565
|
* @param max : the max number of chars
|
|
197
566
|
* @returns : the truncated string
|
|
@@ -201,13 +570,8 @@ class SystemUtils {
|
|
|
201
570
|
return undefined;
|
|
202
571
|
if (s.length < max)
|
|
203
572
|
return s;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (s[i] === ' ')
|
|
207
|
-
return s.slice(0, i);
|
|
208
|
-
i--;
|
|
209
|
-
}
|
|
210
|
-
return s;
|
|
573
|
+
const i = s.lastIndexOf(' ', max - 1);
|
|
574
|
+
return i > 0 ? s.slice(0, i) : s;
|
|
211
575
|
}
|
|
212
576
|
/**
|
|
213
577
|
* Join a list of strings
|
|
@@ -280,247 +644,16 @@ class SystemUtils {
|
|
|
280
644
|
static replaceAsHtml(s) {
|
|
281
645
|
if (!s)
|
|
282
646
|
return '';
|
|
283
|
-
|
|
284
|
-
const m = regex.exec(s);
|
|
285
|
-
if (!m || m.length === 0)
|
|
286
|
-
return s;
|
|
287
|
-
let result = s;
|
|
288
|
-
for (const v of m) {
|
|
289
|
-
result = result.replace(v, `<a href="${v}" target="_blank">${v}</a>`);
|
|
290
|
-
}
|
|
291
|
-
return result;
|
|
647
|
+
return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
|
|
292
648
|
}
|
|
293
649
|
/**
|
|
294
650
|
* Convert markdown to html
|
|
295
651
|
* @param markdown : the markdown data
|
|
296
|
-
* @param
|
|
652
|
+
* @param escapeHtml : true to escape HTML. Default is false
|
|
297
653
|
* @returns the html
|
|
298
654
|
*/
|
|
299
|
-
static markdownToHtml(markdown,
|
|
300
|
-
|
|
301
|
-
return '';
|
|
302
|
-
let html = markdown;
|
|
303
|
-
// 1. Escape HTML entities
|
|
304
|
-
if (escapeEntities) {
|
|
305
|
-
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
306
|
-
}
|
|
307
|
-
// 2. Protect code from inline formatting by replacing it with placeholders.
|
|
308
|
-
// Placeholders are HTML-comment shaped so block detection treats them correctly.
|
|
309
|
-
const codeStore = [];
|
|
310
|
-
const stash = (content) => `<!--C${codeStore.push(content) - 1}-->`;
|
|
311
|
-
// 2a. Fenced code blocks
|
|
312
|
-
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
|
|
313
|
-
const langClass = lang ? ` class="language-${lang}"` : '';
|
|
314
|
-
return stash(`<pre><code${langClass}>${code.trim()}</code></pre>`);
|
|
315
|
-
});
|
|
316
|
-
// 2b. Inline code
|
|
317
|
-
html = html.replace(/`([^`]+)`/g, (_match, code) => stash(`<code>${code}</code>`));
|
|
318
|
-
// 2c. Ensure a space between two letters separated by sentence punctuation
|
|
319
|
-
// (! or ?), e.g. "dettaglio!Ecco" becomes "dettaglio! Ecco".
|
|
320
|
-
// Period, comma, colon and semicolon are ignored.
|
|
321
|
-
html = html.replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
|
|
322
|
-
// 3. Headers
|
|
323
|
-
html = html.replace(/^##### (.*$)/gim, '<h5>$1</h5>');
|
|
324
|
-
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
|
|
325
|
-
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
|
326
|
-
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
|
327
|
-
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
|
328
|
-
// 4. Horizontal rules
|
|
329
|
-
html = html.replace(/^---+$/gim, '<hr>');
|
|
330
|
-
html = html.replace(/^\*\*\*+$/gim, '<hr>');
|
|
331
|
-
// 5. Links
|
|
332
|
-
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
333
|
-
// 6. Images
|
|
334
|
-
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
|
|
335
|
-
// 7. Bold and Italic.
|
|
336
|
-
// Delimiters must hug the text (no inner spaces) so spacing between
|
|
337
|
-
// separate styled spans is preserved. Underscore emphasis only applies at
|
|
338
|
-
// word boundaries, so identifiers like snake_case are left untouched.
|
|
339
|
-
html = html.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
340
|
-
html = html.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
|
|
341
|
-
html = html.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
|
|
342
|
-
html = html.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
|
|
343
|
-
html = html.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
|
|
344
|
-
html = html.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
|
345
|
-
// 8. Strikethrough
|
|
346
|
-
html = html.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
347
|
-
// 9. Blockquotes
|
|
348
|
-
html = html.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>');
|
|
349
|
-
// 10. Tables
|
|
350
|
-
html = this.markdownConvertTables(html);
|
|
351
|
-
// 11. Lists
|
|
352
|
-
html = this.markdownConvertLists(html);
|
|
353
|
-
// 12. Line breaks (two trailing spaces)
|
|
354
|
-
html = html.replace(/ {2}$/gm, '<br>');
|
|
355
|
-
// 13. Paragraphs (last block step, to avoid interfering with other elements)
|
|
356
|
-
html = this.markdownConvertParagraphs(html);
|
|
357
|
-
// 14. Collapse extra blank lines
|
|
358
|
-
html = html.replace(/\n{3,}/g, '\n\n');
|
|
359
|
-
// 15. Restore protected code placeholders
|
|
360
|
-
html = html.replace(/<!--C(\d+)-->/g, (_match, index) => codeStore[Number(index)] ?? '');
|
|
361
|
-
return html.trim();
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Convert markdown table to html
|
|
365
|
-
* @param markdown: the markdown string
|
|
366
|
-
* @returns : the html
|
|
367
|
-
*/
|
|
368
|
-
static markdownConvertTables(markdown) {
|
|
369
|
-
const lines = markdown.split('\n');
|
|
370
|
-
const result = [];
|
|
371
|
-
let inTable = false;
|
|
372
|
-
let tableRows = [];
|
|
373
|
-
let headerProcessed = false;
|
|
374
|
-
for (let i = 0; i < lines.length; i++) {
|
|
375
|
-
const line = lines[i].trim();
|
|
376
|
-
if (line.includes('|') && line.length > 1) {
|
|
377
|
-
const nextLine = i + 1 < lines.length ? lines[i + 1].trim() : '';
|
|
378
|
-
const isHeaderSeparator = /^\|?[\s\-:|]+\|?$/.test(nextLine);
|
|
379
|
-
if (!inTable) {
|
|
380
|
-
inTable = true;
|
|
381
|
-
headerProcessed = false;
|
|
382
|
-
tableRows = [];
|
|
383
|
-
}
|
|
384
|
-
if (isHeaderSeparator && !headerProcessed) {
|
|
385
|
-
tableRows.push(this.markdownConvertTableRow(line, true));
|
|
386
|
-
headerProcessed = true;
|
|
387
|
-
i++;
|
|
388
|
-
}
|
|
389
|
-
else if (!isHeaderSeparator) {
|
|
390
|
-
tableRows.push(this.markdownConvertTableRow(line, false));
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
if (inTable && tableRows.length > 0) {
|
|
395
|
-
result.push('<table>');
|
|
396
|
-
result.push(...tableRows);
|
|
397
|
-
result.push('</table>');
|
|
398
|
-
tableRows = [];
|
|
399
|
-
inTable = false;
|
|
400
|
-
}
|
|
401
|
-
result.push(line);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
if (inTable && tableRows.length > 0) {
|
|
405
|
-
result.push('<table>');
|
|
406
|
-
result.push(...tableRows);
|
|
407
|
-
result.push('</table>');
|
|
408
|
-
}
|
|
409
|
-
return result.join('\n');
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Convert a markdown table row to html
|
|
413
|
-
* @param row : the markdown row
|
|
414
|
-
* @param isHeader : true if is header
|
|
415
|
-
* @returns the html
|
|
416
|
-
*/
|
|
417
|
-
static markdownConvertTableRow(row, isHeader) {
|
|
418
|
-
const cells = row.split('|').map(cell => cell.trim()).filter(cell => cell !== '');
|
|
419
|
-
const tag = isHeader ? 'th' : 'td';
|
|
420
|
-
const cellsHtml = cells.map(cell => ` <${tag}>${cell}</${tag}>`).join('\n');
|
|
421
|
-
return ` <tr>\n${cellsHtml}\n </tr>`;
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Convert a markdown list to html
|
|
425
|
-
* @param markdown : the markdown list
|
|
426
|
-
* @returns the html
|
|
427
|
-
*/
|
|
428
|
-
static markdownConvertLists(markdown) {
|
|
429
|
-
markdown = markdown.replace(/^\* (.*$)/gim, '<li data-type="ul">$1</li>');
|
|
430
|
-
markdown = markdown.replace(/^- (.*$)/gim, '<li data-type="ul">$1</li>');
|
|
431
|
-
markdown = markdown.replace(/^\+ (.*$)/gim, '<li data-type="ul">$1</li>');
|
|
432
|
-
markdown = markdown.replace(/^\d+\. (.*$)/gim, '<li data-type="ol">$1</li>');
|
|
433
|
-
const lines = markdown.split('\n');
|
|
434
|
-
const result = [];
|
|
435
|
-
let currentList = { type: null, items: [] };
|
|
436
|
-
for (const line of lines) {
|
|
437
|
-
const ulMatch = line.match(/^<li data-type="ul">(.*)<\/li>$/);
|
|
438
|
-
const olMatch = line.match(/^<li data-type="ol">(.*)<\/li>$/);
|
|
439
|
-
if (ulMatch) {
|
|
440
|
-
if (currentList.type !== 'ul') {
|
|
441
|
-
if (currentList.type && currentList.items.length > 0) {
|
|
442
|
-
result.push(this.markdownWrapList(currentList.type, currentList.items));
|
|
443
|
-
}
|
|
444
|
-
currentList = { type: 'ul', items: [ulMatch[1]] };
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
currentList.items.push(ulMatch[1]);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
else if (olMatch) {
|
|
451
|
-
if (currentList.type !== 'ol') {
|
|
452
|
-
if (currentList.type && currentList.items.length > 0) {
|
|
453
|
-
result.push(this.markdownWrapList(currentList.type, currentList.items));
|
|
454
|
-
}
|
|
455
|
-
currentList = { type: 'ol', items: [olMatch[1]] };
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
currentList.items.push(olMatch[1]);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
else {
|
|
462
|
-
if (currentList.type && currentList.items.length > 0) {
|
|
463
|
-
result.push(this.markdownWrapList(currentList.type, currentList.items));
|
|
464
|
-
currentList = { type: null, items: [] };
|
|
465
|
-
}
|
|
466
|
-
result.push(line);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
if (currentList.type && currentList.items.length > 0) {
|
|
470
|
-
result.push(this.markdownWrapList(currentList.type, currentList.items));
|
|
471
|
-
}
|
|
472
|
-
return result.join('\n');
|
|
473
|
-
}
|
|
474
|
-
/**
|
|
475
|
-
* Wrap a list
|
|
476
|
-
* @param type: the list type
|
|
477
|
-
* @param items : the item list
|
|
478
|
-
* @returns the html
|
|
479
|
-
*/
|
|
480
|
-
static markdownWrapList(type, items) {
|
|
481
|
-
const listItems = items.map(item => ` <li>${item}</li>`).join('\n');
|
|
482
|
-
return `<${type}>\n${listItems}\n</${type}>`;
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Convert markdown paragraphs to html
|
|
486
|
-
* @param markdown : the markdown paragraphs
|
|
487
|
-
* @returns the html
|
|
488
|
-
*/
|
|
489
|
-
static markdownConvertParagraphs(markdown) {
|
|
490
|
-
// Block-level tags that must not be wrapped in a paragraph.
|
|
491
|
-
const blockTag = /^<\/?(h[1-6]|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|pre|blockquote|hr|p|div)\b/i;
|
|
492
|
-
// A line consisting solely of a protected code placeholder is block-level.
|
|
493
|
-
const codePlaceholder = /^<!--C\d+-->$/;
|
|
494
|
-
const lines = markdown.split('\n');
|
|
495
|
-
const result = [];
|
|
496
|
-
// Buffer of consecutive plain-text lines that belong to the same paragraph.
|
|
497
|
-
let paragraph = [];
|
|
498
|
-
// Flush the buffered lines as a single paragraph, joining soft line breaks
|
|
499
|
-
// (single newlines) with a <br> so each source line keeps its own visual row.
|
|
500
|
-
const flushParagraph = () => {
|
|
501
|
-
if (paragraph.length > 0) {
|
|
502
|
-
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
|
503
|
-
paragraph = [];
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
for (const line of lines) {
|
|
507
|
-
const trimmed = line.trim();
|
|
508
|
-
if (!trimmed) {
|
|
509
|
-
flushParagraph();
|
|
510
|
-
result.push('');
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
if (blockTag.test(trimmed) || codePlaceholder.test(trimmed)) {
|
|
514
|
-
flushParagraph();
|
|
515
|
-
result.push(line);
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
// Plain text or inline-level content (strong, em, a, code, ...) becomes a paragraph.
|
|
519
|
-
// Lines separated by a single newline are merged into the same paragraph.
|
|
520
|
-
paragraph.push(trimmed);
|
|
521
|
-
}
|
|
522
|
-
flushParagraph();
|
|
523
|
-
return result.join('\n');
|
|
655
|
+
static markdownToHtml(markdown, escapeHtml = false) {
|
|
656
|
+
return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
|
|
524
657
|
}
|
|
525
658
|
/**
|
|
526
659
|
* Compare two names
|
|
@@ -560,51 +693,44 @@ class SystemUtils {
|
|
|
560
693
|
* @returns :the ciphered text
|
|
561
694
|
*/
|
|
562
695
|
static cipher(text, key, reverse = false) {
|
|
696
|
+
if (!text || !key)
|
|
697
|
+
return undefined;
|
|
563
698
|
// Surrogate pair limit
|
|
564
699
|
const bound = 0x10000;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// Get rotation from key
|
|
570
|
-
let rotation = key.charCodeAt(i % key.length);
|
|
571
|
-
// Are we decrypting?
|
|
700
|
+
const keyLen = key.length;
|
|
701
|
+
let result = '';
|
|
702
|
+
for (let i = 0; i < text.length; i++) {
|
|
703
|
+
let rotation = key.charCodeAt(i % keyLen);
|
|
572
704
|
if (reverse)
|
|
573
705
|
rotation = -rotation;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
706
|
+
result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
|
|
707
|
+
}
|
|
708
|
+
return result;
|
|
577
709
|
}
|
|
578
710
|
/**
|
|
579
|
-
* Clone an object
|
|
580
|
-
*
|
|
711
|
+
* Clone an object (deep copy).
|
|
712
|
+
* Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
|
|
713
|
+
* @param obj : the object to clone
|
|
581
714
|
* @returns : a new object
|
|
582
715
|
*/
|
|
583
716
|
static clone(obj) {
|
|
584
717
|
if (!obj)
|
|
585
718
|
return {};
|
|
586
|
-
return
|
|
719
|
+
return structuredClone(obj);
|
|
587
720
|
}
|
|
588
721
|
/**
|
|
589
|
-
* Creates a deep clone of an object
|
|
722
|
+
* Creates a deep clone of an object.
|
|
590
723
|
* @param obj - The source object to clone.
|
|
591
|
-
* @param dest - Optional pre-allocated destination object
|
|
724
|
+
* @param dest - Optional pre-allocated destination object to merge the clone into.
|
|
592
725
|
* @returns A deep copy of `obj`.
|
|
593
726
|
*/
|
|
594
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
595
727
|
static deepClone(obj, dest) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (typeof property === 'object' && property !== null) {
|
|
601
|
-
cloneObj[attribute] = this.deepClone(property);
|
|
602
|
-
}
|
|
603
|
-
else {
|
|
604
|
-
cloneObj[attribute] = property;
|
|
605
|
-
}
|
|
728
|
+
const cloned = structuredClone(obj);
|
|
729
|
+
if (dest) {
|
|
730
|
+
Object.assign(dest, cloned);
|
|
731
|
+
return dest;
|
|
606
732
|
}
|
|
607
|
-
return
|
|
733
|
+
return cloned;
|
|
608
734
|
}
|
|
609
735
|
/**
|
|
610
736
|
* Returns `true` when `value` is a syntactically valid UUID string.
|
|
@@ -633,6 +759,10 @@ class SystemUtils {
|
|
|
633
759
|
* @returns : the string UUID
|
|
634
760
|
*/
|
|
635
761
|
static generateUUID() {
|
|
762
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
763
|
+
return crypto.randomUUID();
|
|
764
|
+
}
|
|
765
|
+
// Fallback (non-secure contexts)
|
|
636
766
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
637
767
|
const r = (Math.random() * 16) | 0;
|
|
638
768
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
@@ -650,27 +780,46 @@ class SystemUtils {
|
|
|
650
780
|
return s;
|
|
651
781
|
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
|
|
652
782
|
}
|
|
783
|
+
/** Precompiled validation patterns. */
|
|
784
|
+
static { this.emailRegex = /^(?:[a-z0-9+!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i; }
|
|
785
|
+
static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)\/?$/i; }
|
|
653
786
|
/**
|
|
654
|
-
* Parse a text and return true if is a valid email or
|
|
787
|
+
* Parse a text and return true if it is a valid email (or empty)
|
|
655
788
|
* @param value : email
|
|
656
789
|
* @returns : true if the email is valid or empty
|
|
657
790
|
*/
|
|
658
791
|
static parseEmail(value) {
|
|
659
792
|
if (!value)
|
|
660
793
|
return true;
|
|
661
|
-
|
|
662
|
-
|
|
794
|
+
return this.emailRegex.test(value.trim().toLowerCase());
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Parse a text containing one or more email addresses separated by `;` or `,`
|
|
798
|
+
* and return true if all of them are valid (or the text is empty).
|
|
799
|
+
* Empty items caused by double or trailing separators are ignored.
|
|
800
|
+
* @param value : the email list to parse (e.g. "a@b.it; c@d.it")
|
|
801
|
+
* @returns : true if all emails are valid or the value is empty
|
|
802
|
+
*/
|
|
803
|
+
static parseEmails(value) {
|
|
804
|
+
if (!value || value.trim().length === 0)
|
|
805
|
+
return true;
|
|
806
|
+
const items = value
|
|
807
|
+
.split(/[;,]/)
|
|
808
|
+
.map(e => e.trim().toLowerCase())
|
|
809
|
+
.filter(e => e.length > 0);
|
|
810
|
+
if (items.length === 0)
|
|
811
|
+
return false; // only separators, no addresses
|
|
812
|
+
return items.every(e => this.emailRegex.test(e));
|
|
663
813
|
}
|
|
664
814
|
/**
|
|
665
|
-
* Parse a text and return true if is a valid url or
|
|
815
|
+
* Parse a text and return true if it is a valid url (or empty)
|
|
666
816
|
* @param value : the url to parse
|
|
667
817
|
* @returns : true if the url is valid or empty
|
|
668
818
|
*/
|
|
669
819
|
static parseUrl(value) {
|
|
670
820
|
if (!value)
|
|
671
821
|
return true;
|
|
672
|
-
|
|
673
|
-
return regex.test(value.trim().toLowerCase());
|
|
822
|
+
return this.urlRegex.test(value.trim().toLowerCase());
|
|
674
823
|
}
|
|
675
824
|
/**
|
|
676
825
|
* Get date parts from a string value
|
|
@@ -874,16 +1023,8 @@ class SystemUtils {
|
|
|
874
1023
|
static getQueryStringValueByName(name) {
|
|
875
1024
|
if (!this.isBrowser())
|
|
876
1025
|
return undefined;
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
const httpParams = new HttpParams({ fromString: url.split('?')[1] });
|
|
880
|
-
const raw = httpParams.get(name);
|
|
881
|
-
if (!raw)
|
|
882
|
-
return undefined;
|
|
883
|
-
const v = decodeURIComponent(raw);
|
|
884
|
-
return v && v !== 'null' ? v : undefined;
|
|
885
|
-
}
|
|
886
|
-
return undefined;
|
|
1026
|
+
const v = new URLSearchParams(window.location.search).get(name);
|
|
1027
|
+
return v && v !== 'null' ? v : undefined;
|
|
887
1028
|
}
|
|
888
1029
|
/**
|
|
889
1030
|
* Generate a password
|
|
@@ -1061,6 +1202,8 @@ class SystemUtils {
|
|
|
1061
1202
|
else
|
|
1062
1203
|
return [];
|
|
1063
1204
|
}
|
|
1205
|
+
/** Cache for resolved color luminance results. */
|
|
1206
|
+
static { this.colorLightCache = new Map(); }
|
|
1064
1207
|
/**
|
|
1065
1208
|
* Check if a color is light or dark
|
|
1066
1209
|
* @param color : the color
|
|
@@ -1068,19 +1211,27 @@ class SystemUtils {
|
|
|
1068
1211
|
* @returns true if the color is light
|
|
1069
1212
|
*/
|
|
1070
1213
|
static isColorLight(color, minimumLuminance = 186) {
|
|
1214
|
+
if (!this.isBrowser())
|
|
1215
|
+
return true; // SSR fallback
|
|
1216
|
+
const cacheKey = `${color}|${minimumLuminance}`;
|
|
1217
|
+
const cached = this.colorLightCache.get(cacheKey);
|
|
1218
|
+
if (cached !== undefined)
|
|
1219
|
+
return cached;
|
|
1071
1220
|
const tempDiv = document.createElement('div');
|
|
1072
1221
|
tempDiv.style.color = color;
|
|
1073
1222
|
document.body.appendChild(tempDiv);
|
|
1074
1223
|
const rgb = getComputedStyle(tempDiv).color;
|
|
1075
1224
|
document.body.removeChild(tempDiv);
|
|
1076
1225
|
const result = rgb.match(/\d+/g);
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1226
|
+
let light = true; // fallback
|
|
1227
|
+
if (result && result.length >= 3) {
|
|
1228
|
+
const r = parseInt(result[0], 10);
|
|
1229
|
+
const g = parseInt(result[1], 10);
|
|
1230
|
+
const b = parseInt(result[2], 10);
|
|
1231
|
+
light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
|
|
1232
|
+
}
|
|
1233
|
+
this.colorLightCache.set(cacheKey, light);
|
|
1234
|
+
return light;
|
|
1084
1235
|
}
|
|
1085
1236
|
}
|
|
1086
1237
|
|