@arsedizioni/ars-utils 22.0.21 → 22.0.23

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.
@@ -1,2091 +1,2008 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Pipe, inject, makeEnvironmentProviders, Injectable, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, EventEmitter, signal, computed, Service, PLATFORM_ID, RendererFactory2 } from '@angular/core';
3
- import { parseISO, parse, format, getYear, getMonth, getDate, getDay, getDaysInMonth, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
4
- import { it } from 'date-fns/locale';
5
- import { TZDate } from '@date-fns/tz';
6
- import { DomSanitizer } from '@angular/platform-browser';
2
+ import { inject, Injectable, makeEnvironmentProviders, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, effect, Pipe, EventEmitter, signal, computed, Service, PLATFORM_ID, RendererFactory2 } from '@angular/core';
7
3
  import { DateAdapter, MAT_DATE_LOCALE, MAT_DATE_FORMATS } from '@angular/material/core';
4
+ import { TZDate } from '@date-fns/tz';
5
+ import { getYear, getMonth, getDate, getDay, getDaysInMonth, parseISO, parse, format, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
8
6
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
- import { Subject, BehaviorSubject } from 'rxjs';
10
- import { debounceTime } from 'rxjs/operators';
7
+ import { Subject, filter as filter$1, map as map$1, BehaviorSubject } from 'rxjs';
8
+ import { debounceTime, filter, map } from 'rxjs/operators';
9
+ import { it } from 'date-fns/locale';
11
10
  import { NG_VALIDATORS } from '@angular/forms';
11
+ import { DomSanitizer } from '@angular/platform-browser';
12
12
  import { SelectionModel } from '@angular/cdk/collections';
13
13
  import { isPlatformBrowser } from '@angular/common';
14
14
 
15
15
  /**
16
- * Zero-dependency Markdown to HTML converter.
17
- *
18
- * Design goals:
19
- * - Single line-based pass over blocks (no repeated full-string regex passes).
20
- * - Inline formatting never touches code spans / code blocks (stash & restore).
21
- * - No HTML entity escaping by default in normal text: the source passes as-is.
22
- * Exception: the content of code spans / code blocks is ALWAYS escaped, so
23
- * things like List<string> render correctly (the browser displays the
24
- * original characters; entities never reach the user or the clipboard).
25
- * Opt-in full escaping via { escapeHtml: true } for untrusted input.
26
- * - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
27
- * - Bounded result cache for repeated renders (Angular change detection friendly).
28
- *
29
- * Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
30
- * (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
31
- * (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
32
- * task lists, ordered start offset), GFM tables with alignment, raw HTML
33
- * blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
34
- * is false: their newlines stay plain newlines, never converted to <br>.
35
- *
36
- * Known simplifications (documented, by design):
37
- * - A blank line terminates a list.
38
- * - Setext headings (=== / ---) are not supported, use # syntax.
39
- * - Reference-style links [text][ref] are not supported.
16
+ * Creates an array of the given length, filling each slot with the result of `valueFunction`.
17
+ * @param length - Number of elements to create.
18
+ * @param valueFunction - Factory called with each index to produce the element value.
19
+ * @returns Typed array of `length` elements.
40
20
  */
41
- class MarkdownUtils {
42
- // #region Cache
43
- static { this.cache = new Map(); }
44
- static { this.CACHE_MAX = 200; }
45
- /** Clears the internal result cache. */
46
- static clearCache() {
47
- this.cache.clear();
21
+ function range(length, valueFunction) {
22
+ const valuesArray = Array(length);
23
+ for (let i = 0; i < length; i++) {
24
+ valuesArray[i] = valueFunction(i);
48
25
  }
49
- // #endregion
50
- // #region Public API
51
- /**
52
- * Convert markdown to HTML.
53
- * @param markdown : the markdown source
54
- * @param options : conversion options
55
- * @returns : the HTML string ('' when input is falsy)
56
- */
57
- static toHtml(markdown, options) {
58
- if (!markdown)
59
- return '';
60
- const opts = { escapeHtml: false, breaks: true, ...options };
61
- const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
62
- const hit = this.cache.get(key);
63
- if (hit !== undefined)
64
- return hit;
65
- // Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco")
66
- const source = markdown
67
- .replace(/\r\n?/g, '\n')
68
- .replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
69
- const html = this.parseBlocks(source.split('\n'), opts).trim();
70
- if (this.cache.size >= this.CACHE_MAX) {
71
- this.cache.delete(this.cache.keys().next().value); // FIFO eviction
26
+ return valuesArray;
27
+ }
28
+ // date-fns doesn't have a way to read/print month names or days of the week directly,
29
+ // so we get them by formatting a date with a format that produces the desired month/day.
30
+ const MONTH_FORMATS = {
31
+ long: 'LLLL',
32
+ short: 'LLL',
33
+ narrow: 'LLLLL',
34
+ };
35
+ const DAY_OF_WEEK_FORMATS = {
36
+ long: 'EEEE',
37
+ short: 'EEE',
38
+ narrow: 'EEEEE',
39
+ };
40
+ const MAT_DATE_FNS_FORMATS = {
41
+ parse: {
42
+ dateInput: 'P',
43
+ },
44
+ display: {
45
+ dateInput: 'P',
46
+ monthYearLabel: 'LLL uuuu',
47
+ dateA11yLabel: 'PP',
48
+ monthYearA11yLabel: 'LLLL uuuu',
49
+ },
50
+ };
51
+ /**
52
+ * date-fns adapter that integrates Angular Material's date picker with the date-fns library,
53
+ * applying `Europe/Rome` timezone for all parsed and created dates.
54
+ */
55
+ class DateFnsAdapter extends DateAdapter {
56
+ constructor() {
57
+ super();
58
+ const matDateLocale = inject(MAT_DATE_LOCALE, { optional: true });
59
+ // MAT_DATE_LOCALE defaults to LOCALE_ID (a string): only a date-fns
60
+ // Locale OBJECT is usable here, a string would break format()/parse().
61
+ if (matDateLocale && typeof matDateLocale === 'object') {
62
+ this.setLocale(matDateLocale);
72
63
  }
73
- this.cache.set(key, html);
74
- return html;
75
64
  }
76
- // #endregion
77
- // #region Block parser (single pass)
78
- /** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
79
- static { this.htmlBlockTags = new Set([
80
- 'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
81
- 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
82
- 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
83
- 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
84
- 'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
85
- ]); }
86
- /** Void/self-contained tags: a single tag, no closing tag expected. */
87
- static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
88
65
  /**
89
- * Precompiled: first block-level tag occurrence anywhere in a line.
90
- * Anchored on the '<' literal, so scanning is cheap; the lookahead
91
- * prevents partial matches ('<td' must not match inside '<tdx').
66
+ * Returns the year component of the given date.
67
+ * @param date - The source date.
92
68
  */
93
- static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
94
- /** Result of scanning a line for the start of a raw HTML block. */
95
- static findHtmlBlockStart(line) {
96
- // HTML comment candidate (skipping occurrences inside inline code spans)
97
- let comment = line.indexOf('<!--');
98
- while (comment !== -1 && this.insideCodeSpan(line, comment)) {
99
- comment = line.indexOf('<!--', comment + 4);
100
- }
101
- // Block tag candidate (same code-span guard)
102
- const re = this.htmlBlockScanRe;
103
- re.lastIndex = 0;
104
- let m;
105
- let tagHit;
106
- while ((m = re.exec(line)) !== null) {
107
- if (!this.insideCodeSpan(line, m.index)) {
108
- tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
109
- break;
110
- }
111
- }
112
- if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
113
- return { index: comment, tag: '', isClose: false, isComment: true };
114
- }
115
- return tagHit ? { ...tagHit, isComment: false } : undefined;
69
+ getYear(date) {
70
+ return getYear(date);
116
71
  }
117
- /** True when `index` falls inside an inline code span (odd backtick count before it). */
118
- static insideCodeSpan(line, index) {
119
- let count = 0;
120
- for (let i = 0; i < index; i++) {
121
- if (line.charCodeAt(i) === 96 /* ` */)
122
- count++;
123
- }
124
- return (count & 1) === 1;
72
+ /**
73
+ * Returns the zero-based month index of the given date (0 = January).
74
+ * @param date - The source date.
75
+ */
76
+ getMonth(date) {
77
+ return getMonth(date);
125
78
  }
126
79
  /**
127
- * indexOf-based depth scanner (no regex, no allocations): walks `text`
128
- * adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
129
- * Self-closing <tag ... /> forms do not alter depth.
130
- * @returns [newDepth, endIndex] where endIndex is the position right after
131
- * the '>' that balanced the element, or -1 when still open.
80
+ * Returns the day-of-month of the given date (1-based).
81
+ * @param date - The source date.
132
82
  */
133
- static scanHtmlDepth(text, tag, depth) {
134
- const lower = text.toLowerCase();
135
- const open = '<' + tag;
136
- const close = '</' + tag;
137
- let i = 0;
138
- while ((i = lower.indexOf('<', i)) !== -1) {
139
- if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
140
- depth--;
141
- const gt = lower.indexOf('>', i);
142
- const end = gt === -1 ? lower.length : gt + 1;
143
- if (depth <= 0)
144
- return [0, end];
145
- i = end;
146
- }
147
- else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
148
- const gt = lower.indexOf('>', i);
149
- const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
150
- if (selfClosing) {
151
- if (depth === 0)
152
- return [0, gt + 1]; // standalone self-closed element
153
- }
154
- else {
155
- depth++;
156
- }
157
- i = gt === -1 ? lower.length : gt + 1;
158
- }
159
- else {
160
- i++;
161
- }
162
- }
163
- return [depth, -1];
83
+ getDate(date) {
84
+ return getDate(date);
164
85
  }
165
- /** A tag token must be followed by whitespace, '/', '>' or end of line. */
166
- static isTagBoundary(s, idx) {
167
- if (idx >= s.length)
168
- return true; // tag opening continues on the next line
169
- const c = s.charCodeAt(idx);
170
- return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
86
+ /**
87
+ * Returns the day-of-week of the given date (0 = Sunday).
88
+ * @param date - The source date.
89
+ */
90
+ getDayOfWeek(date) {
91
+ return getDay(date);
171
92
  }
172
93
  /**
173
- * Re-injects the remainder of a partially consumed line so the main loop
174
- * processes it as markdown. Returns the index the loop should resume from.
94
+ * Returns an array of 12 month name strings formatted for the active locale.
95
+ * @param style - One of `'long'`, `'short'`, or `'narrow'`.
175
96
  */
176
- static pushBack(lines, i, remainder) {
177
- if (remainder.trim()) {
178
- lines[i] = remainder;
179
- return i - 1; // the loop's i++ re-processes the remainder
180
- }
181
- return i;
97
+ getMonthNames(style) {
98
+ const pattern = MONTH_FORMATS[style];
99
+ return range(12, i => this.format(new Date(2017, i, 1), pattern));
182
100
  }
183
101
  /**
184
- * Emits a raw HTML block starting at `hit.index` of lines[i], consuming
185
- * following lines until the element is balanced. Text after the block on
186
- * the closing line is pushed back for markdown processing.
187
- * @returns the index of the last consumed line
102
+ * Returns an array of 31 day-of-month label strings formatted using `Intl.DateTimeFormat`
103
+ * when available, falling back to plain numeric strings.
188
104
  */
189
- static emitHtmlBlock(lines, i, hit, out) {
190
- const line = lines[i];
191
- // HTML comment: raw until '-->'
192
- if (hit.isComment) {
193
- let end = line.indexOf('-->', hit.index);
194
- if (end !== -1) {
195
- out.push(line.slice(hit.index, end + 3));
196
- return this.pushBack(lines, i, line.slice(end + 3));
105
+ getDateNames() {
106
+ const dtf = typeof Intl !== 'undefined'
107
+ ? new Intl.DateTimeFormat(this.locale?.code, {
108
+ day: 'numeric',
109
+ timeZone: 'utc',
110
+ })
111
+ : null;
112
+ return range(31, i => {
113
+ if (dtf) {
114
+ // date-fns doesn't appear to support this functionality.
115
+ // Fall back to `Intl` on supported browsers.
116
+ const date = new Date();
117
+ date.setUTCFullYear(2017, 0, i + 1);
118
+ date.setUTCHours(0, 0, 0, 0);
119
+ return dtf.format(date).replace(/[\u200e\u200f]/g, '');
197
120
  }
198
- out.push(line.slice(hit.index));
199
- while (++i < lines.length) {
200
- end = lines[i].indexOf('-->');
201
- if (end !== -1) {
202
- out.push(lines[i].slice(0, end + 3));
203
- return this.pushBack(lines, i, lines[i].slice(end + 3));
204
- }
205
- out.push(lines[i]);
206
- }
207
- return lines.length - 1; // unterminated: consumed to EOF
208
- }
209
- // Stray closing tag or void tag: emit just the tag, resume after '>'
210
- if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
211
- const gt = line.indexOf('>', hit.index);
212
- const end = gt === -1 ? line.length : gt + 1;
213
- out.push(line.slice(hit.index, end));
214
- return this.pushBack(lines, i, line.slice(end));
215
- }
216
- // Opening block tag: raw until the element is balanced (depth === 0)
217
- let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
218
- if (split !== -1) {
219
- out.push(line.slice(hit.index, hit.index + split));
220
- return this.pushBack(lines, i, line.slice(hit.index + split));
221
- }
222
- out.push(line.slice(hit.index));
223
- while (depth > 0 && ++i < lines.length) {
224
- [depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
225
- if (split !== -1) {
226
- out.push(lines[i].slice(0, split));
227
- return this.pushBack(lines, i, lines[i].slice(split));
228
- }
229
- out.push(lines[i]);
230
- }
231
- return Math.min(i, lines.length - 1);
232
- }
233
- static parseBlocks(lines, opts) {
234
- const out = [];
235
- let paragraph = [];
236
- const flush = () => {
237
- if (paragraph.length > 0) {
238
- const sep = opts.breaks ? '<br>' : ' ';
239
- out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
240
- paragraph = [];
241
- }
242
- };
243
- for (let i = 0; i < lines.length; i++) {
244
- const line = lines[i];
245
- const trimmed = line.trim();
246
- // --- Fenced code block -------------------------------------------------
247
- const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
248
- if (fence) {
249
- flush();
250
- const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
251
- const code = [];
252
- i++;
253
- while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
254
- code.push(lines[i]);
255
- i++;
256
- }
257
- out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
258
- continue;
259
- }
260
- // --- Blank line --------------------------------------------------------
261
- if (!trimmed) {
262
- flush();
263
- continue;
264
- }
265
- // --- Raw HTML block passthrough (only with escaping disabled) ------------
266
- // A block-level HTML element found ANYWHERE in the line starts a raw
267
- // block: text before it joins the current paragraph, the element is
268
- // emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
269
- // any text after the closing tag resumes markdown processing.
270
- if (!opts.escapeHtml && line.includes('<')) {
271
- const hit = this.findHtmlBlockStart(line);
272
- if (hit) {
273
- const before = line.slice(0, hit.index).trim();
274
- if (before)
275
- paragraph.push(before);
276
- flush();
277
- i = this.emitHtmlBlock(lines, i, hit, out);
278
- continue;
279
- }
280
- }
281
- // --- ATX heading -------------------------------------------------------
282
- const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
283
- if (heading) {
284
- flush();
285
- const level = heading[1].length;
286
- out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
287
- continue;
288
- }
289
- // --- Horizontal rule ---------------------------------------------------
290
- if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
291
- flush();
292
- out.push('<hr>');
293
- continue;
294
- }
295
- // --- Blockquote (consecutive lines, one level stripped, recursive) ------
296
- if (/^>\s?/.test(trimmed)) {
297
- flush();
298
- const quoted = [];
299
- while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
300
- quoted.push(lines[i].trim().replace(/^>\s?/, ''));
301
- i++;
302
- }
303
- i--;
304
- out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
305
- continue;
306
- }
307
- // --- Table (header row + separator row) ---------------------------------
308
- if (trimmed.includes('|') &&
309
- i + 1 < lines.length &&
310
- lines[i + 1].includes('-') &&
311
- /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
312
- flush();
313
- const aligns = this.tableAligns(lines[i + 1].trim());
314
- const head = this.tableRow(trimmed, 'th', aligns, opts);
315
- const body = [];
316
- i += 2;
317
- while (i < lines.length && lines[i].trim().includes('|')) {
318
- body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
319
- i++;
320
- }
321
- i--;
322
- out.push(`<table>\n<thead>\n${head}\n</thead>` +
323
- (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
324
- `\n</table>`);
325
- continue;
326
- }
327
- // --- List ---------------------------------------------------------------
328
- if (MarkdownUtils.listItemRe.test(line)) {
329
- flush();
330
- const [html, last] = this.parseList(lines, i, opts);
331
- out.push(html);
332
- i = last;
333
- continue;
334
- }
335
- // --- Plain text: accumulate into current paragraph ----------------------
336
- paragraph.push(trimmed);
337
- }
338
- flush();
339
- return out.join('\n');
121
+ return String(i + 1);
122
+ });
340
123
  }
341
- // #endregion
342
- // #region Lists
343
- /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
344
- static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
345
124
  /**
346
- * Gathers the contiguous list block starting at `start`, builds it (with
347
- * nesting) and returns [html, indexOfLastConsumedLine].
125
+ * Returns an array of 7 day-of-week name strings formatted for the active locale.
126
+ * @param style - One of `'long'`, `'short'`, or `'narrow'`.
348
127
  */
349
- static parseList(lines, start, opts) {
350
- const block = [];
351
- let i = start;
352
- while (i < lines.length) {
353
- const l = lines[i];
354
- if (!l.trim())
355
- break; // blank line ends the list
356
- if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
357
- block.push(l);
358
- i++;
359
- }
360
- else
361
- break;
362
- }
363
- return [this.buildList(block, opts), i - 1];
364
- }
365
- static buildList(block, opts) {
366
- const first = block[0].match(this.listItemRe);
367
- const baseIndent = first[1].length;
368
- const ordered = /^\d/.test(first[2]);
369
- const startNum = ordered ? parseInt(first[2], 10) : 1;
370
- const items = [];
371
- for (const l of block) {
372
- const m = l.match(this.listItemRe);
373
- if (m && m[1].length <= baseIndent) {
374
- items.push({ text: m[3], childLines: [] });
375
- }
376
- else if (items.length > 0) {
377
- // Continuation or nested content: strip one nesting level of indent
378
- items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
379
- }
380
- }
381
- const lis = items.map(item => {
382
- let inner;
383
- // GFM task list: - [ ] / - [x]
384
- const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
385
- if (task) {
386
- const checked = task[1] !== ' ' ? ' checked' : '';
387
- inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
388
- }
389
- else {
390
- inner = this.inline(item.text, opts);
391
- }
392
- if (item.childLines.length > 0) {
393
- inner += '\n' + this.parseBlocks(item.childLines, opts);
394
- }
395
- return `<li>${inner}</li>`;
396
- });
397
- const tag = ordered ? 'ol' : 'ul';
398
- const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
399
- return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
400
- }
401
- // #endregion
402
- // #region Tables
403
- static tableAligns(separator) {
404
- return separator
405
- .replace(/^\||\|$/g, '')
406
- .split('|')
407
- .map(cell => {
408
- const t = cell.trim();
409
- if (t.startsWith(':') && t.endsWith(':'))
410
- return 'center';
411
- if (t.endsWith(':'))
412
- return 'right';
413
- if (t.startsWith(':'))
414
- return 'left';
415
- return undefined;
416
- });
417
- }
418
- static tableRow(row, tag, aligns, opts) {
419
- const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
420
- const cellsHtml = cells
421
- .map((cell, j) => {
422
- const align = aligns[j] ? ` align="${aligns[j]}"` : '';
423
- return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
424
- })
425
- .join('');
426
- return `<tr>${cellsHtml}</tr>`;
128
+ getDayOfWeekNames(style) {
129
+ const pattern = DAY_OF_WEEK_FORMATS[style];
130
+ return range(7, i => this.format(new Date(2017, 0, i + 1), pattern));
427
131
  }
428
- // #endregion
429
- // #region Inline formatting
430
132
  /**
431
- * Applies inline markdown to a single text segment.
432
- * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
433
- * placeholders so later regex passes can never corrupt it.
133
+ * Returns the four-digit year string for the given date.
134
+ * @param date - The source date.
434
135
  */
435
- static inline(text, opts) {
436
- const store = [];
437
- const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
438
- // 1. Inline code: stash first so later passes can't touch it.
439
- // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
440
- // (the browser renders entities back to the original characters).
441
- let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
442
- // 2. Escape raw HTML as entities (opt-in only)
443
- if (opts.escapeHtml)
444
- s = this.escape(s);
445
- // 3. Images: ![alt](url "title")
446
- s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
447
- const url = this.safeUrl(src);
448
- if (!url)
449
- return alt;
450
- const t = title ? ` title="${title}"` : '';
451
- return stash(`<img src="${url}" alt="${alt}"${t} loading="lazy">`);
452
- });
453
- // 4. Links: [text](url "title")
454
- s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
455
- const url = this.safeUrl(href);
456
- if (!url)
457
- return txt;
458
- const t = title ? ` title="${title}"` : '';
459
- return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
460
- });
461
- // 5. Autolink remaining bare URLs (markdown links are already stashed)
462
- s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
463
- // 6. Bold / italic / strikethrough.
464
- // Delimiters must hug the text; underscore emphasis only at word
465
- // boundaries so identifiers like snake_case are left untouched.
466
- s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
467
- s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
468
- s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
469
- s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
470
- s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
471
- s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
472
- s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
473
- // 7. Restore stashed HTML
474
- return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
136
+ getYearName(date) {
137
+ return this.format(date, 'y');
475
138
  }
476
- // #endregion
477
- // #region Security helpers
478
- /** Escapes &, <, > and " for safe HTML interpolation. */
479
- static escape(s) {
480
- return s
481
- .replace(/&/g, '&amp;')
482
- .replace(/</g, '&lt;')
483
- .replace(/>/g, '&gt;')
484
- .replace(/"/g, '&quot;');
139
+ /**
140
+ * Returns the first day of the week for the active locale (0 = Sunday, 1 = Monday, …).
141
+ */
142
+ getFirstDayOfWeek() {
143
+ return this.locale?.options?.weekStartsOn ?? 0;
485
144
  }
486
145
  /**
487
- * Returns a sanitized URL or undefined when the scheme is dangerous.
488
- * Blocks javascript:, vbscript: and data: (also when obfuscated with
489
- * whitespace/control characters, e.g. "java\tscript:").
146
+ * Returns the number of days in the month of the given date.
147
+ * @param date - The source date.
490
148
  */
491
- static safeUrl(url) {
492
- const compact = url.replace(/[\s\u0000-\u001f]/g, '');
493
- if (/^(javascript|vbscript|data):/i.test(compact))
494
- return undefined;
495
- return url.replace(/"/g, '%22');
149
+ getNumDaysInMonth(date) {
150
+ return getDaysInMonth(date);
496
151
  }
497
- // #endregion
498
- // #region Clipboard
499
152
  /**
500
- * Copies markdown content to the clipboard in two flavors:
501
- * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
502
- * - text/plain : the original markdown source (paste into editors, IDEs...)
503
- * @param markdown : the markdown source
504
- * @param options : conversion options for the HTML flavor
505
- * @returns : true on success
153
+ * Creates an independent copy of the given date.
154
+ * @param date - The date to clone.
506
155
  */
507
- static async copyToClipboard(markdown, options) {
508
- if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
509
- return false;
510
- try {
511
- if (typeof ClipboardItem !== 'undefined') {
512
- const html = this.toHtml(markdown, options);
513
- await navigator.clipboard.write([
514
- new ClipboardItem({
515
- 'text/html': new Blob([html], { type: 'text/html' }),
516
- 'text/plain': new Blob([markdown], { type: 'text/plain' }),
517
- }),
518
- ]);
519
- }
520
- else {
521
- // Older engines: plain text only
522
- await navigator.clipboard.writeText(markdown);
523
- }
524
- return true;
525
- }
526
- catch {
527
- // Clipboard API requires a secure context and user activation
528
- return false;
529
- }
156
+ clone(date) {
157
+ return new Date(date.getTime());
530
158
  }
531
159
  /**
532
- * Copies plain text (e.g. the content of a single code block) to the clipboard.
533
- * @param text : the text to copy
534
- * @returns : true on success
160
+ * Creates a `Date` in the `Europe/Rome` timezone for the given year, month, and day.
161
+ * Throws an `Error` when any component is out of range.
162
+ * @param year - Full four-digit year.
163
+ * @param month - Zero-based month index (0 = January, 11 = December).
164
+ * @param date - Day-of-month (1-based).
535
165
  */
536
- static async copyText(text) {
537
- if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
538
- return false;
539
- try {
540
- await navigator.clipboard.writeText(text);
541
- return true;
166
+ createDate(year, month, date) {
167
+ if (month < 0 || month > 11) {
168
+ throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
542
169
  }
543
- catch {
544
- return false;
170
+ if (date < 1) {
171
+ throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
172
+ }
173
+ // Passing the year to the constructor causes year numbers <100 to be converted to 19xx.
174
+ // To work around this we use `setFullYear` and `setHours` instead.
175
+ const result = new Date();
176
+ result.setFullYear(year, month, date);
177
+ result.setHours(0, 0, 0, 0);
178
+ const result2 = new TZDate(result, 'Europe/Rome');
179
+ if (result2.getMonth() !== month) {
180
+ throw Error(`Invalid date "${date}" for month with index "${month}".`);
545
181
  }
182
+ return result2;
546
183
  }
547
184
  /**
548
- * Extracts the visible plain text from a rendered element
549
- * (what the user sees, entities already decoded by the browser).
550
- * @param element : the element hosting the rendered markdown
551
- * @returns : the plain text
185
+ * Returns today's date in the local timezone.
552
186
  */
553
- static elementToText(element) {
554
- return element.innerText ?? element.textContent ?? '';
187
+ today() {
188
+ return new Date();
555
189
  }
556
- }
557
-
558
- var DateFormat;
559
- (function (DateFormat) {
560
- DateFormat[DateFormat["Short"] = 1] = "Short";
561
- DateFormat[DateFormat["Long"] = 2] = "Long";
562
- DateFormat[DateFormat["LongWithShortMonth"] = 3] = "LongWithShortMonth";
563
- DateFormat[DateFormat["LongWithWeekDay"] = 4] = "LongWithWeekDay";
564
- DateFormat[DateFormat["LongWithShortWeekDay"] = 5] = "LongWithShortWeekDay";
565
- DateFormat[DateFormat["MonthAndYear"] = 6] = "MonthAndYear";
566
- DateFormat[DateFormat["LongMonthAndYear"] = 7] = "LongMonthAndYear";
567
- DateFormat[DateFormat["WeekDay"] = 8] = "WeekDay";
568
- DateFormat[DateFormat["LongWeekDay"] = 9] = "LongWeekDay";
569
- DateFormat[DateFormat["DayAndMonth"] = 10] = "DayAndMonth";
570
- DateFormat[DateFormat["ShortUS"] = 11] = "ShortUS";
571
- DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
572
- })(DateFormat || (DateFormat = {}));
573
- class SystemUtils {
574
- /** Shared collator for locale-aware, case-insensitive string comparison. */
575
- static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
576
190
  /**
577
- * Array find by key
578
- * @param array : the array to scan
579
- * @param key : key name
580
- * @param value : the value to search for
581
- * @returns : the property value or null
191
+ * Parses a value into a `Date`.
192
+ * - Strings are first attempted as ISO 8601, then matched against each format in `parseFormat`.
193
+ * - Numbers are treated as Unix timestamps (milliseconds).
194
+ * - Existing `Date` instances are cloned.
195
+ * @param value - The value to parse.
196
+ * @param parseFormat - A format string or an array of format strings (date-fns tokens).
197
+ * @returns A valid `Date` in `Europe/Rome`, an invalid sentinel, or `null` for unrecognised input.
582
198
  */
583
- static arrayFindByKey(array, key, value) {
584
- if (!array)
585
- return undefined;
586
- for (const item of array) {
587
- if (item[key] === value) {
588
- return item;
199
+ parse(value, parseFormat) {
200
+ if (typeof value === 'string' && value.length > 0) {
201
+ const iso8601Date = parseISO(value);
202
+ if (this.isValid(iso8601Date)) {
203
+ return new TZDate(iso8601Date, 'Europe/Rome');
589
204
  }
205
+ const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
206
+ if (!formats.length) {
207
+ throw Error('Formats array must not be empty.');
208
+ }
209
+ for (const currentFormat of formats) {
210
+ const fromFormat = parse(value, currentFormat, new Date(), { locale: this.locale });
211
+ if (this.isValid(fromFormat)) {
212
+ return new TZDate(fromFormat, 'Europe/Rome');
213
+ }
214
+ }
215
+ return this.invalid();
590
216
  }
591
- return undefined;
217
+ else if (typeof value === 'number') {
218
+ return new Date(value);
219
+ }
220
+ else if (value instanceof Date) {
221
+ return this.clone(value);
222
+ }
223
+ return null;
592
224
  }
593
225
  /**
594
- * Array find index by key
595
- * @param array : the array to scan
596
- * @param key : the key name
597
- * @param value : the value to search for
598
- * @returns : the array index or -1 if not found
226
+ * Formats a `Date` using the given date-fns display format string.
227
+ * Throws an `Error` when `date` is not valid.
228
+ * @param date - The date to format.
229
+ * @param displayFormat - A date-fns format string (e.g. `'P'`, `'LLL uuuu'`).
599
230
  */
600
- static arrayFindIndexByKey(array, key, value) {
601
- if (!array)
602
- return -1;
603
- for (let i = 0; i < array.length; i++) {
604
- if (array[i][key] === value) {
605
- return i;
606
- }
231
+ format(date, displayFormat) {
232
+ if (!this.isValid(date)) {
233
+ throw Error('DateFnsAdapter: Cannot format invalid date.');
607
234
  }
608
- return -1;
235
+ return format(date, displayFormat, { locale: this.locale });
609
236
  }
610
237
  /**
611
- * Get a value from and array made of name|value items
612
- * @param array : the array to scan
613
- * @param value : the value to search for
614
- * @returns : the property value or null if not found
238
+ * Adds the given number of whole years to a date.
239
+ * @param date - The base date.
240
+ * @param years - Number of years to add (can be negative).
615
241
  */
616
- static arrayGetValue(array, value) {
617
- if (!array)
618
- return undefined;
619
- for (const item of array) {
620
- const i = item;
621
- if (i['value'] === value) {
622
- return i['name'] ?? i['id'];
623
- }
624
- }
625
- return undefined;
242
+ addCalendarYears(date, years) {
243
+ return addYears(date, years);
626
244
  }
627
245
  /**
628
- * Convert items to nodes into a tree structure
629
- * @param items : list of nodes
630
- * @param parent : parent node
631
- * @returns : an array of INode objects
246
+ * Adds the given number of whole months to a date.
247
+ * @param date - The base date.
248
+ * @param months - Number of months to add (can be negative).
632
249
  */
633
- static arrayToNodes(items, parent) {
634
- const nodes = [];
635
- items.forEach(n => {
636
- const node = {
637
- id: n.id.toString(),
638
- name: n.name,
639
- count: n.count ? n.count : 0,
640
- children: undefined,
641
- parent,
642
- bag: n,
643
- };
644
- nodes.push(node);
645
- node.children =
646
- n.children && n.children.length > 0
647
- ? this.arrayToNodes(n.children, node)
648
- : [];
649
- });
650
- return nodes;
250
+ addCalendarMonths(date, months) {
251
+ return addMonths(date, months);
651
252
  }
652
253
  /**
653
- * Comparator factory for sorting arrays of objects by a given property key.
654
- * @param key - Name of the property to sort by.
655
- * @param order - Sort direction: `'asc'` (default) or `'desc'`.
656
- * @returns A comparator function that returns a negative, zero, or positive number.
254
+ * Adds the given number of whole days to a date.
255
+ * @param date - The base date.
256
+ * @param days - Number of days to add (can be negative).
657
257
  */
658
- static arraySortCompare(key, order = 'asc') {
659
- const dir = order === 'desc' ? -1 : 1;
660
- return (a, b) => {
661
- const varA = a[key];
662
- const varB = b[key];
663
- if (varA === varB)
664
- return 0;
665
- if (varA === undefined || varA === null)
666
- return -1 * dir;
667
- if (varB === undefined || varB === null)
668
- return 1 * dir;
669
- if (typeof varA === 'string' && typeof varB === 'string') {
670
- return SystemUtils.collator.compare(varA, varB) * dir;
671
- }
672
- return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
673
- };
258
+ addCalendarDays(date, days) {
259
+ return addDays(date, days);
674
260
  }
675
261
  /**
676
- * Format weight
677
- * @param gr : grams
678
- * @returns : the formatted string
262
+ * Serialises a date to an ISO 8601 date string (`yyyy-MM-dd`).
263
+ * @param date - The date to serialise.
679
264
  */
680
- static formatWeight(gr) {
681
- if (gr > 1000000)
682
- return `${(gr / 1000000).toFixed(2)} t`;
683
- else if (gr > 100000)
684
- return `${(gr / 100000).toFixed(2)} q`;
685
- else if (gr > 1000)
686
- return `${(gr / 1000).toFixed(2)} kg`;
687
- else
688
- return `${gr} gr`;
265
+ toIso8601(date) {
266
+ return formatISO(date, { representation: 'date' });
689
267
  }
690
268
  /**
691
- * Format file size
692
- * @param bytes : number of bytes
693
- * @returns : the formatted string
269
+ * Returns the given value when it is a valid `Date`, or `null` for an empty string.
270
+ * Deserialises valid ISO 8601 strings into `Date` instances.
271
+ * Delegates all other values to the base-class implementation.
272
+ * @param value - The raw value to deserialise.
694
273
  */
695
- static formatFileSize(bytes) {
696
- if (bytes > 1024000)
697
- return `${(bytes / 1024000).toFixed(1)} MB`;
698
- if (bytes > 1024)
699
- return `${(bytes / 1024).toFixed(1)} Kb`;
700
- return `${bytes} byte`;
274
+ deserialize(value) {
275
+ if (typeof value === 'string') {
276
+ if (!value) {
277
+ return null;
278
+ }
279
+ const date = parseISO(value);
280
+ if (this.isValid(date)) {
281
+ return date;
282
+ }
283
+ }
284
+ return super.deserialize(value);
701
285
  }
702
286
  /**
703
- * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
704
- * @param a : string a
705
- * @param b : string b
706
- * @returns : 0 if equals, 1 if bigger, -1 if lower
287
+ * Returns `true` when `obj` is an instance of `Date`.
288
+ * @param obj - The object to test.
707
289
  */
708
- static compareString(a, b) {
709
- return this.collator.compare(a ?? '', b ?? '');
290
+ isDateInstance(obj) {
291
+ return isDate(obj);
710
292
  }
711
293
  /**
712
- * Capitalize a string
713
- * @param s : the string to capitalize
714
- * @returns : the capitalized string
294
+ * Returns `true` when `date` represents a valid point in time.
295
+ * @param date - The date to validate.
715
296
  */
716
- static capitalize(s) {
717
- if (!s)
718
- return undefined;
719
- let b = "";
720
- let cap = true;
721
- for (const char of s) {
722
- if (char === " ") {
723
- b += char;
724
- cap = true;
725
- }
726
- else if (cap) {
727
- b += char.toUpperCase();
728
- cap = false;
297
+ isValid(date) {
298
+ return isValid(date);
299
+ }
300
+ /**
301
+ * Returns a sentinel `Date` that represents an invalid date (`new Date(NaN)`).
302
+ */
303
+ invalid() {
304
+ return new Date(NaN);
305
+ }
306
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
307
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter }); }
308
+ }
309
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, decorators: [{
310
+ type: Injectable
311
+ }], ctorParameters: () => [] });
312
+ /**
313
+ * Standalone providers for the ars-utils date-fns adapter.
314
+ *
315
+ * Configures Angular Material to use {@link DateFnsAdapter} (Europe/Rome timezone)
316
+ * and the matching {@link MAT_DATE_FNS_FORMATS}.
317
+ *
318
+ * @example
319
+ * bootstrapApplication(AppComponent, {
320
+ * providers: [provideArsDateFns()]
321
+ * });
322
+ */
323
+ function provideArsDateFns() {
324
+ return makeEnvironmentProviders([
325
+ {
326
+ provide: DateAdapter,
327
+ useClass: DateFnsAdapter,
328
+ deps: [MAT_DATE_LOCALE],
329
+ },
330
+ { provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }
331
+ ]);
332
+ }
333
+
334
+ /**
335
+ * Directive that moves browser focus to the host element after the first render cycle.
336
+ * Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
337
+ */
338
+ class AutoFocusDirective {
339
+ constructor() {
340
+ this.elementRef = inject(ElementRef);
341
+ afterNextRender(() => {
342
+ this.elementRef.nativeElement?.focus();
343
+ });
344
+ }
345
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
346
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
347
+ }
348
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, decorators: [{
349
+ type: Directive,
350
+ args: [{
351
+ selector: '[autoFocus]',
352
+ standalone: true
353
+ }]
354
+ }], ctorParameters: () => [] });
355
+
356
+ class FileInfo {
357
+ isValid() {
358
+ return this.valid;
359
+ }
360
+ }
361
+ class ValueModel {
362
+ }
363
+ class IDModel {
364
+ }
365
+ class GroupModel {
366
+ }
367
+ class DeleteModel extends GroupModel {
368
+ }
369
+ class RelationModel {
370
+ }
371
+ class UpdateRelationsModel {
372
+ }
373
+ class QueryModel {
374
+ }
375
+ class ImportModel {
376
+ }
377
+ class DateInterval {
378
+ get fromAsDate() {
379
+ if (this.from) {
380
+ if (!(this.from instanceof Date)) {
381
+ this.from = new Date(this.from);
729
382
  }
730
- else {
731
- b += char.toLowerCase();
383
+ if (this.from) {
384
+ return new Date(this.from.getFullYear(), this.from.getMonth(), this.from.getDate(), 2, 0, 0);
732
385
  }
733
386
  }
734
- return b;
735
- }
736
- /**
737
- * Truncate a string at the last word boundary before `max`.
738
- * @param s : the string to truncate
739
- * @param max : the max number of chars
740
- * @returns : the truncated string
741
- */
742
- static truncate(s, max = 500) {
743
- if (!s)
744
- return undefined;
745
- if (s.length < max)
746
- return s;
747
- const i = s.lastIndexOf(' ', max - 1);
748
- return i > 0 ? s.slice(0, i) : s;
387
+ return undefined;
749
388
  }
750
- /**
751
- * Join a list of strings
752
- * @param items : the list of strings
753
- * @param sep : the separator string
754
- * @param max : the maximum resulting string
755
- * @returns : the joined string
756
- */
757
- static join(items, sep = " ", max = 350) {
758
- if (!items || items.length === 0)
759
- return undefined;
760
- if (items.length > 1) {
761
- let l = 0;
762
- let s = "";
763
- while (s.length < max && items.length > l) {
764
- if (l > 0) {
765
- s += sep;
766
- }
767
- s += items[l++];
389
+ get toAsDate() {
390
+ if (this.to) {
391
+ if (!(this.to instanceof Date)) {
392
+ this.to = new Date(this.to);
768
393
  }
769
- if (items.length > l) {
770
- s += "...";
394
+ if (this.to) {
395
+ return new Date(this.to.getFullYear(), this.to.getMonth(), this.to.getDate(), 2, 0, 0);
771
396
  }
772
- return s;
773
397
  }
774
- return items[0];
398
+ return undefined;
775
399
  }
776
- /**
777
- * Normalize a string by converting it to lowercase and removing extra spaces, with special handling for acronyms and camelCase.
778
- * @param s : the string to normalize
779
- * @returns : The normalized string, or `undefined` if the input is falsy.
780
- */
781
- static normalizeDisplay(s) {
782
- if (!s)
783
- return s;
784
- return s
785
- .split(' ')
786
- .map((word, wordIndex) => {
787
- if (!word)
788
- return word;
789
- // If the word is all uppercase and contains at least one letter, we assume it's an acronym and leave it as is
790
- if (word === word.toUpperCase() && /[A-Z]/.test(word)) {
791
- return word;
792
- }
793
- // Otherwise, convert to lowercase letter by letter
794
- const chars = word.split('');
795
- return chars
796
- .map((char, charIndex) => {
797
- if (charIndex === 0 && wordIndex === 0) {
798
- // First letter of the first word:
799
- // remains uppercase only if the first 2 letters are both uppercase
800
- const secondChar = chars[1] ?? '';
801
- const keepUppercase = char === char.toUpperCase() &&
802
- secondChar === secondChar.toUpperCase() &&
803
- /[A-Z]/.test(char) &&
804
- /[A-Z]/.test(secondChar);
805
- return keepUppercase ? char : char.toLowerCase();
806
- }
807
- return char.toLowerCase();
808
- })
809
- .join('');
810
- })
811
- .join(' ');
400
+ constructor(from, to) {
401
+ this.from = from;
402
+ this.to = to;
812
403
  }
813
- /**
814
- * Wraps bare URLs in the given string with `<a>` anchor tags.
815
- * @param s - The plain-text or HTML string to process.
816
- * @returns The string with URLs replaced by clickable links, or `''` when `s` is falsy.
817
- */
818
- static replaceAsHtml(s) {
819
- if (!s)
820
- return '';
821
- return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
404
+ clear() {
405
+ this.from = undefined;
406
+ this.to = undefined;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Zero-dependency Markdown to HTML converter.
412
+ *
413
+ * Design goals:
414
+ * - Single line-based pass over blocks (no repeated full-string regex passes).
415
+ * - Inline formatting never touches code spans / code blocks (stash & restore).
416
+ * - No HTML entity escaping by default in normal text: the source passes as-is.
417
+ * Exception: the content of code spans / code blocks is ALWAYS escaped, so
418
+ * things like List<string> render correctly (the browser displays the
419
+ * original characters; entities never reach the user or the clipboard).
420
+ * Opt-in full escaping via { escapeHtml: true } for untrusted input.
421
+ * - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
422
+ * - Bounded result cache for repeated renders (Angular change detection friendly).
423
+ *
424
+ * Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
425
+ * (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
426
+ * (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
427
+ * task lists, ordered start offset), GFM tables with alignment, raw HTML
428
+ * blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
429
+ * is false: their newlines stay plain newlines, never converted to <br>.
430
+ *
431
+ * Known simplifications (documented, by design):
432
+ * - A blank line terminates a list.
433
+ * - Setext headings (=== / ---) are not supported, use # syntax.
434
+ * - Reference-style links [text][ref] are not supported.
435
+ */
436
+ class MarkdownUtils {
437
+ // #region Cache
438
+ static { this.cache = new Map(); }
439
+ static { this.CACHE_MAX = 200; }
440
+ /** Clears the internal result cache. */
441
+ static clearCache() {
442
+ this.cache.clear();
822
443
  }
444
+ // #endregion
445
+ // #region Public API
823
446
  /**
824
- * Convert markdown to html
825
- * @param markdown : the markdown data
826
- * @param escapeHtml : true to escape HTML. Default is false
827
- * @returns the html
447
+ * Convert markdown to HTML.
448
+ * @param markdown : the markdown source
449
+ * @param options : conversion options
450
+ * @returns : the HTML string ('' when input is falsy)
828
451
  */
829
- static markdownToHtml(markdown, escapeHtml = false) {
830
- return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
452
+ static toHtml(markdown, options) {
453
+ if (!markdown)
454
+ return '';
455
+ const opts = { escapeHtml: false, breaks: true, ...options };
456
+ const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
457
+ const hit = this.cache.get(key);
458
+ if (hit !== undefined)
459
+ return hit;
460
+ // Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco").
461
+ // Only when the next char is uppercase (a new sentence), so URLs with query
462
+ // strings ("/page?id=1") and code identifiers are left untouched.
463
+ const source = markdown
464
+ .replace(/\r\n?/g, '\n')
465
+ .replace(/([a-zà-ÿ][!?])([A-ZÀ-Þ])/g, '$1 $2');
466
+ const html = this.parseBlocks(source.split('\n'), opts).trim();
467
+ if (this.cache.size >= this.CACHE_MAX) {
468
+ this.cache.delete(this.cache.keys().next().value); // FIFO eviction
469
+ }
470
+ this.cache.set(key, html);
471
+ return html;
831
472
  }
473
+ // #endregion
474
+ // #region Block parser (single pass)
475
+ /** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
476
+ static { this.htmlBlockTags = new Set([
477
+ 'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
478
+ 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
479
+ 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
480
+ 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
481
+ 'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
482
+ ]); }
483
+ /** Void/self-contained tags: a single tag, no closing tag expected. */
484
+ static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
832
485
  /**
833
- * Compare two names
834
- * @param a : name a
835
- * @param b : name b
836
- * @returns : true if a equals b
486
+ * Precompiled: first block-level tag occurrence anywhere in a line.
487
+ * Anchored on the '<' literal, so scanning is cheap; the lookahead
488
+ * prevents partial matches ('<td' must not match inside '<tdx').
837
489
  */
838
- static compareNames(a, b) {
839
- if (a)
840
- a = a.trim();
841
- if (b)
842
- b = b.trim();
843
- if (a && b && a.length !== b.length)
844
- return false;
845
- if (this.compareString(a, b) === 0)
846
- return true;
847
- const p1 = (a ?? '').split(' ');
848
- const p2 = (b ?? '').split(' ');
849
- if (p1.length !== p2.length)
850
- return false;
851
- let matches = p1.length;
852
- for (const s1 of p1) {
853
- for (const s2 of p2) {
854
- if (this.compareString(s1, s2) === 0) {
855
- matches--;
856
- break;
857
- }
490
+ static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
491
+ /** Result of scanning a line for the start of a raw HTML block. */
492
+ static findHtmlBlockStart(line) {
493
+ // HTML comment candidate (skipping occurrences inside inline code spans)
494
+ let comment = line.indexOf('<!--');
495
+ while (comment !== -1 && this.insideCodeSpan(line, comment)) {
496
+ comment = line.indexOf('<!--', comment + 4);
497
+ }
498
+ // Block tag candidate (same code-span guard)
499
+ const re = this.htmlBlockScanRe;
500
+ re.lastIndex = 0;
501
+ let m;
502
+ let tagHit;
503
+ while ((m = re.exec(line)) !== null) {
504
+ if (!this.insideCodeSpan(line, m.index)) {
505
+ tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
506
+ break;
858
507
  }
859
508
  }
860
- return matches === 0;
509
+ if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
510
+ return { index: comment, tag: '', isClose: false, isComment: true };
511
+ }
512
+ return tagHit ? { ...tagHit, isComment: false } : undefined;
861
513
  }
862
- /**
863
- * Cipher a text
864
- * @param text : the text
865
- * @param key : the key
866
- * @param reverse : true to decode, false to encode
867
- * @returns :the ciphered text
868
- */
869
- static cipher(text, key, reverse = false) {
870
- if (!text || !key)
871
- return undefined;
872
- // Surrogate pair limit
873
- const bound = 0x10000;
874
- const keyLen = key.length;
875
- let result = '';
876
- for (let i = 0; i < text.length; i++) {
877
- let rotation = key.charCodeAt(i % keyLen);
878
- if (reverse)
879
- rotation = -rotation;
880
- result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
514
+ /** True when `index` falls inside an inline code span (odd backtick count before it). */
515
+ static insideCodeSpan(line, index) {
516
+ let count = 0;
517
+ for (let i = 0; i < index; i++) {
518
+ if (line.charCodeAt(i) === 96 /* ` */)
519
+ count++;
881
520
  }
882
- return result;
521
+ return (count & 1) === 1;
883
522
  }
884
523
  /**
885
- * Clone an object (deep copy).
886
- * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
887
- * @param obj : the object to clone
888
- * @returns : a new object
524
+ * indexOf-based depth scanner (no regex, no allocations): walks `text`
525
+ * adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
526
+ * Self-closing <tag ... /> forms do not alter depth.
527
+ * @returns [newDepth, endIndex] where endIndex is the position right after
528
+ * the '>' that balanced the element, or -1 when still open.
889
529
  */
890
- static clone(obj) {
891
- if (!obj)
892
- return {};
893
- return structuredClone(obj);
530
+ static scanHtmlDepth(text, tag, depth) {
531
+ const lower = text.toLowerCase();
532
+ const open = '<' + tag;
533
+ const close = '</' + tag;
534
+ let i = 0;
535
+ while ((i = lower.indexOf('<', i)) !== -1) {
536
+ if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
537
+ depth--;
538
+ const gt = lower.indexOf('>', i);
539
+ const end = gt === -1 ? lower.length : gt + 1;
540
+ if (depth <= 0)
541
+ return [0, end];
542
+ i = end;
543
+ }
544
+ else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
545
+ const gt = lower.indexOf('>', i);
546
+ const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
547
+ if (selfClosing) {
548
+ if (depth === 0)
549
+ return [0, gt + 1]; // standalone self-closed element
550
+ }
551
+ else {
552
+ depth++;
553
+ }
554
+ i = gt === -1 ? lower.length : gt + 1;
555
+ }
556
+ else {
557
+ i++;
558
+ }
559
+ }
560
+ return [depth, -1];
561
+ }
562
+ /** A tag token must be followed by whitespace, '/', '>' or end of line. */
563
+ static isTagBoundary(s, idx) {
564
+ if (idx >= s.length)
565
+ return true; // tag opening continues on the next line
566
+ const c = s.charCodeAt(idx);
567
+ return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
894
568
  }
895
569
  /**
896
- * Creates a deep clone of an object.
897
- * @param obj - The source object to clone.
898
- * @param dest - Optional pre-allocated destination object to merge the clone into.
899
- * @returns A deep copy of `obj`.
570
+ * Re-injects the remainder of a partially consumed line so the main loop
571
+ * processes it as markdown. Returns the index the loop should resume from.
900
572
  */
901
- static deepClone(obj, dest) {
902
- const cloned = structuredClone(obj);
903
- if (dest) {
904
- Object.assign(dest, cloned);
905
- return dest;
573
+ static pushBack(lines, i, remainder) {
574
+ if (remainder.trim()) {
575
+ lines[i] = remainder;
576
+ return i - 1; // the loop's i++ re-processes the remainder
906
577
  }
907
- return cloned;
578
+ return i;
908
579
  }
909
580
  /**
910
- * Returns `true` when `value` is a syntactically valid UUID string.
911
- * @param value - The string to validate.
581
+ * Emits a raw HTML block starting at `hit.index` of lines[i], consuming
582
+ * following lines until the element is balanced. Text after the block on
583
+ * the closing line is pushed back for markdown processing.
584
+ * @returns the index of the last consumed line
912
585
  */
913
- static parseUUID(value) {
914
- const regex = /^({|()?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}(}|))?$/;
915
- return !!value && value.length > 0 && regex.test(value);
586
+ static emitHtmlBlock(lines, i, hit, out) {
587
+ const line = lines[i];
588
+ // HTML comment: raw until '-->'
589
+ if (hit.isComment) {
590
+ let end = line.indexOf('-->', hit.index);
591
+ if (end !== -1) {
592
+ out.push(line.slice(hit.index, end + 3));
593
+ return this.pushBack(lines, i, line.slice(end + 3));
594
+ }
595
+ out.push(line.slice(hit.index));
596
+ while (++i < lines.length) {
597
+ end = lines[i].indexOf('-->');
598
+ if (end !== -1) {
599
+ out.push(lines[i].slice(0, end + 3));
600
+ return this.pushBack(lines, i, lines[i].slice(end + 3));
601
+ }
602
+ out.push(lines[i]);
603
+ }
604
+ return lines.length - 1; // unterminated: consumed to EOF
605
+ }
606
+ // Stray closing tag or void tag: emit just the tag, resume after '>'
607
+ if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
608
+ const gt = line.indexOf('>', hit.index);
609
+ const end = gt === -1 ? line.length : gt + 1;
610
+ out.push(line.slice(hit.index, end));
611
+ return this.pushBack(lines, i, line.slice(end));
612
+ }
613
+ // Opening block tag: raw until the element is balanced (depth === 0)
614
+ let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
615
+ if (split !== -1) {
616
+ out.push(line.slice(hit.index, hit.index + split));
617
+ return this.pushBack(lines, i, line.slice(hit.index + split));
618
+ }
619
+ out.push(line.slice(hit.index));
620
+ while (depth > 0 && ++i < lines.length) {
621
+ [depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
622
+ if (split !== -1) {
623
+ out.push(lines[i].slice(0, split));
624
+ return this.pushBack(lines, i, lines[i].slice(split));
625
+ }
626
+ out.push(lines[i]);
627
+ }
628
+ return Math.min(i, lines.length - 1);
916
629
  }
917
- /**
918
- * Returns `true` when `value` is a valid, non-empty (non-zero) UUID.
919
- * @param value - The string to validate.
920
- */
921
- static parseUUIDNotEmpty(value) {
922
- const regex = /^({|()?0{8}-(0{4}-){3}0{12}(}|))?$/;
923
- return this.parseUUID(value) && !regex.test(value ?? '');
630
+ static parseBlocks(lines, opts) {
631
+ const out = [];
632
+ let paragraph = [];
633
+ const flush = () => {
634
+ if (paragraph.length > 0) {
635
+ const sep = opts.breaks ? '<br>' : ' ';
636
+ out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
637
+ paragraph = [];
638
+ }
639
+ };
640
+ for (let i = 0; i < lines.length; i++) {
641
+ const line = lines[i];
642
+ const trimmed = line.trim();
643
+ // --- Fenced code block -------------------------------------------------
644
+ const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
645
+ if (fence) {
646
+ flush();
647
+ const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
648
+ const code = [];
649
+ i++;
650
+ while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
651
+ code.push(lines[i]);
652
+ i++;
653
+ }
654
+ out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
655
+ continue;
656
+ }
657
+ // --- Blank line --------------------------------------------------------
658
+ if (!trimmed) {
659
+ flush();
660
+ continue;
661
+ }
662
+ // --- Raw HTML block passthrough (only with escaping disabled) ------------
663
+ // A block-level HTML element found ANYWHERE in the line starts a raw
664
+ // block: text before it joins the current paragraph, the element is
665
+ // emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
666
+ // any text after the closing tag resumes markdown processing.
667
+ if (!opts.escapeHtml && line.includes('<')) {
668
+ const hit = this.findHtmlBlockStart(line);
669
+ if (hit) {
670
+ const before = line.slice(0, hit.index).trim();
671
+ if (before)
672
+ paragraph.push(before);
673
+ flush();
674
+ i = this.emitHtmlBlock(lines, i, hit, out);
675
+ continue;
676
+ }
677
+ }
678
+ // --- ATX heading -------------------------------------------------------
679
+ const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
680
+ if (heading) {
681
+ flush();
682
+ const level = heading[1].length;
683
+ out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
684
+ continue;
685
+ }
686
+ // --- Horizontal rule ---------------------------------------------------
687
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
688
+ flush();
689
+ out.push('<hr>');
690
+ continue;
691
+ }
692
+ // --- Blockquote (consecutive lines, one level stripped, recursive) ------
693
+ if (/^>\s?/.test(trimmed)) {
694
+ flush();
695
+ const quoted = [];
696
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
697
+ quoted.push(lines[i].trim().replace(/^>\s?/, ''));
698
+ i++;
699
+ }
700
+ i--;
701
+ out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
702
+ continue;
703
+ }
704
+ // --- Table (header row + separator row) ---------------------------------
705
+ if (trimmed.includes('|') &&
706
+ i + 1 < lines.length &&
707
+ lines[i + 1].includes('-') &&
708
+ /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
709
+ flush();
710
+ const aligns = this.tableAligns(lines[i + 1].trim());
711
+ const head = this.tableRow(trimmed, 'th', aligns, opts);
712
+ const body = [];
713
+ i += 2;
714
+ while (i < lines.length && lines[i].trim().includes('|')) {
715
+ body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
716
+ i++;
717
+ }
718
+ i--;
719
+ out.push(`<table>\n<thead>\n${head}\n</thead>` +
720
+ (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
721
+ `\n</table>`);
722
+ continue;
723
+ }
724
+ // --- List ---------------------------------------------------------------
725
+ if (MarkdownUtils.listItemRe.test(line)) {
726
+ flush();
727
+ const [html, last] = this.parseList(lines, i, opts);
728
+ out.push(html);
729
+ i = last;
730
+ continue;
731
+ }
732
+ // --- Plain text: accumulate into current paragraph ----------------------
733
+ paragraph.push(trimmed);
734
+ }
735
+ flush();
736
+ return out.join('\n');
924
737
  }
738
+ // #endregion
739
+ // #region Lists
740
+ /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
741
+ static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
925
742
  /**
926
- * Return an empty UUID
927
- * @returns : the empty UUID
928
- */
929
- static emptyUUID() { return "00000000-0000-0000-0000-000000000000"; }
930
- ;
931
- /**
932
- * Create a new UUID
933
- * @returns : the string UUID
743
+ * Gathers the contiguous list block starting at `start`, builds it (with
744
+ * nesting) and returns [html, indexOfLastConsumedLine].
934
745
  */
935
- static generateUUID() {
936
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
937
- return crypto.randomUUID();
746
+ static parseList(lines, start, opts) {
747
+ const block = [];
748
+ let i = start;
749
+ while (i < lines.length) {
750
+ const l = lines[i];
751
+ if (!l.trim())
752
+ break; // blank line ends the list
753
+ if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
754
+ block.push(l);
755
+ i++;
756
+ }
757
+ else
758
+ break;
938
759
  }
939
- // Fallback (non-secure contexts)
940
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
941
- const r = (Math.random() * 16) | 0;
942
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
943
- return v.toString(16);
760
+ return [this.buildList(block, opts), i - 1];
761
+ }
762
+ static buildList(block, opts) {
763
+ const first = block[0].match(this.listItemRe);
764
+ const baseIndent = first[1].length;
765
+ const ordered = /^\d/.test(first[2]);
766
+ const startNum = ordered ? parseInt(first[2], 10) : 1;
767
+ const items = [];
768
+ for (const l of block) {
769
+ const m = l.match(this.listItemRe);
770
+ if (m && m[1].length <= baseIndent) {
771
+ items.push({ text: m[3], childLines: [] });
772
+ }
773
+ else if (items.length > 0) {
774
+ // Continuation or nested content: strip one nesting level of indent
775
+ items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
776
+ }
777
+ }
778
+ const lis = items.map(item => {
779
+ let inner;
780
+ // GFM task list: - [ ] / - [x]
781
+ const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
782
+ if (task) {
783
+ const checked = task[1] !== ' ' ? ' checked' : '';
784
+ inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
785
+ }
786
+ else {
787
+ inner = this.inline(item.text, opts);
788
+ }
789
+ if (item.childLines.length > 0) {
790
+ inner += '\n' + this.parseBlocks(item.childLines, opts);
791
+ }
792
+ return `<li>${inner}</li>`;
944
793
  });
794
+ const tag = ordered ? 'ol' : 'ul';
795
+ const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
796
+ return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
945
797
  }
946
- /**
947
- * Reconstruct a standard UUID (with dashes) from a 32-char hex string without dashes.
948
- * @param value : 32-character hex string
949
- * @returns : the formatted UUID or the original string if it doesn't match the expected format
950
- */
951
- static inflateUUID(value) {
952
- const s = value.trim();
953
- if (s.length !== 32)
954
- return s;
955
- return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
798
+ // #endregion
799
+ // #region Tables
800
+ static tableAligns(separator) {
801
+ return separator
802
+ .replace(/^\||\|$/g, '')
803
+ .split('|')
804
+ .map(cell => {
805
+ const t = cell.trim();
806
+ if (t.startsWith(':') && t.endsWith(':'))
807
+ return 'center';
808
+ if (t.endsWith(':'))
809
+ return 'right';
810
+ if (t.startsWith(':'))
811
+ return 'left';
812
+ return undefined;
813
+ });
956
814
  }
957
- /** Precompiled validation patterns. */
958
- 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; }
959
- static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)\/?$/i; }
960
- /**
961
- * Parse a text and return true if it is a valid email (or empty)
962
- * @param value : email
963
- * @returns : true if the email is valid or empty
964
- */
965
- static parseEmail(value) {
966
- if (!value)
967
- return true;
968
- return this.emailRegex.test(value.trim().toLowerCase());
815
+ static tableRow(row, tag, aligns, opts) {
816
+ const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
817
+ const cellsHtml = cells
818
+ .map((cell, j) => {
819
+ const align = aligns[j] ? ` align="${aligns[j]}"` : '';
820
+ return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
821
+ })
822
+ .join('');
823
+ return `<tr>${cellsHtml}</tr>`;
969
824
  }
825
+ // #endregion
826
+ // #region Inline formatting
970
827
  /**
971
- * Parse a text containing one or more email addresses separated by `;` or `,`
972
- * and return true if all of them are valid (or the text is empty).
973
- * Empty items caused by double or trailing separators are ignored.
974
- * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
975
- * @returns : true if all emails are valid or the value is empty
828
+ * Applies inline markdown to a single text segment.
829
+ * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
830
+ * placeholders so later regex passes can never corrupt it.
976
831
  */
977
- static parseEmails(value) {
978
- if (!value || value.trim().length === 0)
979
- return true;
980
- const items = value
981
- .split(/[;,]/)
982
- .map(e => e.trim().toLowerCase())
983
- .filter(e => e.length > 0);
984
- if (items.length === 0)
985
- return false; // only separators, no addresses
986
- return items.every(e => this.emailRegex.test(e));
832
+ static inline(text, opts) {
833
+ const store = [];
834
+ const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
835
+ // 1. Inline code: stash first so later passes can't touch it.
836
+ // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
837
+ // (the browser renders entities back to the original characters).
838
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
839
+ // 2. Escape raw HTML as entities (opt-in only)
840
+ if (opts.escapeHtml)
841
+ s = this.escape(s);
842
+ // 3. Images: ![alt](url "title")
843
+ s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
844
+ const url = this.safeUrl(src);
845
+ if (!url)
846
+ return alt;
847
+ const t = title ? ` title="${this.escapeAttr(title)}"` : '';
848
+ return stash(`<img src="${url}" alt="${this.escapeAttr(alt)}"${t} loading="lazy">`);
849
+ });
850
+ // 4. Links: [text](url "title")
851
+ s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
852
+ const url = this.safeUrl(href);
853
+ if (!url)
854
+ return txt;
855
+ const t = title ? ` title="${this.escapeAttr(title)}"` : '';
856
+ return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
857
+ });
858
+ // 5. Autolink remaining bare URLs (markdown links are already stashed)
859
+ s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
860
+ // 6. Bold / italic / strikethrough.
861
+ // Delimiters must hug the text; underscore emphasis only at word
862
+ // boundaries so identifiers like snake_case are left untouched.
863
+ s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
864
+ s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
865
+ s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
866
+ s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
867
+ s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
868
+ s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
869
+ s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
870
+ // 7. Restore stashed HTML
871
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
987
872
  }
988
- /**
989
- * Parse a text and return true if it is a valid url (or empty)
990
- * @param value : the url to parse
991
- * @returns : true if the url is valid or empty
992
- */
993
- static parseUrl(value) {
994
- if (!value)
995
- return true;
996
- return this.urlRegex.test(value.trim().toLowerCase());
873
+ // #endregion
874
+ // #region Security helpers
875
+ /** Escapes quotes for safe interpolation inside a double-quoted HTML attribute. */
876
+ static escapeAttr(s) {
877
+ return s.replace(/&(?!(?:amp|lt|gt|quot|#\d+);)/g, '&amp;').replace(/"/g, '&quot;');
878
+ }
879
+ /** Escapes &, <, > and " for safe HTML interpolation. */
880
+ static escape(s) {
881
+ return s
882
+ .replace(/&/g, '&amp;')
883
+ .replace(/</g, '&lt;')
884
+ .replace(/>/g, '&gt;')
885
+ .replace(/"/g, '&quot;');
997
886
  }
998
887
  /**
999
- * Get date parts from a string value
1000
- * @param value : the string to parse
1001
- * @returns : an array of numbers with year, month, day
888
+ * Returns a sanitized URL or undefined when the scheme is dangerous.
889
+ * Blocks javascript:, vbscript: and data: (also when obfuscated with
890
+ * whitespace/control characters, e.g. "java\tscript:").
1002
891
  */
1003
- static getDateParts(value) {
1004
- if (!value)
892
+ static safeUrl(url) {
893
+ const compact = url.replace(/[\s\u0000-\u001f]/g, '');
894
+ if (/^(javascript|vbscript|data):/i.test(compact))
1005
895
  return undefined;
1006
- let parts = [];
1007
- if (value.indexOf("-") !== -1) {
1008
- const p = value.split("-");
1009
- if (p.length !== 3)
1010
- return undefined;
1011
- parts = [parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])];
1012
- }
1013
- else if (value.indexOf("/") !== -1) {
1014
- const p = value.split("/");
1015
- if (p.length !== 3)
1016
- return undefined;
1017
- parts = [parseInt(p[2]), parseInt(p[1]) - 1, parseInt(p[0])];
1018
- }
1019
- return parts;
896
+ return url.replace(/"/g, '%22');
1020
897
  }
898
+ // #endregion
899
+ // #region Clipboard
1021
900
  /**
1022
- * Parse a date
1023
- * @param value : the value to check
1024
- * @param locale : the locale to use
1025
- * @returns : a valid Date object or null
901
+ * Copies markdown content to the clipboard in two flavors:
902
+ * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
903
+ * - text/plain : the original markdown source (paste into editors, IDEs...)
904
+ * @param markdown : the markdown source
905
+ * @param options : conversion options for the HTML flavor
906
+ * @returns : true on success
1026
907
  */
1027
- static parseDate(value, locale = it) {
1028
- // No value at all
1029
- if (!value)
1030
- return undefined;
1031
- // A Date value
1032
- if (value instanceof Date && value.getTime())
1033
- return value;
1034
- // Parse known formats using date-fns
1035
- let d = parseISO(value);
1036
- if (d && d.getTime() && d.getFullYear() > 1750)
1037
- return d;
1038
- d = parse(value, 'dd/MM/yyyy', new Date(), { locale: locale });
1039
- if (d && d.getTime() && d.getFullYear() > 1750)
1040
- return d;
1041
- d = parse(value, 'yyyy-MM-dd', new Date(), { locale: locale });
1042
- if (d && d.getTime() && d.getFullYear() > 1750)
1043
- return d;
1044
- // Parse values manually
1045
- const parts = this.getDateParts(value);
1046
- if (!parts)
1047
- return undefined;
1048
- if (parts[0] < 100)
1049
- parts[0] += 2000;
1050
- if (isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1051
- return undefined;
1052
- if (isNaN(parts[1]) || parts[1] < 0)
1053
- return undefined;
1054
- if (isNaN(parts[2]) || parts[2] < 0)
1055
- return undefined;
1056
- if (parts[1] > 11)
1057
- return undefined;
1058
- if (parts[1] === 1 && parts[2] > 29)
1059
- return undefined;
1060
- else if (parts[1] !== 1 && parts[2] > 31)
1061
- return undefined;
1062
- return new TZDate(parts[0], parts[1], parts[2], 12);
908
+ static async copyToClipboard(markdown, options) {
909
+ if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
910
+ return false;
911
+ try {
912
+ if (typeof ClipboardItem !== 'undefined') {
913
+ const html = this.toHtml(markdown, options);
914
+ await navigator.clipboard.write([
915
+ new ClipboardItem({
916
+ 'text/html': new Blob([html], { type: 'text/html' }),
917
+ 'text/plain': new Blob([markdown], { type: 'text/plain' }),
918
+ }),
919
+ ]);
920
+ }
921
+ else {
922
+ // Older engines: plain text only
923
+ await navigator.clipboard.writeText(markdown);
924
+ }
925
+ return true;
926
+ }
927
+ catch {
928
+ // Clipboard API requires a secure context and user activation
929
+ return false;
930
+ }
1063
931
  }
1064
932
  /**
1065
- * Format a date
1066
- * @param value : the date or string to format
1067
- * @param fmt : the DateFormat to use or the string pattern
1068
- * @param locale : the locale to use (default is IT)
1069
- * @returns : the formatted string
933
+ * Copies plain text (e.g. the content of a single code block) to the clipboard.
934
+ * @param text : the text to copy
935
+ * @returns : true on success
1070
936
  */
1071
- static formatDate(value, fmt = DateFormat.Short, locale = it) {
1072
- // No value at all
1073
- if (!value)
1074
- return '';
1075
- // A string
1076
- if (typeof value === 'string' || value instanceof String)
1077
- value = this.parseDate(value);
1078
- // Not a date
1079
- if (!(value instanceof Date && value.getTime()))
1080
- return '';
1081
- // Format
1082
- switch (fmt) {
1083
- case DateFormat.Short: return format(value, "dd/MM/yyyy", { locale: locale });
1084
- case DateFormat.Long: return format(value, "d MMMM yyyy", { locale: locale });
1085
- case DateFormat.LongWithShortMonth: return format(value, "d MMM yyyy", { locale: locale });
1086
- case DateFormat.LongWithWeekDay: return format(value, "EEEE, d MMMM yyyy", { locale: locale });
1087
- case DateFormat.LongWithShortWeekDay: return format(value, "EEE, d MMMM yyyy", { locale: locale });
1088
- case DateFormat.MonthAndYear: return format(value, "MMM yyyy", { locale: locale });
1089
- case DateFormat.LongMonthAndYear: return format(value, "MMMM yyyy", { locale: locale });
1090
- case DateFormat.WeekDay: return format(value, "EEE, d", { locale: locale });
1091
- case DateFormat.LongWeekDay: return format(value, "EEEE, d", { locale: locale });
1092
- case DateFormat.DayAndMonth: return format(value, "d MMMM", { locale: locale });
1093
- case DateFormat.ShortUS: return format(value, "MM/dd/yyyy", { locale: locale });
1094
- case DateFormat.ShortISO8601: return format(value, "yyyy-MM-dd", { locale: locale });
1095
- default:
1096
- return format(value, fmt, { locale: locale });
937
+ static async copyText(text) {
938
+ if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
939
+ return false;
940
+ try {
941
+ await navigator.clipboard.writeText(text);
942
+ return true;
943
+ }
944
+ catch {
945
+ return false;
1097
946
  }
1098
947
  }
1099
948
  /**
1100
- * Return an italian local date
1101
- * @param value : the date
1102
- * @returns : the new date
949
+ * Extracts the visible plain text from a rendered element
950
+ * (what the user sees, entities already decoded by the browser).
951
+ * @param element : the element hosting the rendered markdown
952
+ * @returns : the plain text
1103
953
  */
1104
- static toLocalDate(value) {
1105
- // No value at all
1106
- if (!value)
1107
- return undefined;
1108
- // A string
1109
- if (typeof value === 'string' || value instanceof String)
1110
- value = this.parseDate(value);
1111
- // Not a date
1112
- if (!(value instanceof Date && value.getTime()))
1113
- return undefined;
1114
- // Update date
1115
- return new TZDate(value, "Europe/Rome");
954
+ static elementToText(element) {
955
+ return element.innerText ?? element.textContent ?? '';
1116
956
  }
957
+ }
958
+
959
+ var DateFormat;
960
+ (function (DateFormat) {
961
+ DateFormat[DateFormat["Short"] = 1] = "Short";
962
+ DateFormat[DateFormat["Long"] = 2] = "Long";
963
+ DateFormat[DateFormat["LongWithShortMonth"] = 3] = "LongWithShortMonth";
964
+ DateFormat[DateFormat["LongWithWeekDay"] = 4] = "LongWithWeekDay";
965
+ DateFormat[DateFormat["LongWithShortWeekDay"] = 5] = "LongWithShortWeekDay";
966
+ DateFormat[DateFormat["MonthAndYear"] = 6] = "MonthAndYear";
967
+ DateFormat[DateFormat["LongMonthAndYear"] = 7] = "LongMonthAndYear";
968
+ DateFormat[DateFormat["WeekDay"] = 8] = "WeekDay";
969
+ DateFormat[DateFormat["LongWeekDay"] = 9] = "LongWeekDay";
970
+ DateFormat[DateFormat["DayAndMonth"] = 10] = "DayAndMonth";
971
+ DateFormat[DateFormat["ShortUS"] = 11] = "ShortUS";
972
+ DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
973
+ })(DateFormat || (DateFormat = {}));
974
+ class SystemUtils {
975
+ /** Shared collator for locale-aware, case-insensitive string comparison. */
976
+ static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
1117
977
  /**
1118
- * Update a DateInterval object according to a string
1119
- * @param value : string value
1120
- * @param interval : DateInterval value to update
1121
- * @param end : true if must be updated the first or the end value
1122
- * @param copy : copy the same value (works only if not end element)
978
+ * Array find by key
979
+ * @param array : the array to scan
980
+ * @param key : key name
981
+ * @param value : the value to search for
982
+ * @returns : the property value or null
1123
983
  */
1124
- static changeDateInterval(value, interval, end = false, copy = false) {
1125
- if (value) {
1126
- let year = -1;
1127
- if (value.length === 4 && (year = parseInt(value)) > 1750) {
1128
- if (!end) {
1129
- interval.from = new TZDate(new Date(year, 0, 1), 'Europe/Rome');
1130
- interval.to = new TZDate(new Date(year, 11, 31), 'Europe/Rome');
1131
- }
1132
- else
1133
- interval.to = new Date(year, 11, 31);
1134
- }
1135
- else {
1136
- let parts = this.getDateParts(value);
1137
- if (!parts || isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1138
- return;
1139
- const d = new TZDate(new Date(parts[0], parts[1], parts[2]), 'Europe/Rome');
1140
- if (end)
1141
- interval.to = d;
1142
- else if (copy) {
1143
- interval.from = d;
1144
- interval.to = d;
1145
- }
1146
- else
1147
- interval.from = d;
984
+ static arrayFindByKey(array, key, value) {
985
+ if (!array)
986
+ return undefined;
987
+ for (const item of array) {
988
+ if (item[key] === value) {
989
+ return item;
1148
990
  }
1149
991
  }
992
+ return undefined;
1150
993
  }
1151
994
  /**
1152
- * Formats a number using `Intl.NumberFormat`.
1153
- * @param value - The number to format.
1154
- * @param decimals - Maximum decimal places (default: `2`).
1155
- * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1156
- * @returns The formatted number string.
995
+ * Array find index by key
996
+ * @param array : the array to scan
997
+ * @param key : the key name
998
+ * @param value : the value to search for
999
+ * @returns : the array index or -1 if not found
1157
1000
  */
1158
- static formatNumber(value, decimals = 2, locale = 'it-IT') {
1159
- return Intl.NumberFormat(locale, { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1001
+ static arrayFindIndexByKey(array, key, value) {
1002
+ if (!array)
1003
+ return -1;
1004
+ for (let i = 0; i < array.length; i++) {
1005
+ if (array[i][key] === value) {
1006
+ return i;
1007
+ }
1008
+ }
1009
+ return -1;
1160
1010
  }
1161
1011
  /**
1162
- * Formats a number as a currency string using `Intl.NumberFormat`.
1163
- * @param value - The number to format.
1164
- * @param currency - ISO 4217 currency code (default: `'EUR'`).
1165
- * @param decimals - Maximum decimal places (default: `2`).
1166
- * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1167
- * @returns The formatted currency string.
1012
+ * Get a value from and array made of name|value items
1013
+ * @param array : the array to scan
1014
+ * @param value : the value to search for
1015
+ * @returns : the property value or null if not found
1168
1016
  */
1169
- static formatCurrency(value, currency = 'EUR', decimals = 2, locale = 'it-IT') {
1170
- return Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1017
+ static arrayGetValue(array, value) {
1018
+ if (!array)
1019
+ return undefined;
1020
+ for (const item of array) {
1021
+ const i = item;
1022
+ if (i['value'] === value) {
1023
+ return i['name'] ?? i['id'];
1024
+ }
1025
+ }
1026
+ return undefined;
1171
1027
  }
1172
1028
  /**
1173
- * Percent-encodes a string for safe inclusion in a URL.
1174
- * @param value - The string to encode.
1175
- * @returns The encoded string, or `undefined` when `value` is empty.
1029
+ * Convert items to nodes into a tree structure
1030
+ * @param items : list of nodes
1031
+ * @param parent : parent node
1032
+ * @returns : an array of INode objects
1176
1033
  */
1177
- static urlEncode(value) {
1178
- return value.length > 0
1179
- ? encodeURIComponent(value)
1180
- : undefined;
1034
+ static arrayToNodes(items, parent) {
1035
+ const nodes = [];
1036
+ items.forEach(n => {
1037
+ const node = {
1038
+ id: n.id.toString(),
1039
+ name: n.name,
1040
+ count: n.count ? n.count : 0,
1041
+ children: undefined,
1042
+ parent,
1043
+ bag: n,
1044
+ };
1045
+ nodes.push(node);
1046
+ node.children =
1047
+ n.children && n.children.length > 0
1048
+ ? this.arrayToNodes(n.children, node)
1049
+ : [];
1050
+ });
1051
+ return nodes;
1181
1052
  }
1182
1053
  /**
1183
- * Decodes a percent-encoded URL string, treating `+` as a space.
1184
- * @param value - The encoded string to decode.
1185
- * @returns The decoded string, or `undefined` when `value` is empty or absent.
1054
+ * Comparator factory for sorting arrays of objects by a given property key.
1055
+ * Pass the element type for compile-time key checking:
1056
+ * `items.sort(SystemUtils.arraySortCompare<Employee>('lastName'))`.
1057
+ * @param key - Name of the property to sort by (autocompleted from `T` when provided).
1058
+ * @param order - Sort direction: `'asc'` (default) or `'desc'`.
1059
+ * @returns A comparator function that returns a negative, zero, or positive number.
1186
1060
  */
1187
- static urlDecode(value) {
1188
- return value && value.length > 0
1189
- ? decodeURIComponent(value.replace(/\+/g, '%20'))
1190
- : undefined;
1061
+ static arraySortCompare(key, order = 'asc') {
1062
+ const dir = order === 'desc' ? -1 : 1;
1063
+ return (a, b) => {
1064
+ const varA = a[key];
1065
+ const varB = b[key];
1066
+ if (varA === varB)
1067
+ return 0;
1068
+ if (varA === undefined || varA === null)
1069
+ return -1 * dir;
1070
+ if (varB === undefined || varB === null)
1071
+ return 1 * dir;
1072
+ if (typeof varA === 'string' && typeof varB === 'string') {
1073
+ return SystemUtils.collator.compare(varA, varB) * dir;
1074
+ }
1075
+ return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
1076
+ };
1191
1077
  }
1192
1078
  /**
1193
- * Reads a query string parameter value from the current page URL.
1194
- * @param name - The parameter name to look up.
1195
- * @returns The decoded parameter value, or `undefined` when absent or running server-side.
1079
+ * Format weight
1080
+ * @param gr : grams
1081
+ * @returns : the formatted string
1196
1082
  */
1197
- static getQueryStringValueByName(name) {
1198
- if (!this.isBrowser())
1199
- return undefined;
1200
- const v = new URLSearchParams(window.location.search).get(name);
1201
- return v && v !== 'null' ? v : undefined;
1083
+ static formatWeight(gr) {
1084
+ if (gr > 1000000)
1085
+ return `${(gr / 1000000).toFixed(2)} t`;
1086
+ else if (gr > 100000)
1087
+ return `${(gr / 100000).toFixed(2)} q`;
1088
+ else if (gr > 1000)
1089
+ return `${(gr / 1000).toFixed(2)} kg`;
1090
+ else
1091
+ return `${gr} gr`;
1202
1092
  }
1203
1093
  /**
1204
- * Generate a password
1205
- * @returns : the password string
1094
+ * Format file size
1095
+ * @param bytes : number of bytes
1096
+ * @returns : the formatted string
1206
1097
  */
1207
- static generatePassword() {
1208
- const random = "$" + Math.random().toString(36).slice(-11);
1209
- let password = "";
1210
- let hasUpperCase = false;
1211
- for (const rnd of random) {
1212
- if (!hasUpperCase && "abcdefghjilmnopqrstuvywxz".includes(rnd)) {
1213
- password += rnd.toUpperCase();
1214
- hasUpperCase = true;
1215
- }
1216
- else {
1217
- password += rnd;
1218
- }
1219
- }
1220
- if (!hasUpperCase)
1221
- password = password.substring(0, 11) + "M";
1222
- return password;
1098
+ static formatFileSize(bytes) {
1099
+ const MB = 1024 * 1024;
1100
+ if (bytes >= MB)
1101
+ return `${(bytes / MB).toFixed(1)} MB`;
1102
+ if (bytes >= 1024)
1103
+ return `${(bytes / 1024).toFixed(1)} KB`;
1104
+ return `${bytes} byte`;
1105
+ }
1106
+ /**
1107
+ * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
1108
+ * @param a : string a
1109
+ * @param b : string b
1110
+ * @returns : 0 if equals, 1 if bigger, -1 if lower
1111
+ */
1112
+ static compareString(a, b) {
1113
+ return this.collator.compare(a ?? '', b ?? '');
1223
1114
  }
1224
1115
  /**
1225
- * Calculate password strength
1226
- * @param password: the password to evaluate
1227
- * @returns the password strength info
1116
+ * Capitalize a string
1117
+ * @param s : the string to capitalize
1118
+ * @returns : the capitalized string
1228
1119
  */
1229
- static calculatePasswordStrength(password) {
1230
- if (password && password.length > 0) {
1231
- let score = 0;
1232
- const suggestions = [];
1233
- // Length
1234
- if (password.length >= 10)
1235
- score++;
1236
- else
1237
- suggestions.push('Usa almeno 10 caratteri.');
1238
- if (password.length >= 12)
1239
- score++;
1240
- else if (password.length >= 10)
1241
- suggestions.push('Considera di usare più di 12 caratteri.');
1242
- // Lowercase letters
1243
- if (/[a-z]/.test(password))
1244
- score++;
1245
- else
1246
- suggestions.push('Aggiungi lettere minuscole.');
1247
- // Uppercase letters
1248
- if (/[A-Z]/.test(password))
1249
- score++;
1250
- else
1251
- suggestions.push('Aggiungi lettere maiuscole.');
1252
- // Numbers
1253
- if (/\d/.test(password))
1254
- score++;
1255
- else
1256
- suggestions.push('Aggiungi numeri.');
1257
- // Special characters
1258
- if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password))
1259
- score++;
1260
- else
1261
- suggestions.push('Aggiungi caratteri speciali (!@#$%^&*).');
1262
- // Common patterns
1263
- if (/(.)\1{2,}/.test(password)) {
1264
- score = Math.max(0, score - 1);
1265
- suggestions.push('Evita di ripetere lo stesso carattere.');
1266
- }
1267
- if (/123|abc|qwe/i.test(password)) {
1268
- score = Math.max(0, score - 1);
1269
- suggestions.push('Evita sequenze comuni (123, abc, qwe).');
1270
- }
1271
- // Label and color
1272
- let label;
1273
- let color;
1274
- let isValid;
1275
- if (score <= 2) {
1276
- label = 'Molto debole';
1277
- color = '#f44336';
1278
- isValid = false;
1279
- }
1280
- else if (score <= 3) {
1281
- label = 'Debole';
1282
- color = '#ff9800';
1283
- isValid = false;
1284
- }
1285
- else if (score <= 4) {
1286
- label = 'Media';
1287
- color = '#ffc107';
1288
- isValid = true;
1120
+ static capitalize(s) {
1121
+ if (!s)
1122
+ return undefined;
1123
+ let b = "";
1124
+ let cap = true;
1125
+ for (const char of s) {
1126
+ if (char === " ") {
1127
+ b += char;
1128
+ cap = true;
1289
1129
  }
1290
- else if (score <= 5) {
1291
- label = 'Forte';
1292
- color = '#8bc34a';
1293
- isValid = true;
1130
+ else if (cap) {
1131
+ b += char.toUpperCase();
1132
+ cap = false;
1294
1133
  }
1295
1134
  else {
1296
- label = 'Molto forte';
1297
- color = '#4caf50';
1298
- isValid = true;
1135
+ b += char.toLowerCase();
1299
1136
  }
1300
- return { score, label, color, suggestions, isValid };
1301
1137
  }
1302
- else
1303
- return {
1304
- score: 0,
1305
- isValid: false,
1306
- suggestions: []
1307
- };
1138
+ return b;
1308
1139
  }
1309
1140
  /**
1310
- * Check if current browser supports touch
1311
- * @returns : true if the display is touchable
1141
+ * Truncate a string at the last word boundary before `max`.
1142
+ * @param s : the string to truncate
1143
+ * @param max : the max number of chars
1144
+ * @returns : the truncated string
1312
1145
  */
1313
- static isTouchable() {
1314
- return (this.isBrowser() && ("ontouchstart" in window ||
1315
- navigator.maxTouchPoints > 0));
1146
+ static truncate(s, max = 500) {
1147
+ if (!s)
1148
+ return undefined;
1149
+ if (s.length <= max)
1150
+ return s;
1151
+ const i = s.lastIndexOf(' ', max - 1);
1152
+ return i > 0 ? s.slice(0, i) : s;
1316
1153
  }
1317
1154
  /**
1318
- * This check will prevent 'window' logic to be executed
1319
- * while executing the server rendering
1320
- * @returns : true if using the browser
1155
+ * Join a list of strings
1156
+ * @param items : the list of strings
1157
+ * @param sep : the separator string
1158
+ * @param max : the maximum resulting string
1159
+ * @returns : the joined string
1321
1160
  */
1322
- static isBrowser() {
1323
- return typeof (window) !== 'undefined';
1161
+ static join(items, sep = " ", max = 350) {
1162
+ if (!items || items.length === 0)
1163
+ return undefined;
1164
+ if (items.length > 1) {
1165
+ let l = 0;
1166
+ let s = "";
1167
+ while (s.length < max && items.length > l) {
1168
+ if (l > 0) {
1169
+ s += sep;
1170
+ }
1171
+ s += items[l++];
1172
+ }
1173
+ if (items.length > l) {
1174
+ s += "...";
1175
+ }
1176
+ return s;
1177
+ }
1178
+ return items[0];
1324
1179
  }
1325
1180
  /**
1326
- * Convert folders in a tree of Node object.
1327
- * @param folders : the subfolders group or null to root
1328
- * @returns : a node list
1181
+ * Normalize a string by converting it to lowercase and removing extra spaces, with special handling for acronyms and camelCase.
1182
+ * @param s : the string to normalize
1183
+ * @returns : The normalized string, or `undefined` if the input is falsy.
1329
1184
  */
1330
- static toNodes(folders) {
1331
- return this._toNodes(folders, undefined);
1185
+ static normalizeDisplay(s) {
1186
+ if (!s)
1187
+ return s;
1188
+ return s
1189
+ .split(' ')
1190
+ .map((word, wordIndex) => {
1191
+ if (!word)
1192
+ return word;
1193
+ // If the word is all uppercase and contains at least one letter, we assume it's an acronym and leave it as is
1194
+ if (word === word.toUpperCase() && /[A-Z]/.test(word)) {
1195
+ return word;
1196
+ }
1197
+ // Otherwise, convert to lowercase letter by letter
1198
+ const chars = word.split('');
1199
+ return chars
1200
+ .map((char, charIndex) => {
1201
+ if (charIndex === 0 && wordIndex === 0) {
1202
+ // First letter of the first word:
1203
+ // remains uppercase only if the first 2 letters are both uppercase
1204
+ const secondChar = chars[1] ?? '';
1205
+ const keepUppercase = char === char.toUpperCase() &&
1206
+ secondChar === secondChar.toUpperCase() &&
1207
+ /[A-Z]/.test(char) &&
1208
+ /[A-Z]/.test(secondChar);
1209
+ return keepUppercase ? char : char.toLowerCase();
1210
+ }
1211
+ return char.toLowerCase();
1212
+ })
1213
+ .join('');
1214
+ })
1215
+ .join(' ');
1332
1216
  }
1333
1217
  /**
1334
- * Convert folders in a tree of Node object.
1335
- * @param folders : the children group or null to root
1336
- * @param parent : the parent node
1337
- * @returns : a node list
1218
+ * Wraps bare URLs in the given string with `<a>` anchor tags.
1219
+ * @param s - The plain-text or HTML string to process.
1220
+ * @returns The string with URLs replaced by clickable links, or `''` when `s` is falsy.
1338
1221
  */
1339
- static _toNodes(folders, parent) {
1340
- const nodes = [];
1341
- folders.forEach((n) => {
1342
- const node = {
1343
- id: n.id,
1344
- name: n.name,
1345
- count: n.count,
1346
- parent: parent,
1347
- children: undefined,
1348
- bag: n,
1349
- };
1350
- const children = n.children ?? n.subFolders ?? [];
1351
- if (children.length > 0) {
1352
- node.children = this._toNodes(children, node);
1353
- }
1354
- nodes.push(node);
1355
- });
1356
- return nodes;
1222
+ static replaceAsHtml(s) {
1223
+ if (!s)
1224
+ return '';
1225
+ return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
1357
1226
  }
1358
1227
  /**
1359
- * Returns an array of individual power-of-2 flag values that are set in `value`.
1360
- * @param value - The bitmask to decompose.
1361
- * @param max - Upper-bound exponent: checks flags from `1` up to `1 << max` (default: `30`).
1362
- * @returns Array of set flag values, or an empty array when `value` is `0`.
1228
+ * Convert markdown to html
1229
+ * @param markdown : the markdown data
1230
+ * @param escapeHtml : true to escape HTML. Default is false
1231
+ * @returns the html
1363
1232
  */
1364
- static getFlags(value, max = 30) {
1365
- if (value !== 0) {
1366
- const items = [];
1367
- let v = 1;
1368
- const m = 1 << max;
1369
- while (v < m) {
1370
- if ((value & v) === v)
1371
- items.push(v);
1372
- v = v << 1;
1373
- }
1374
- return items;
1375
- }
1376
- else
1377
- return [];
1233
+ static markdownToHtml(markdown, escapeHtml = false) {
1234
+ return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
1378
1235
  }
1379
- /** Cache for resolved color luminance results. */
1380
- static { this.colorLightCache = new Map(); }
1381
1236
  /**
1382
- * Check if a color is light or dark
1383
- * @param color : the color
1384
- * @param minimumLuminance : the lumimance to consider
1385
- * @returns true if the color is light
1386
- */
1387
- static isColorLight(color, minimumLuminance = 186) {
1388
- if (!this.isBrowser())
1389
- return true; // SSR fallback
1390
- const cacheKey = `${color}|${minimumLuminance}`;
1391
- const cached = this.colorLightCache.get(cacheKey);
1392
- if (cached !== undefined)
1393
- return cached;
1394
- const tempDiv = document.createElement('div');
1395
- tempDiv.style.color = color;
1396
- document.body.appendChild(tempDiv);
1397
- const rgb = getComputedStyle(tempDiv).color;
1398
- document.body.removeChild(tempDiv);
1399
- const result = rgb.match(/\d+/g);
1400
- let light = true; // fallback
1401
- if (result && result.length >= 3) {
1402
- const r = parseInt(result[0], 10);
1403
- const g = parseInt(result[1], 10);
1404
- const b = parseInt(result[2], 10);
1405
- light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
1237
+ * Compare two names
1238
+ * @param a : name a
1239
+ * @param b : name b
1240
+ * @returns : true if a equals b
1241
+ */
1242
+ static compareNames(a, b) {
1243
+ if (a)
1244
+ a = a.trim();
1245
+ if (b)
1246
+ b = b.trim();
1247
+ if (a && b && a.length !== b.length)
1248
+ return false;
1249
+ if (this.compareString(a, b) === 0)
1250
+ return true;
1251
+ const p1 = (a ?? '').split(' ');
1252
+ const p2 = (b ?? '').split(' ');
1253
+ if (p1.length !== p2.length)
1254
+ return false;
1255
+ let matches = p1.length;
1256
+ const used = new Array(p2.length).fill(false);
1257
+ for (const s1 of p1) {
1258
+ for (let j = 0; j < p2.length; j++) {
1259
+ if (!used[j] && this.compareString(s1, p2[j]) === 0) {
1260
+ used[j] = true;
1261
+ matches--;
1262
+ break;
1263
+ }
1264
+ }
1406
1265
  }
1407
- this.colorLightCache.set(cacheKey, light);
1408
- return light;
1266
+ return matches === 0;
1409
1267
  }
1410
- }
1411
-
1412
- /**
1413
- * General-purpose formatting pipe that converts a raw value to a locale-aware string
1414
- * based on the specified format type.
1415
- *
1416
- * Supported types: `'date'` / `'D'`, `'currency'` / `'C'`, `'number'` / `'N'`,
1417
- * `'number0'` / `'N0'`, `'percentage'` / `'P'`.
1418
- *
1419
- * Usage: `{{ value | format:'currency' }}`
1420
- */
1421
- class FormatPipe {
1422
1268
  /**
1423
- * Formats a value according to the specified type and optional pattern.
1424
- * Returns `undefined` when the value is `null` or `undefined`, or when the type is unrecognised.
1425
- * @param value - The raw value to format.
1426
- * @param type - The format type identifier (default: `'date'`).
1427
- * @param pattern - The date pattern used when `type` is `'date'` (default: `'dd/MM/yyyy'`).
1428
- * @returns A formatted string, or `undefined` when the value cannot be formatted.
1269
+ * Obfuscate a text with a key-driven code point rotation.
1270
+ *
1271
+ * @deprecated This is OBFUSCATION, not cryptography: it hides values from
1272
+ * casual inspection but offers no real security. Use {@link encrypt} /
1273
+ * {@link decrypt} (AES-GCM via Web Crypto) for anything sensitive.
1274
+ *
1275
+ * The rotation operates on Unicode code points and skips the surrogate
1276
+ * range, so the output is always a well-formed string: emoji and other
1277
+ * astral characters round-trip correctly (the previous code-unit based
1278
+ * version could emit lone surrogates and corrupt them).
1279
+ *
1280
+ * BREAKING CHANGE: values encoded with the previous code-unit algorithm
1281
+ * cannot be decoded by this implementation.
1282
+ *
1283
+ * @param text : the text to encode or decode
1284
+ * @param key : the key
1285
+ * @param reverse : true to decode, false to encode
1286
+ * @returns : the obfuscated (or restored) text, or undefined when text/key are empty
1429
1287
  */
1430
- transform(value, type = 'date', pattern = 'dd/MM/yyyy') {
1431
- if (value === undefined || value === null)
1288
+ static cipher(text, key, reverse = false) {
1289
+ if (!text || !key)
1432
1290
  return undefined;
1433
- switch (type) {
1434
- case 'D':
1435
- case 'date': {
1436
- const d = SystemUtils.parseDate(value, it);
1437
- if (d)
1438
- return format(d, pattern, { locale: it });
1439
- break;
1440
- }
1441
- case 'C':
1442
- case 'currency':
1443
- return new Intl.NumberFormat('it-IT', { style: 'currency', currency: 'EUR' }).format(value);
1444
- case 'N':
1445
- case 'number':
1446
- return new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(value);
1447
- case 'N0':
1448
- case 'number0':
1449
- return new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(value);
1450
- case 'P':
1451
- case 'percentage':
1452
- return new Intl.NumberFormat('it-IT', { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(value);
1291
+ // Valid Unicode scalar values: [0, 0xD7FF] + [0xE000, 0x10FFFF].
1292
+ // We rotate inside this contiguous "logical" space (surrogates excluded)
1293
+ // so the result is always a valid code point.
1294
+ const SURROGATE_START = 0xd800;
1295
+ const SURROGATE_SIZE = 0x800;
1296
+ const SPACE = 0x110000 - SURROGATE_SIZE; // number of valid scalar values
1297
+ const toIndex = (cp) => (cp < SURROGATE_START ? cp : cp - SURROGATE_SIZE);
1298
+ const toCodePoint = (i) => (i < SURROGATE_START ? i : i + SURROGATE_SIZE);
1299
+ const keyLen = key.length;
1300
+ let result = '';
1301
+ let i = 0;
1302
+ for (const ch of text) { // iterates by code point, not code unit
1303
+ let rotation = key.charCodeAt(i % keyLen);
1304
+ if (reverse)
1305
+ rotation = -rotation;
1306
+ const index = toIndex(ch.codePointAt(0));
1307
+ const rotated = ((index + rotation) % SPACE + SPACE) % SPACE;
1308
+ result += String.fromCodePoint(toCodePoint(rotated));
1309
+ i++;
1453
1310
  }
1454
- return undefined;
1311
+ return result;
1455
1312
  }
1456
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1457
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, isStandalone: true, name: "format" }); }
1458
- }
1459
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, decorators: [{
1460
- type: Pipe,
1461
- args: [{
1462
- name: 'format',
1463
- standalone: true
1464
- }]
1465
- }] });
1466
-
1467
- /**
1468
- * Pipe that applies a global regex replacement on a string and returns the result
1469
- * as sanitized HTML. When `regexValue` is `'\n'` and no `replaceValue` is given,
1470
- * newlines are replaced with `<br>` tags.
1471
- *
1472
- * Usage: `{{ text | replace:'\n':'' }}`
1473
- */
1474
- class ReplacePipe {
1475
- constructor() {
1476
- this.sanitizer = inject(DomSanitizer);
1313
+ // #region Real encryption (Web Crypto, AES-GCM)
1314
+ /** Payload format version, first byte of the binary envelope. */
1315
+ static { this.CIPHER_VERSION = 1; }
1316
+ /** PBKDF2 salt length in bytes. */
1317
+ static { this.CIPHER_SALT_LENGTH = 16; }
1318
+ /** AES-GCM IV length in bytes (96 bit, the recommended size for GCM). */
1319
+ static { this.CIPHER_IV_LENGTH = 12; }
1320
+ /** PBKDF2-HMAC-SHA-256 iterations (OWASP recommendation). */
1321
+ static { this.CIPHER_ITERATIONS = 600000; }
1322
+ /**
1323
+ * Derives an AES-GCM 256-bit key from a password using PBKDF2-HMAC-SHA-256.
1324
+ * @param password : the user password / passphrase
1325
+ * @param salt : the random salt bound to this payload
1326
+ * @returns : a non-extractable AES-GCM CryptoKey
1327
+ */
1328
+ static async deriveKey(password, salt) {
1329
+ const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
1330
+ return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: salt, iterations: this.CIPHER_ITERATIONS, hash: 'SHA-256' }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
1331
+ }
1332
+ /**
1333
+ * Encodes bytes as URL-safe Base64 (no '+', '/' or trailing '=').
1334
+ * @param bytes : the bytes to encode
1335
+ * @returns : the base64url string
1336
+ */
1337
+ static toBase64Url(bytes) {
1338
+ let binary = '';
1339
+ const CHUNK = 0x8000;
1340
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1341
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
1342
+ }
1343
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1344
+ }
1345
+ /**
1346
+ * Decodes a URL-safe Base64 string back to bytes.
1347
+ * @param value : the base64url string
1348
+ * @returns : the decoded bytes
1349
+ */
1350
+ static fromBase64Url(value) {
1351
+ const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
1352
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
1353
+ const binary = atob(padded);
1354
+ const bytes = new Uint8Array(binary.length);
1355
+ for (let i = 0; i < binary.length; i++)
1356
+ bytes[i] = binary.charCodeAt(i);
1357
+ return bytes;
1358
+ }
1359
+ /**
1360
+ * Encrypts a text with a password using AES-GCM (256 bit) and a key derived
1361
+ * via PBKDF2-HMAC-SHA-256 with a random per-message salt.
1362
+ *
1363
+ * The result is a self-contained, URL-safe Base64 payload:
1364
+ * `version (1 byte) | salt (16 bytes) | iv (12 bytes) | ciphertext+tag`.
1365
+ * GCM is authenticated: any tampering makes {@link decrypt} fail.
1366
+ *
1367
+ * Requires a secure context (HTTPS or localhost) for `crypto.subtle`.
1368
+ *
1369
+ * @param text : the plain text to encrypt
1370
+ * @param password : the password / passphrase
1371
+ * @returns : the base64url payload, or undefined when text/password are
1372
+ * empty or the Web Crypto API is unavailable
1373
+ */
1374
+ static async encrypt(text, password) {
1375
+ if (!text || !password)
1376
+ return undefined;
1377
+ if (typeof crypto === 'undefined' || !crypto.subtle)
1378
+ return undefined;
1379
+ try {
1380
+ const salt = crypto.getRandomValues(new Uint8Array(this.CIPHER_SALT_LENGTH));
1381
+ const iv = crypto.getRandomValues(new Uint8Array(this.CIPHER_IV_LENGTH));
1382
+ const key = await this.deriveKey(password, salt);
1383
+ const cipherText = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, new TextEncoder().encode(text)));
1384
+ const payload = new Uint8Array(1 + salt.length + iv.length + cipherText.length);
1385
+ payload[0] = this.CIPHER_VERSION;
1386
+ payload.set(salt, 1);
1387
+ payload.set(iv, 1 + salt.length);
1388
+ payload.set(cipherText, 1 + salt.length + iv.length);
1389
+ return this.toBase64Url(payload);
1390
+ }
1391
+ catch {
1392
+ return undefined;
1393
+ }
1477
1394
  }
1478
1395
  /**
1479
- * Replaces all occurrences of `regexValue` in `value` with `replaceValue`.
1480
- * Returns `undefined` when `value` is empty or `undefined`.
1481
- * @param value - The source string to process.
1482
- * @param regexValue - The regex pattern string to match (applied with the global flag).
1483
- * @param replaceValue - The replacement string. Defaults to `'<br>'` when `regexValue` is `'\n'` and this is falsy.
1484
- * @returns A `SafeHtml` value with all matches replaced, or `undefined` when the input is empty.
1396
+ * Decrypts a payload produced by {@link encrypt}.
1397
+ * Returns undefined when the password is wrong, the payload was tampered
1398
+ * with, the format is unknown, or the Web Crypto API is unavailable.
1399
+ *
1400
+ * @param payload : the base64url payload returned by {@link encrypt}
1401
+ * @param password : the password / passphrase used to encrypt
1402
+ * @returns : the original plain text, or undefined on any failure
1485
1403
  */
1486
- transform(value, regexValue, replaceValue) {
1487
- if (!value)
1404
+ static async decrypt(payload, password) {
1405
+ if (!payload || !password)
1488
1406
  return undefined;
1489
- const replacement = (regexValue === '\n' && !replaceValue) ? '<br>' : replaceValue;
1490
- return this.sanitizer.bypassSecurityTrustHtml(value.replace(new RegExp(regexValue, 'g'), replacement));
1491
- }
1492
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1493
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, isStandalone: true, name: "replace" }); }
1494
- }
1495
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, decorators: [{
1496
- type: Pipe,
1497
- args: [{
1498
- name: 'replace',
1499
- standalone: true
1500
- }]
1501
- }] });
1502
-
1503
- /**
1504
- * Pipe that marks an HTML string as trusted so Angular does not escape it when
1505
- * bound via `[innerHTML]`.
1506
- *
1507
- * Usage: `<div [innerHTML]="html | safeHtml"></div>`
1508
- */
1509
- class SafeHtmlPipe {
1510
- constructor() {
1511
- this.sanitizer = inject(DomSanitizer);
1407
+ if (typeof crypto === 'undefined' || !crypto.subtle)
1408
+ return undefined;
1409
+ try {
1410
+ const bytes = this.fromBase64Url(payload);
1411
+ const headerLength = 1 + this.CIPHER_SALT_LENGTH + this.CIPHER_IV_LENGTH;
1412
+ if (bytes.length <= headerLength || bytes[0] !== this.CIPHER_VERSION)
1413
+ return undefined;
1414
+ const salt = bytes.subarray(1, 1 + this.CIPHER_SALT_LENGTH);
1415
+ const iv = bytes.subarray(1 + this.CIPHER_SALT_LENGTH, headerLength);
1416
+ const cipherText = bytes.subarray(headerLength);
1417
+ const key = await this.deriveKey(password, salt);
1418
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, cipherText);
1419
+ return new TextDecoder().decode(plain);
1420
+ }
1421
+ catch {
1422
+ // Wrong password or tampered payload: GCM authentication failed
1423
+ return undefined;
1424
+ }
1512
1425
  }
1426
+ // #endregion
1513
1427
  /**
1514
- * Bypasses Angular's HTML sanitization and returns a `SafeHtml` instance.
1515
- * @param value - The raw HTML string to trust. Treated as an empty string when `undefined`.
1516
- * @returns A `SafeHtml` value that can be bound to `[innerHTML]` without escaping.
1428
+ * Clone an object (deep copy).
1429
+ * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
1430
+ * @param obj : the object to clone
1431
+ * @returns : a new object
1517
1432
  */
1518
- transform(value) {
1519
- return this.sanitizer.bypassSecurityTrustHtml(value ?? '');
1433
+ static clone(obj) {
1434
+ if (obj === null || obj === undefined)
1435
+ return obj;
1436
+ return structuredClone(obj);
1520
1437
  }
1521
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1522
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, isStandalone: true, name: "safeHtml" }); }
1523
- }
1524
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, decorators: [{
1525
- type: Pipe,
1526
- args: [{
1527
- name: 'safeHtml',
1528
- standalone: true
1529
- }]
1530
- }] });
1531
-
1532
- /**
1533
- * Pipe that marks a URL string as a trusted resource URL so Angular does not block it
1534
- * when bound to attributes such as `[src]` or `[href]` on iframes, objects, or embeds.
1535
- *
1536
- * Usage: `<iframe [src]="url | safeUrl"></iframe>`
1537
- */
1538
- class SafeUrlPipe {
1539
- constructor() {
1540
- this.sanitizer = inject(DomSanitizer);
1438
+ /**
1439
+ * Creates a deep clone of an object.
1440
+ * @param obj - The source object to clone.
1441
+ * @param dest - Optional pre-allocated destination object to merge the clone into.
1442
+ * @returns A deep copy of `obj`.
1443
+ */
1444
+ static deepClone(obj, dest) {
1445
+ const cloned = structuredClone(obj);
1446
+ if (dest) {
1447
+ Object.assign(dest, cloned);
1448
+ return dest;
1449
+ }
1450
+ return cloned;
1541
1451
  }
1542
1452
  /**
1543
- * Bypasses Angular's resource-URL sanitization and returns a `SafeResourceUrl` instance.
1544
- * @param value - The URL string to trust. Treated as an empty string when `undefined`.
1545
- * @returns A `SafeResourceUrl` that can be bound to resource URL attributes without blocking.
1453
+ * Returns `true` when `value` is a syntactically valid UUID string.
1454
+ * @param value - The string to validate.
1546
1455
  */
1547
- transform(value) {
1548
- return this.sanitizer.bypassSecurityTrustResourceUrl(value ?? '');
1456
+ static parseUUID(value) {
1457
+ if (!value)
1458
+ return false;
1459
+ let s = value.trim();
1460
+ // Strip a matching wrapper pair, if present
1461
+ if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('(') && s.endsWith(')'))) {
1462
+ s = s.slice(1, -1);
1463
+ }
1464
+ return /^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(s);
1549
1465
  }
1550
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1551
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, isStandalone: true, name: "safeUrl" }); }
1552
- }
1553
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, decorators: [{
1554
- type: Pipe,
1555
- args: [{
1556
- name: 'safeUrl',
1557
- standalone: true
1558
- }]
1559
- }] });
1560
-
1561
- /**
1562
- * Impure pipe that filters an array using a caller-provided predicate function.
1563
- * Because the pipe is impure it re-evaluates on every change-detection cycle,
1564
- * which is necessary when the predicate's captured state changes.
1565
- *
1566
- * Usage: `*ngFor="let item of items | callback:myFilter"`
1567
- */
1568
- class SearchCallbackPipe {
1569
1466
  /**
1570
- * Filters `items` by applying `callback` to each element.
1571
- * Returns the original array unchanged when either argument is falsy.
1572
- * @param items - The source array to filter. May be `undefined`.
1573
- * @param callback - A predicate function that returns `true` for items to keep.
1574
- * @returns A new filtered array, the original array when no callback is provided,
1575
- * or `undefined` when `items` is `undefined`.
1467
+ * Returns `true` when `value` is a valid, non-empty (non-zero) UUID.
1468
+ * @param value - The string to validate.
1576
1469
  */
1577
- transform(items, callback) {
1578
- if (!items || !callback)
1579
- return items;
1580
- return items.filter(item => callback(item));
1470
+ static parseUUIDNotEmpty(value) {
1471
+ return this.parseUUID(value) && !/^[{(]?0{8}-(?:0{4}-){3}0{12}[)}]?$/.test((value ?? '').trim());
1581
1472
  }
1582
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1583
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, isStandalone: true, name: "callback", pure: false }); }
1584
- }
1585
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, decorators: [{
1586
- type: Pipe,
1587
- args: [{
1588
- name: 'callback',
1589
- pure: false,
1590
- standalone: true
1591
- }]
1592
- }] });
1593
-
1594
- /**
1595
- * Impure pipe that filters an array of searchable items against a text query.
1596
- *
1597
- * Each item is matched either via its `searchBag.name` property (when present)
1598
- * or by converting the item itself to a lowercase string. The optional `metadata`
1599
- * argument is updated in-place with the total item count and the filtered count,
1600
- * making it usable in the template alongside `*ngFor`.
1601
- *
1602
- * Usage:
1603
- * ```html
1604
- * <div *ngFor="let item of items | search:filterText:meta">...</div>
1605
- * <div>Showing {{ meta.count }} of {{ meta.total }}</div>
1606
- * ```
1607
- */
1608
- class SearchFilterPipe {
1609
1473
  /**
1610
- * Filters `items` by performing a case-insensitive substring match against `value`.
1611
- * When `items` or `value` is falsy the original array is returned unfiltered.
1612
- * @param items - The source array to filter. May be `undefined`.
1613
- * @param value - The search text to match against each item. May be `undefined`.
1614
- * @param metadata - Optional object that is updated with `total` and `count` after filtering.
1615
- * @returns The filtered array, the original array when no filter text is given,
1616
- * or `undefined` when `items` is `undefined`.
1474
+ * Return an empty UUID
1475
+ * @returns : the empty UUID
1476
+ */
1477
+ static emptyUUID() { return "00000000-0000-0000-0000-000000000000"; }
1478
+ ;
1479
+ /**
1480
+ * Create a new UUID
1481
+ * @returns : the string UUID
1617
1482
  */
1618
- transform(items, value, metadata) {
1619
- metadata ??= { total: 0, count: 0 };
1620
- if (!items || !value)
1621
- return items;
1622
- const query = value.toLowerCase();
1623
- const result = items.filter(item => {
1624
- if (!item)
1625
- return false;
1626
- const text = item.searchBag?.name ?? (typeof item === 'string' ? item : undefined);
1627
- return text?.toLowerCase().includes(query) ?? false;
1483
+ static generateUUID() {
1484
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1485
+ return crypto.randomUUID();
1486
+ }
1487
+ // Fallback (non-secure contexts)
1488
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1489
+ const r = (Math.random() * 16) | 0;
1490
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
1491
+ return v.toString(16);
1628
1492
  });
1629
- metadata.total = items.length;
1630
- metadata.count = result.length;
1631
- return result;
1632
1493
  }
1633
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1634
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, isStandalone: true, name: "search" }); }
1635
- }
1636
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, decorators: [{
1637
- type: Pipe,
1638
- args: [{
1639
- name: 'search',
1640
- standalone: true
1641
- }]
1642
- }] });
1643
-
1644
- /**
1645
- * Pipe that converts plain-text newlines (`\r\n`, `\r`, `\n`) to HTML `<br>` tags
1646
- * and marks the result as trusted HTML so Angular does not escape it.
1647
- *
1648
- * Usage: `{{ text | formatHtml }}`
1649
- */
1650
- class FormatHtmlPipe {
1651
- constructor() {
1652
- this.sanitizer = inject(DomSanitizer);
1494
+ /**
1495
+ * Reconstruct a standard UUID (with dashes) from a 32-char hex string without dashes.
1496
+ * @param value : 32-character hex string
1497
+ * @returns : the formatted UUID or the original string if it doesn't match the expected format
1498
+ */
1499
+ static inflateUUID(value) {
1500
+ const s = value.trim();
1501
+ if (s.length !== 32)
1502
+ return s;
1503
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
1653
1504
  }
1505
+ /** Precompiled validation patterns. */
1506
+ 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; }
1507
+ static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,12})(:\d{1,5})?([/\w .%-]*)?(\?[^\s#]*)?(#\S*)?$/i; }
1654
1508
  /**
1655
- * Transforms a plain-text string into sanitized HTML by replacing newline characters
1656
- * with `<br>` tags.
1657
- * @param value - The input string to transform. Treated as an empty string when `undefined`.
1658
- * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
1509
+ * Parse a text and return true if it is a valid email (or empty)
1510
+ * @param value : email
1511
+ * @returns : true if the email is valid or empty
1659
1512
  */
1660
- transform(value) {
1661
- return this.sanitizer.bypassSecurityTrustHtml((value ?? '').replaceAll(/(?:\r\n|\r|\n)/g, '<br>'));
1513
+ static parseEmail(value) {
1514
+ if (!value)
1515
+ return true;
1516
+ return this.emailRegex.test(value.trim().toLowerCase());
1662
1517
  }
1663
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1664
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, isStandalone: true, name: "formatHtml" }); }
1665
- }
1666
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, decorators: [{
1667
- type: Pipe,
1668
- args: [{
1669
- name: 'formatHtml',
1670
- standalone: true
1671
- }]
1672
- }] });
1673
-
1674
- /**
1675
- * Standalone providers for the ars-utils "core" layer.
1676
- *
1677
- * Registers the core pipes as injectable services so they can be used
1678
- * via `inject()` in services, guards, and resolvers — not only in templates.
1679
- * Components that use these pipes only in templates should import them
1680
- * directly via `imports: [FormatPipe, SafeHtmlPipe, ...]` instead.
1681
- *
1682
- * @example
1683
- * bootstrapApplication(AppComponent, {
1684
- * providers: [provideArsCore()]
1685
- * });
1686
- */
1687
- function provideArsCore() {
1688
- return makeEnvironmentProviders([
1689
- SearchFilterPipe,
1690
- SearchCallbackPipe,
1691
- SafeHtmlPipe,
1692
- SafeUrlPipe,
1693
- ReplacePipe,
1694
- FormatPipe,
1695
- FormatHtmlPipe
1696
- ]);
1697
- }
1698
-
1699
- /**
1700
- * Creates an array of the given length, filling each slot with the result of `valueFunction`.
1701
- * @param length - Number of elements to create.
1702
- * @param valueFunction - Factory called with each index to produce the element value.
1703
- * @returns Typed array of `length` elements.
1704
- */
1705
- function range(length, valueFunction) {
1706
- const valuesArray = Array(length);
1707
- for (let i = 0; i < length; i++) {
1708
- valuesArray[i] = valueFunction(i);
1518
+ /**
1519
+ * Parse a text containing one or more email addresses separated by `;` or `,`
1520
+ * and return true if all of them are valid (or the text is empty).
1521
+ * Empty items caused by double or trailing separators are ignored.
1522
+ * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
1523
+ * @returns : true if all emails are valid or the value is empty
1524
+ */
1525
+ static parseEmails(value) {
1526
+ if (!value || value.trim().length === 0)
1527
+ return true;
1528
+ const items = value
1529
+ .split(/[;,]/)
1530
+ .map(e => e.trim().toLowerCase())
1531
+ .filter(e => e.length > 0);
1532
+ if (items.length === 0)
1533
+ return false; // only separators, no addresses
1534
+ return items.every(e => this.emailRegex.test(e));
1709
1535
  }
1710
- return valuesArray;
1711
- }
1712
- // date-fns doesn't have a way to read/print month names or days of the week directly,
1713
- // so we get them by formatting a date with a format that produces the desired month/day.
1714
- const MONTH_FORMATS = {
1715
- long: 'LLLL',
1716
- short: 'LLL',
1717
- narrow: 'LLLLL',
1718
- };
1719
- const DAY_OF_WEEK_FORMATS = {
1720
- long: 'EEEE',
1721
- short: 'EEE',
1722
- narrow: 'EEEEE',
1723
- };
1724
- const MAT_DATE_FNS_FORMATS = {
1725
- parse: {
1726
- dateInput: 'P',
1727
- },
1728
- display: {
1729
- dateInput: 'P',
1730
- monthYearLabel: 'LLL uuuu',
1731
- dateA11yLabel: 'PP',
1732
- monthYearA11yLabel: 'LLLL uuuu',
1733
- },
1734
- };
1735
- /**
1736
- * date-fns adapter that integrates Angular Material's date picker with the date-fns library,
1737
- * applying `Europe/Rome` timezone for all parsed and created dates.
1738
- */
1739
- class DateFnsAdapter extends DateAdapter {
1740
- constructor() {
1741
- super();
1742
- const matDateLocale = inject(MAT_DATE_LOCALE, { optional: true });
1743
- if (matDateLocale) {
1744
- this.setLocale(matDateLocale);
1536
+ /**
1537
+ * Parse a text and return true if it is a valid url (or empty)
1538
+ * @param value : the url to parse
1539
+ * @returns : true if the url is valid or empty
1540
+ */
1541
+ static parseUrl(value) {
1542
+ if (!value)
1543
+ return true;
1544
+ return this.urlRegex.test(value.trim().toLowerCase());
1545
+ }
1546
+ /**
1547
+ * Get date parts from a string value
1548
+ * @param value : the string to parse
1549
+ * @returns : an array of numbers with year, month, day
1550
+ */
1551
+ static getDateParts(value) {
1552
+ if (!value)
1553
+ return undefined;
1554
+ let parts = [];
1555
+ if (value.indexOf("-") !== -1) {
1556
+ const p = value.split("-");
1557
+ if (p.length !== 3)
1558
+ return undefined;
1559
+ parts = [parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])];
1560
+ }
1561
+ else if (value.indexOf("/") !== -1) {
1562
+ const p = value.split("/");
1563
+ if (p.length !== 3)
1564
+ return undefined;
1565
+ parts = [parseInt(p[2]), parseInt(p[1]) - 1, parseInt(p[0])];
1566
+ }
1567
+ else {
1568
+ return undefined;
1745
1569
  }
1570
+ return parts;
1746
1571
  }
1747
1572
  /**
1748
- * Returns the year component of the given date.
1749
- * @param date - The source date.
1573
+ * Checks whether a value is a valid Date instance.
1574
+ * Note: do not use `date.getTime()` as a truthiness test, it is 0 (falsy)
1575
+ * for the Unix epoch (1970-01-01T00:00:00Z) even though the date is valid.
1576
+ * @param d : the value to check
1577
+ * @returns : true when `d` is a Date representing a valid point in time
1750
1578
  */
1751
- getYear(date) {
1752
- return getYear(date);
1579
+ static isValidDate(d) {
1580
+ return d instanceof Date && !isNaN(d.getTime());
1753
1581
  }
1754
1582
  /**
1755
- * Returns the zero-based month index of the given date (0 = January).
1756
- * @param date - The source date.
1583
+ * Parse a date
1584
+ * @param value : the value to check
1585
+ * @param locale : the locale to use
1586
+ * @returns : a valid Date object or undefined
1757
1587
  */
1758
- getMonth(date) {
1759
- return getMonth(date);
1588
+ static parseDate(value, locale = it) {
1589
+ // No value at all
1590
+ if (!value)
1591
+ return undefined;
1592
+ // A Date value
1593
+ if (value instanceof Date)
1594
+ return this.isValidDate(value) ? value : undefined;
1595
+ // Parse known formats using date-fns
1596
+ let d = parseISO(value);
1597
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1598
+ return d;
1599
+ d = parse(value, 'dd/MM/yyyy', new Date(), { locale: locale });
1600
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1601
+ return d;
1602
+ d = parse(value, 'yyyy-MM-dd', new Date(), { locale: locale });
1603
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1604
+ return d;
1605
+ // Parse values manually
1606
+ const parts = this.getDateParts(value);
1607
+ if (!parts)
1608
+ return undefined;
1609
+ if (parts[0] < 100)
1610
+ parts[0] += 2000;
1611
+ if (isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1612
+ return undefined;
1613
+ if (isNaN(parts[1]) || parts[1] < 0)
1614
+ return undefined;
1615
+ if (isNaN(parts[2]) || parts[2] < 0)
1616
+ return undefined;
1617
+ if (parts[1] > 11)
1618
+ return undefined;
1619
+ if (parts[1] === 1 && parts[2] > 29)
1620
+ return undefined;
1621
+ else if (parts[1] !== 1 && parts[2] > 31)
1622
+ return undefined;
1623
+ return new TZDate(parts[0], parts[1], parts[2], 12);
1760
1624
  }
1761
1625
  /**
1762
- * Returns the day-of-month of the given date (1-based).
1763
- * @param date - The source date.
1626
+ * Format a date
1627
+ * @param value : the date or string to format
1628
+ * @param fmt : the DateFormat to use or the string pattern
1629
+ * @param locale : the locale to use (default is IT)
1630
+ * @returns : the formatted string
1764
1631
  */
1765
- getDate(date) {
1766
- return getDate(date);
1632
+ static formatDate(value, fmt = DateFormat.Short, locale = it) {
1633
+ // No value at all
1634
+ if (!value)
1635
+ return '';
1636
+ // A string
1637
+ if (typeof value === 'string' || value instanceof String)
1638
+ value = this.parseDate(value);
1639
+ // Not a date
1640
+ if (!this.isValidDate(value))
1641
+ return '';
1642
+ // Format
1643
+ switch (fmt) {
1644
+ case DateFormat.Short: return format(value, "dd/MM/yyyy", { locale: locale });
1645
+ case DateFormat.Long: return format(value, "d MMMM yyyy", { locale: locale });
1646
+ case DateFormat.LongWithShortMonth: return format(value, "d MMM yyyy", { locale: locale });
1647
+ case DateFormat.LongWithWeekDay: return format(value, "EEEE, d MMMM yyyy", { locale: locale });
1648
+ case DateFormat.LongWithShortWeekDay: return format(value, "EEE, d MMMM yyyy", { locale: locale });
1649
+ case DateFormat.MonthAndYear: return format(value, "MMM yyyy", { locale: locale });
1650
+ case DateFormat.LongMonthAndYear: return format(value, "MMMM yyyy", { locale: locale });
1651
+ case DateFormat.WeekDay: return format(value, "EEE, d", { locale: locale });
1652
+ case DateFormat.LongWeekDay: return format(value, "EEEE, d", { locale: locale });
1653
+ case DateFormat.DayAndMonth: return format(value, "d MMMM", { locale: locale });
1654
+ case DateFormat.ShortUS: return format(value, "MM/dd/yyyy", { locale: locale });
1655
+ case DateFormat.ShortISO8601: return format(value, "yyyy-MM-dd", { locale: locale });
1656
+ default:
1657
+ return format(value, fmt, { locale: locale });
1658
+ }
1767
1659
  }
1768
1660
  /**
1769
- * Returns the day-of-week of the given date (0 = Sunday).
1770
- * @param date - The source date.
1661
+ * Return an italian local date
1662
+ * @param value : the date
1663
+ * @returns : the new date
1771
1664
  */
1772
- getDayOfWeek(date) {
1773
- return getDay(date);
1665
+ static toLocalDate(value) {
1666
+ // No value at all
1667
+ if (!value)
1668
+ return undefined;
1669
+ // A string
1670
+ if (typeof value === 'string' || value instanceof String)
1671
+ value = this.parseDate(value);
1672
+ // Not a date
1673
+ if (!this.isValidDate(value))
1674
+ return undefined;
1675
+ // Update date
1676
+ return new TZDate(value, "Europe/Rome");
1774
1677
  }
1775
1678
  /**
1776
- * Returns an array of 12 month name strings formatted for the active locale.
1777
- * @param style - One of `'long'`, `'short'`, or `'narrow'`.
1679
+ * Update a DateInterval object according to a string
1680
+ * @param value : string value
1681
+ * @param interval : DateInterval value to update
1682
+ * @param end : true if must be updated the first or the end value
1683
+ * @param copy : copy the same value (works only if not end element)
1778
1684
  */
1779
- getMonthNames(style) {
1780
- const pattern = MONTH_FORMATS[style];
1781
- return range(12, i => this.format(new Date(2017, i, 1), pattern));
1685
+ static changeDateInterval(value, interval, end = false, copy = false) {
1686
+ if (value) {
1687
+ let year = -1;
1688
+ if (value.length === 4 && (year = parseInt(value)) > 1750) {
1689
+ if (!end) {
1690
+ interval.from = new TZDate(new Date(year, 0, 1), 'Europe/Rome');
1691
+ interval.to = new TZDate(new Date(year, 11, 31), 'Europe/Rome');
1692
+ }
1693
+ else
1694
+ interval.to = new Date(year, 11, 31);
1695
+ }
1696
+ else {
1697
+ const parts = this.getDateParts(value);
1698
+ if (!parts || parts.length !== 3)
1699
+ return;
1700
+ if (isNaN(parts[0]) || parts[0] < 1750)
1701
+ return;
1702
+ if (isNaN(parts[1]) || parts[1] < 0 || parts[1] > 11)
1703
+ return;
1704
+ if (isNaN(parts[2]) || parts[2] < 1 || parts[2] > 31)
1705
+ return;
1706
+ const d = new TZDate(new Date(parts[0], parts[1], parts[2]), 'Europe/Rome');
1707
+ if (end)
1708
+ interval.to = d;
1709
+ else if (copy) {
1710
+ interval.from = d;
1711
+ interval.to = d;
1712
+ }
1713
+ else
1714
+ interval.from = d;
1715
+ }
1716
+ }
1782
1717
  }
1718
+ /** Cache of `Intl.NumberFormat` instances (their construction is expensive). */
1719
+ static { this.numberFormatCache = new Map(); }
1783
1720
  /**
1784
- * Returns an array of 31 day-of-month label strings formatted using `Intl.DateTimeFormat`
1785
- * when available, falling back to plain numeric strings.
1721
+ * Returns a cached `Intl.NumberFormat` for the given options, creating it on first use.
1722
+ * @param locale - BCP 47 locale tag.
1723
+ * @param options - The `Intl.NumberFormat` options.
1724
+ * @returns A shared formatter instance.
1786
1725
  */
1787
- getDateNames() {
1788
- const dtf = typeof Intl !== 'undefined'
1789
- ? new Intl.DateTimeFormat(this.locale?.code, {
1790
- day: 'numeric',
1791
- timeZone: 'utc',
1792
- })
1793
- : null;
1794
- return range(31, i => {
1795
- if (dtf) {
1796
- // date-fns doesn't appear to support this functionality.
1797
- // Fall back to `Intl` on supported browsers.
1798
- const date = new Date();
1799
- date.setUTCFullYear(2017, 0, i + 1);
1800
- date.setUTCHours(0, 0, 0, 0);
1801
- return dtf.format(date).replace(/[\u200e\u200f]/g, '');
1802
- }
1803
- return String(i + 1);
1804
- });
1726
+ static getNumberFormat(locale, options) {
1727
+ const key = `${locale}|${options.style}|${options.currency ?? ''}|${options.minimumFractionDigits}|${options.maximumFractionDigits}`;
1728
+ let fmt = this.numberFormatCache.get(key);
1729
+ if (!fmt) {
1730
+ fmt = new Intl.NumberFormat(locale, options);
1731
+ this.numberFormatCache.set(key, fmt);
1732
+ }
1733
+ return fmt;
1805
1734
  }
1806
1735
  /**
1807
- * Returns an array of 7 day-of-week name strings formatted for the active locale.
1808
- * @param style - One of `'long'`, `'short'`, or `'narrow'`.
1736
+ * Formats a number using a cached `Intl.NumberFormat`.
1737
+ * @param value - The number to format.
1738
+ * @param decimals - Maximum decimal places (default: `2`).
1739
+ * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1740
+ * @returns The formatted number string.
1809
1741
  */
1810
- getDayOfWeekNames(style) {
1811
- const pattern = DAY_OF_WEEK_FORMATS[style];
1812
- return range(7, i => this.format(new Date(2017, 0, i + 1), pattern));
1742
+ static formatNumber(value, decimals = 2, locale = 'it-IT') {
1743
+ return this.getNumberFormat(locale, { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1813
1744
  }
1814
1745
  /**
1815
- * Returns the four-digit year string for the given date.
1816
- * @param date - The source date.
1746
+ * Formats a number as a currency string using a cached `Intl.NumberFormat`.
1747
+ * @param value - The number to format.
1748
+ * @param currency - ISO 4217 currency code (default: `'EUR'`).
1749
+ * @param decimals - Maximum decimal places (default: `2`).
1750
+ * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1751
+ * @returns The formatted currency string.
1817
1752
  */
1818
- getYearName(date) {
1819
- return this.format(date, 'y');
1753
+ static formatCurrency(value, currency = 'EUR', decimals = 2, locale = 'it-IT') {
1754
+ return this.getNumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1820
1755
  }
1821
1756
  /**
1822
- * Returns the first day of the week for the active locale (0 = Sunday, 1 = Monday, …).
1757
+ * Percent-encodes a string for safe inclusion in a URL.
1758
+ * @param value - The string to encode.
1759
+ * @returns The encoded string, or `undefined` when `value` is empty.
1823
1760
  */
1824
- getFirstDayOfWeek() {
1825
- return this.locale?.options?.weekStartsOn ?? 0;
1761
+ static urlEncode(value) {
1762
+ return value.length > 0
1763
+ ? encodeURIComponent(value)
1764
+ : undefined;
1826
1765
  }
1827
1766
  /**
1828
- * Returns the number of days in the month of the given date.
1829
- * @param date - The source date.
1767
+ * Decodes a percent-encoded URL string, treating `+` as a space.
1768
+ * @param value - The encoded string to decode.
1769
+ * @returns The decoded string, or `undefined` when `value` is empty or absent.
1830
1770
  */
1831
- getNumDaysInMonth(date) {
1832
- return getDaysInMonth(date);
1771
+ static urlDecode(value) {
1772
+ return value && value.length > 0
1773
+ ? decodeURIComponent(value.replace(/\+/g, '%20'))
1774
+ : undefined;
1833
1775
  }
1834
1776
  /**
1835
- * Creates an independent copy of the given date.
1836
- * @param date - The date to clone.
1777
+ * Reads a query string parameter value from the current page URL.
1778
+ * @param name - The parameter name to look up.
1779
+ * @returns The decoded parameter value, or `undefined` when absent or running server-side.
1837
1780
  */
1838
- clone(date) {
1839
- return new Date(date.getTime());
1781
+ static getQueryStringValueByName(name) {
1782
+ if (!this.isBrowser())
1783
+ return undefined;
1784
+ const v = new URLSearchParams(window.location.search).get(name);
1785
+ return v && v !== 'null' ? v : undefined;
1840
1786
  }
1841
1787
  /**
1842
- * Creates a `Date` in the `Europe/Rome` timezone for the given year, month, and day.
1843
- * Throws an `Error` when any component is out of range.
1844
- * @param year - Full four-digit year.
1845
- * @param month - Zero-based month index (0 = January, 11 = December).
1846
- * @param date - Day-of-month (1-based).
1788
+ * Generate a password
1789
+ * @returns : the password string
1847
1790
  */
1848
- createDate(year, month, date) {
1849
- if (month < 0 || month > 11) {
1850
- throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
1851
- }
1852
- if (date < 1) {
1853
- throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
1791
+ static generatePassword(length = 12) {
1792
+ const lower = 'abcdefghijkmnopqrstuvwxyz'; // no 'l' (ambiguous)
1793
+ const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no 'I'/'O' (ambiguous)
1794
+ const digits = '23456789'; // no '0'/'1' (ambiguous)
1795
+ const symbols = '$!#%&*+-';
1796
+ const all = lower + upper + digits + symbols;
1797
+ // Secure random indices (crypto is available in browsers and Node)
1798
+ const rnd = (max) => {
1799
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
1800
+ const buf = new Uint32Array(1);
1801
+ crypto.getRandomValues(buf);
1802
+ return buf[0] % max;
1803
+ }
1804
+ return Math.floor(Math.random() * max);
1805
+ };
1806
+ // Guarantee at least one char per class, fill the rest, then shuffle
1807
+ const chars = [
1808
+ lower[rnd(lower.length)],
1809
+ upper[rnd(upper.length)],
1810
+ digits[rnd(digits.length)],
1811
+ symbols[rnd(symbols.length)],
1812
+ ];
1813
+ while (chars.length < Math.max(length, 8)) {
1814
+ chars.push(all[rnd(all.length)]);
1854
1815
  }
1855
- // Passing the year to the constructor causes year numbers <100 to be converted to 19xx.
1856
- // To work around this we use `setFullYear` and `setHours` instead.
1857
- const result = new Date();
1858
- result.setFullYear(year, month, date);
1859
- result.setHours(0, 0, 0, 0);
1860
- const result2 = new TZDate(result, 'Europe/Rome');
1861
- if (result2.getMonth() !== month) {
1862
- throw Error(`Invalid date "${date}" for month with index "${month}".`);
1816
+ for (let i = chars.length - 1; i > 0; i--) {
1817
+ const j = rnd(i + 1);
1818
+ [chars[i], chars[j]] = [chars[j], chars[i]];
1863
1819
  }
1864
- return result2;
1820
+ return chars.join('');
1865
1821
  }
1866
1822
  /**
1867
- * Returns today's date in the local timezone.
1868
- */
1869
- today() {
1870
- return new Date();
1871
- }
1872
- /**
1873
- * Parses a value into a `Date`.
1874
- * - Strings are first attempted as ISO 8601, then matched against each format in `parseFormat`.
1875
- * - Numbers are treated as Unix timestamps (milliseconds).
1876
- * - Existing `Date` instances are cloned.
1877
- * @param value - The value to parse.
1878
- * @param parseFormat - A format string or an array of format strings (date-fns tokens).
1879
- * @returns A valid `Date` in `Europe/Rome`, an invalid sentinel, or `null` for unrecognised input.
1823
+ * Calculate password strength
1824
+ * @param password: the password to evaluate
1825
+ * @returns the password strength info
1880
1826
  */
1881
- parse(value, parseFormat) {
1882
- if (typeof value === 'string' && value.length > 0) {
1883
- const iso8601Date = parseISO(value);
1884
- if (this.isValid(iso8601Date)) {
1885
- return new TZDate(iso8601Date, 'Europe/Rome');
1827
+ static calculatePasswordStrength(password) {
1828
+ if (password && password.length > 0) {
1829
+ let score = 0;
1830
+ const suggestions = [];
1831
+ // Length
1832
+ if (password.length >= 10)
1833
+ score++;
1834
+ else
1835
+ suggestions.push('Usa almeno 10 caratteri.');
1836
+ if (password.length >= 12)
1837
+ score++;
1838
+ else if (password.length >= 10)
1839
+ suggestions.push('Considera di usare più di 12 caratteri.');
1840
+ // Lowercase letters
1841
+ if (/[a-z]/.test(password))
1842
+ score++;
1843
+ else
1844
+ suggestions.push('Aggiungi lettere minuscole.');
1845
+ // Uppercase letters
1846
+ if (/[A-Z]/.test(password))
1847
+ score++;
1848
+ else
1849
+ suggestions.push('Aggiungi lettere maiuscole.');
1850
+ // Numbers
1851
+ if (/\d/.test(password))
1852
+ score++;
1853
+ else
1854
+ suggestions.push('Aggiungi numeri.');
1855
+ // Special characters
1856
+ if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password))
1857
+ score++;
1858
+ else
1859
+ suggestions.push('Aggiungi caratteri speciali (!@#$%^&*).');
1860
+ // Common patterns
1861
+ if (/(.)\1{2,}/.test(password)) {
1862
+ score = Math.max(0, score - 1);
1863
+ suggestions.push('Evita di ripetere lo stesso carattere.');
1886
1864
  }
1887
- const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
1888
- if (!formats.length) {
1889
- throw Error('Formats array must not be empty.');
1865
+ if (/123|abc|qwe/i.test(password)) {
1866
+ score = Math.max(0, score - 1);
1867
+ suggestions.push('Evita sequenze comuni (123, abc, qwe).');
1868
+ }
1869
+ // Label and color
1870
+ let label;
1871
+ let color;
1872
+ let isValid;
1873
+ if (score <= 2) {
1874
+ label = 'Molto debole';
1875
+ color = '#f44336';
1876
+ isValid = false;
1877
+ }
1878
+ else if (score <= 3) {
1879
+ label = 'Debole';
1880
+ color = '#ff9800';
1881
+ isValid = false;
1882
+ }
1883
+ else if (score <= 4) {
1884
+ label = 'Media';
1885
+ color = '#ffc107';
1886
+ isValid = true;
1887
+ }
1888
+ else if (score <= 5) {
1889
+ label = 'Forte';
1890
+ color = '#8bc34a';
1891
+ isValid = true;
1890
1892
  }
1891
- for (const currentFormat of formats) {
1892
- const fromFormat = parse(value, currentFormat, new Date(), { locale: this.locale });
1893
- if (this.isValid(fromFormat)) {
1894
- return new TZDate(fromFormat, 'Europe/Rome');
1895
- }
1893
+ else {
1894
+ label = 'Molto forte';
1895
+ color = '#4caf50';
1896
+ isValid = true;
1896
1897
  }
1897
- return this.invalid();
1898
- }
1899
- else if (typeof value === 'number') {
1900
- return new Date(value);
1901
- }
1902
- else if (value instanceof Date) {
1903
- return this.clone(value);
1904
- }
1905
- return null;
1906
- }
1907
- /**
1908
- * Formats a `Date` using the given date-fns display format string.
1909
- * Throws an `Error` when `date` is not valid.
1910
- * @param date - The date to format.
1911
- * @param displayFormat - A date-fns format string (e.g. `'P'`, `'LLL uuuu'`).
1912
- */
1913
- format(date, displayFormat) {
1914
- if (!this.isValid(date)) {
1915
- throw Error('DateFnsAdapter: Cannot format invalid date.');
1898
+ return { score, label, color, suggestions, isValid };
1916
1899
  }
1917
- return format(date, displayFormat, { locale: this.locale });
1900
+ else
1901
+ return {
1902
+ score: 0,
1903
+ isValid: false,
1904
+ suggestions: []
1905
+ };
1918
1906
  }
1919
1907
  /**
1920
- * Adds the given number of whole years to a date.
1921
- * @param date - The base date.
1922
- * @param years - Number of years to add (can be negative).
1908
+ * Check if current browser supports touch
1909
+ * @returns : true if the display is touchable
1923
1910
  */
1924
- addCalendarYears(date, years) {
1925
- return addYears(date, years);
1911
+ static isTouchable() {
1912
+ return (this.isBrowser() && ("ontouchstart" in window ||
1913
+ navigator.maxTouchPoints > 0));
1926
1914
  }
1927
1915
  /**
1928
- * Adds the given number of whole months to a date.
1929
- * @param date - The base date.
1930
- * @param months - Number of months to add (can be negative).
1916
+ * This check will prevent 'window' logic to be executed
1917
+ * while executing the server rendering
1918
+ * @returns : true if using the browser
1931
1919
  */
1932
- addCalendarMonths(date, months) {
1933
- return addMonths(date, months);
1920
+ static isBrowser() {
1921
+ return typeof (window) !== 'undefined';
1934
1922
  }
1935
1923
  /**
1936
- * Adds the given number of whole days to a date.
1937
- * @param date - The base date.
1938
- * @param days - Number of days to add (can be negative).
1924
+ * Convert folders in a tree of Node object.
1925
+ * @param folders : the subfolders group or null to root
1926
+ * @returns : a node list
1939
1927
  */
1940
- addCalendarDays(date, days) {
1941
- return addDays(date, days);
1928
+ static toNodes(folders) {
1929
+ return this._toNodes(folders, undefined);
1942
1930
  }
1943
1931
  /**
1944
- * Serialises a date to an ISO 8601 date string (`yyyy-MM-dd`).
1945
- * @param date - The date to serialise.
1932
+ * Convert folders in a tree of Node object.
1933
+ * @param folders : the children group or null to root
1934
+ * @param parent : the parent node
1935
+ * @returns : a node list
1946
1936
  */
1947
- toIso8601(date) {
1948
- return formatISO(date, { representation: 'date' });
1937
+ static _toNodes(folders, parent) {
1938
+ const nodes = [];
1939
+ folders.forEach((n) => {
1940
+ const node = {
1941
+ id: n.id,
1942
+ name: n.name,
1943
+ count: n.count,
1944
+ parent: parent,
1945
+ children: undefined,
1946
+ bag: n,
1947
+ };
1948
+ const children = n.children ?? n.subFolders ?? [];
1949
+ if (children.length > 0) {
1950
+ node.children = this._toNodes(children, node);
1951
+ }
1952
+ nodes.push(node);
1953
+ });
1954
+ return nodes;
1949
1955
  }
1950
1956
  /**
1951
- * Returns the given value when it is a valid `Date`, or `null` for an empty string.
1952
- * Deserialises valid ISO 8601 strings into `Date` instances.
1953
- * Delegates all other values to the base-class implementation.
1954
- * @param value - The raw value to deserialise.
1957
+ * Returns an array of individual power-of-2 flag values that are set in `value`.
1958
+ * @param value - The bitmask to decompose.
1959
+ * @param max - Upper-bound exponent: checks flags from `1` up to `1 << max` (default: `30`).
1960
+ * @returns Array of set flag values, or an empty array when `value` is `0`.
1955
1961
  */
1956
- deserialize(value) {
1957
- if (typeof value === 'string') {
1958
- if (!value) {
1959
- return null;
1960
- }
1961
- const date = parseISO(value);
1962
- if (this.isValid(date)) {
1963
- return date;
1962
+ static getFlags(value, max = 30) {
1963
+ if (value !== 0) {
1964
+ const items = [];
1965
+ const top = Math.min(max, 30); // beyond 30 the shift overflows into the sign bit
1966
+ for (let exp = 0; exp <= top; exp++) {
1967
+ const v = 1 << exp;
1968
+ if ((value & v) === v)
1969
+ items.push(v);
1964
1970
  }
1971
+ return items;
1965
1972
  }
1966
- return super.deserialize(value);
1967
- }
1968
- /**
1969
- * Returns `true` when `obj` is an instance of `Date`.
1970
- * @param obj - The object to test.
1971
- */
1972
- isDateInstance(obj) {
1973
- return isDate(obj);
1974
- }
1975
- /**
1976
- * Returns `true` when `date` represents a valid point in time.
1977
- * @param date - The date to validate.
1978
- */
1979
- isValid(date) {
1980
- return isValid(date);
1973
+ else
1974
+ return [];
1981
1975
  }
1976
+ /** Cache for resolved color luminance results. */
1977
+ static { this.colorLightCache = new Map(); }
1982
1978
  /**
1983
- * Returns a sentinel `Date` that represents an invalid date (`new Date(NaN)`).
1979
+ * Check if a color is light or dark
1980
+ * @param color : the color
1981
+ * @param minimumLuminance : the lumimance to consider
1982
+ * @returns true if the color is light
1984
1983
  */
1985
- invalid() {
1986
- return new Date(NaN);
1987
- }
1988
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1989
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter }); }
1990
- }
1991
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, decorators: [{
1992
- type: Injectable
1993
- }], ctorParameters: () => [] });
1994
- /**
1995
- * Standalone providers for the ars-utils date-fns adapter.
1996
- *
1997
- * Configures Angular Material to use {@link DateFnsAdapter} (Europe/Rome timezone)
1998
- * and the matching {@link MAT_DATE_FNS_FORMATS}.
1999
- *
2000
- * @example
2001
- * bootstrapApplication(AppComponent, {
2002
- * providers: [provideArsDateFns()]
2003
- * });
2004
- */
2005
- function provideArsDateFns() {
2006
- return makeEnvironmentProviders([
2007
- {
2008
- provide: DateAdapter,
2009
- useClass: DateFnsAdapter,
2010
- deps: [MAT_DATE_LOCALE],
2011
- },
2012
- { provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }
2013
- ]);
2014
- }
2015
-
2016
- /**
2017
- * Directive that moves browser focus to the host element after the first render cycle.
2018
- * Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
2019
- */
2020
- class AutoFocusDirective {
2021
- constructor() {
2022
- this.elementRef = inject(ElementRef);
2023
- afterNextRender(() => {
2024
- this.elementRef.nativeElement?.focus();
2025
- });
2026
- }
2027
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2028
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
2029
- }
2030
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, decorators: [{
2031
- type: Directive,
2032
- args: [{
2033
- selector: '[autoFocus]',
2034
- standalone: true
2035
- }]
2036
- }], ctorParameters: () => [] });
2037
-
2038
- class FileInfo {
2039
- isValid() {
2040
- return this.valid;
2041
- }
2042
- }
2043
- class ValueModel {
2044
- }
2045
- class IDModel {
2046
- }
2047
- class GroupModel {
2048
- }
2049
- class DeleteModel extends GroupModel {
2050
- }
2051
- class RelationModel {
2052
- }
2053
- class UpdateRelationsModel {
2054
- }
2055
- class QueryModel {
2056
- }
2057
- class ImportModel {
2058
- }
2059
- class DateInterval {
2060
- get fromAsDate() {
2061
- if (this.from) {
2062
- if (!(this.from instanceof Date)) {
2063
- this.from = new Date(this.from);
2064
- }
2065
- if (this.from) {
2066
- return new Date(this.from.getFullYear(), this.from.getMonth(), this.from.getDate(), 2, 0, 0);
2067
- }
2068
- }
2069
- return undefined;
2070
- }
2071
- get toAsDate() {
2072
- if (this.to) {
2073
- if (!(this.to instanceof Date)) {
2074
- this.to = new Date(this.to);
2075
- }
2076
- if (this.to) {
2077
- return new Date(this.to.getFullYear(), this.to.getMonth(), this.to.getDate(), 2, 0, 0);
2078
- }
2079
- }
2080
- return undefined;
2081
- }
2082
- constructor(from, to) {
2083
- this.from = from;
2084
- this.to = to;
2085
- }
2086
- clear() {
2087
- this.from = undefined;
2088
- this.to = undefined;
1984
+ static isColorLight(color, minimumLuminance = 186) {
1985
+ if (!this.isBrowser())
1986
+ return true; // SSR fallback
1987
+ const cacheKey = `${color}|${minimumLuminance}`;
1988
+ const cached = this.colorLightCache.get(cacheKey);
1989
+ if (cached !== undefined)
1990
+ return cached;
1991
+ const tempDiv = document.createElement('div');
1992
+ tempDiv.style.color = color;
1993
+ document.body.appendChild(tempDiv);
1994
+ const rgb = getComputedStyle(tempDiv).color;
1995
+ document.body.removeChild(tempDiv);
1996
+ const result = rgb.match(/\d+/g);
1997
+ let light = true; // fallback
1998
+ if (result && result.length >= 3) {
1999
+ const r = parseInt(result[0], 10);
2000
+ const g = parseInt(result[1], 10);
2001
+ const b = parseInt(result[2], 10);
2002
+ light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
2003
+ }
2004
+ this.colorLightCache.set(cacheKey, light);
2005
+ return light;
2089
2006
  }
2090
2007
  }
2091
2008
 
@@ -2097,7 +2014,7 @@ class DateInterval {
2097
2014
  class DateIntervalChangeDirective {
2098
2015
  constructor() {
2099
2016
  /** The date interval model to update when the input value changes. */
2100
- this.dateIntervalChange = input(new DateInterval(null, null), /* @ts-ignore */
2017
+ this.dateIntervalChange = input(new DateInterval(), /* @ts-ignore */
2101
2018
  ...(ngDevMode ? [{ debugName: "dateIntervalChange" }] : /* istanbul ignore next */ []));
2102
2019
  /** When `true`, the directive updates the interval's end date; otherwise the start date. */
2103
2020
  this.end = input(false, /* @ts-ignore */
@@ -2114,19 +2031,27 @@ class DateIntervalChangeDirective {
2114
2031
  });
2115
2032
  }
2116
2033
  /**
2117
- * Handles `keyup` events on the host element.
2118
- * Prevents default browser behaviour for the space key and forwards the event to the debounce pipeline.
2034
+ * Handles `keydown` events on the host element.
2035
+ * The space key must be blocked HERE: by the time `keyup` fires the character
2036
+ * has already been inserted into the input, so `preventDefault` on `keyup`
2037
+ * cannot stop it.
2119
2038
  * @param e - The keyboard event emitted by the host input.
2120
2039
  */
2121
- onKeyup(e) {
2040
+ onKeydown(e) {
2122
2041
  if (e.key === ' ') {
2123
2042
  e.preventDefault();
2124
2043
  e.stopPropagation();
2125
2044
  }
2045
+ }
2046
+ /**
2047
+ * Handles `keyup` events on the host element and forwards them to the debounce pipeline.
2048
+ * @param e - The keyboard event emitted by the host input.
2049
+ */
2050
+ onKeyup(e) {
2126
2051
  this.subject.next(e);
2127
2052
  }
2128
2053
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateIntervalChangeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2129
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: DateIntervalChangeDirective, isStandalone: true, selector: "[dateIntervalChange]", inputs: { dateIntervalChange: { classPropertyName: "dateIntervalChange", publicName: "dateIntervalChange", isSignal: true, isRequired: false, transformFunction: null }, end: { classPropertyName: "end", publicName: "end", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keyup": "onKeyup($event)" } }, ngImport: i0 }); }
2054
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: DateIntervalChangeDirective, isStandalone: true, selector: "[dateIntervalChange]", inputs: { dateIntervalChange: { classPropertyName: "dateIntervalChange", publicName: "dateIntervalChange", isSignal: true, isRequired: false, transformFunction: null }, end: { classPropertyName: "end", publicName: "end", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onKeydown($event)", "keyup": "onKeyup($event)" } }, ngImport: i0 }); }
2130
2055
  }
2131
2056
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateIntervalChangeDirective, decorators: [{
2132
2057
  type: Directive,
@@ -2134,7 +2059,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2134
2059
  selector: '[dateIntervalChange]',
2135
2060
  standalone: true,
2136
2061
  }]
2137
- }], ctorParameters: () => [], propDecorators: { dateIntervalChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateIntervalChange", required: false }] }], end: [{ type: i0.Input, args: [{ isSignal: true, alias: "end", required: false }] }], onKeyup: [{
2062
+ }], ctorParameters: () => [], propDecorators: { dateIntervalChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateIntervalChange", required: false }] }], end: [{ type: i0.Input, args: [{ isSignal: true, alias: "end", required: false }] }], onKeydown: [{
2063
+ type: HostListener,
2064
+ args: ['keydown', ['$event']]
2065
+ }], onKeyup: [{
2138
2066
  type: HostListener,
2139
2067
  args: ['keyup', ['$event']]
2140
2068
  }] } });
@@ -2163,6 +2091,7 @@ class CopyClipboardDirective {
2163
2091
  if (SystemUtils.isBrowser()) {
2164
2092
  const listener = (clipEvent) => {
2165
2093
  clipEvent.clipboardData?.setData('text/html', payload);
2094
+ clipEvent.clipboardData?.setData('text/plain', payload);
2166
2095
  clipEvent.preventDefault();
2167
2096
  this.copied.emit(payload);
2168
2097
  };
@@ -2230,17 +2159,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2230
2159
  /**
2231
2160
  * Directive that validates that the host control's value equals the value of another control.
2232
2161
  * Bind `[equals]="otherControl"`.
2162
+ *
2163
+ * The host control is re-validated whenever the OTHER control's value changes:
2164
+ * without this, typing a new password AFTER the confirmation field was filled
2165
+ * left the form incorrectly valid (the classic password/confirm bug).
2233
2166
  */
2234
2167
  class EqualsValidatorDirective {
2235
2168
  constructor() {
2236
2169
  /** The control whose value must match the host control's value. */
2237
2170
  this.equals = input(undefined, /* @ts-ignore */
2238
2171
  ...(ngDevMode ? [{ debugName: "equals" }] : /* istanbul ignore next */ []));
2172
+ // Re-subscribe whenever the [equals] binding points to a different control,
2173
+ // and trigger host re-validation on every change of the other control.
2174
+ effect(() => {
2175
+ const other = this.equals();
2176
+ this.subscription?.unsubscribe();
2177
+ this.subscription = other?.valueChanges.subscribe(() => this.onValidatorChange?.());
2178
+ });
2179
+ inject(DestroyRef).onDestroy(() => this.subscription?.unsubscribe());
2180
+ }
2181
+ /**
2182
+ * Registers the callback Angular invokes to re-run this validator.
2183
+ * @param fn - The revalidation callback provided by the forms API.
2184
+ */
2185
+ registerOnValidatorChange(fn) {
2186
+ this.onValidatorChange = fn;
2239
2187
  }
2240
2188
  /**
2241
2189
  * Validates that the host control value equals the bound control's value.
2242
2190
  * Returns `null` (valid) when no control is bound.
2243
2191
  * @param control - The form control to validate.
2192
+ * @returns `null` when valid, `{ equals: ... }` otherwise.
2244
2193
  */
2245
2194
  validate(control) {
2246
2195
  const eq = this.equals();
@@ -2270,7 +2219,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2270
2219
  ],
2271
2220
  standalone: true,
2272
2221
  }]
2273
- }], propDecorators: { equals: [{ type: i0.Input, args: [{ isSignal: true, alias: "equals", required: false }] }] } });
2222
+ }], ctorParameters: () => [], propDecorators: { equals: [{ type: i0.Input, args: [{ isSignal: true, alias: "equals", required: false }] }] } });
2274
2223
 
2275
2224
  /**
2276
2225
  * Directive that validates a file size against configurable minimum and maximum bounds.
@@ -2454,18 +2403,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2454
2403
  /**
2455
2404
  * Directive that validates that the host control's value is different from another control's value.
2456
2405
  * Bind `[notEqual]="otherControl"`.
2406
+ *
2407
+ * The host control is re-validated whenever the OTHER control's value changes,
2408
+ * so editing either field keeps both error states consistent.
2457
2409
  */
2458
2410
  class NotEqualValidatorDirective {
2459
2411
  constructor() {
2460
2412
  /** The control whose value must differ from the host control's value. */
2461
2413
  this.notEqual = input(undefined, /* @ts-ignore */
2462
2414
  ...(ngDevMode ? [{ debugName: "notEqual" }] : /* istanbul ignore next */ []));
2415
+ effect(() => {
2416
+ const other = this.notEqual();
2417
+ this.subscription?.unsubscribe();
2418
+ this.subscription = other?.valueChanges.subscribe(() => this.onValidatorChange?.());
2419
+ });
2420
+ inject(DestroyRef).onDestroy(() => this.subscription?.unsubscribe());
2421
+ }
2422
+ /**
2423
+ * Registers the callback Angular invokes to re-run this validator.
2424
+ * @param fn - The revalidation callback provided by the forms API.
2425
+ */
2426
+ registerOnValidatorChange(fn) {
2427
+ this.onValidatorChange = fn;
2463
2428
  }
2464
2429
  /**
2465
2430
  * Validates that the host control value is not equal to the bound control's value.
2466
2431
  * Also clears the `notequal` error on the other control when the host becomes valid.
2467
2432
  * Returns `null` (valid) when no control is bound.
2468
2433
  * @param control - The form control to validate.
2434
+ * @returns `null` when valid, `{ notequal: true }` otherwise.
2469
2435
  */
2470
2436
  validate(control) {
2471
2437
  const notEqual = this.notEqual();
@@ -2477,7 +2443,8 @@ class NotEqualValidatorDirective {
2477
2443
  control.markAsTouched();
2478
2444
  }
2479
2445
  else if (notEqual.hasError('notequal')) {
2480
- notEqual.setErrors({ notequal: null });
2446
+ const { notequal: _removed, ...rest } = notEqual.errors ?? {};
2447
+ notEqual.setErrors(Object.keys(rest).length > 0 ? rest : null);
2481
2448
  notEqual.updateValueAndValidity({ onlySelf: true, emitEvent: false });
2482
2449
  notEqual.markAsTouched();
2483
2450
  notEqual.markAsDirty();
@@ -2506,7 +2473,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2506
2473
  ],
2507
2474
  standalone: true,
2508
2475
  }]
2509
- }], propDecorators: { notEqual: [{ type: i0.Input, args: [{ isSignal: true, alias: "notEqual", required: false }] }] } });
2476
+ }], ctorParameters: () => [], propDecorators: { notEqual: [{ type: i0.Input, args: [{ isSignal: true, alias: "notEqual", required: false }] }] } });
2510
2477
 
2511
2478
  /**
2512
2479
  * Directive that validates that a control value is not a future date.
@@ -2522,8 +2489,11 @@ class NotFutureValidatorDirective {
2522
2489
  const input = control.value;
2523
2490
  if (!input || input.length === 0)
2524
2491
  return null;
2492
+ const parsed = SystemUtils.parseDate(input);
2493
+ if (!parsed)
2494
+ return { notFuture: "Non valido." };
2525
2495
  const today = endOfDay(new Date());
2526
- const d = endOfDay(SystemUtils.parseDate(input));
2496
+ const d = endOfDay(parsed);
2527
2497
  return d <= today ? null : { notFuture: "Non valido." };
2528
2498
  }
2529
2499
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: NotFutureValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -2635,7 +2605,10 @@ class SqlDateValidatorDirective {
2635
2605
  const input = control.value;
2636
2606
  if (!input || input.length === 0)
2637
2607
  return null;
2638
- const d = endOfDay(SystemUtils.parseDate(input));
2608
+ const parsed = SystemUtils.parseDate(input);
2609
+ if (!parsed)
2610
+ return { sqlDate: "Non valido." };
2611
+ const d = endOfDay(parsed);
2639
2612
  return d.getFullYear() > 1750 ? null : { sqlDate: "Non valido." };
2640
2613
  }
2641
2614
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SqlDateValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -2681,13 +2654,13 @@ class TimeValidatorDirective {
2681
2654
  const p = value.split(':');
2682
2655
  if (p.length !== 2)
2683
2656
  return -1;
2684
- const hh = parseInt(p[0]);
2685
- if (hh < 0 || hh > 23)
2657
+ const hh = parseInt(p[0], 10);
2658
+ if (isNaN(hh) || hh < 0 || hh > 23)
2686
2659
  return -1;
2687
- const mm = parseInt(p[1]);
2688
- if (mm < 0 || mm > 59)
2660
+ const mm = parseInt(p[1], 10);
2661
+ if (isNaN(mm) || mm < 0 || mm > 59)
2689
2662
  return -1;
2690
- return parseInt(p[0] + p[1]);
2663
+ return hh * 100 + mm;
2691
2664
  }
2692
2665
  /**
2693
2666
  * Validates that the control value is a valid time string and, when slots are configured,
@@ -2735,168 +2708,435 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2735
2708
  ],
2736
2709
  standalone: true,
2737
2710
  }]
2738
- }], propDecorators: { slots: [{ type: i0.Input, args: [{ isSignal: true, alias: "slots", required: false }] }] } });
2711
+ }], propDecorators: { slots: [{ type: i0.Input, args: [{ isSignal: true, alias: "slots", required: false }] }] } });
2712
+
2713
+ /**
2714
+ * Directive that validates a control value as a well-formed URL.
2715
+ * Apply `url` to a text input that expects a URL.
2716
+ */
2717
+ class UrlValidatorDirective {
2718
+ /**
2719
+ * Validates that the control value is a well-formed URL.
2720
+ * Returns `null` (valid) when the control is empty.
2721
+ * @param control - The form control to validate.
2722
+ */
2723
+ validate(control) {
2724
+ const input = control.value;
2725
+ if (!input || input.length === 0)
2726
+ return null;
2727
+ return SystemUtils.parseUrl(input) ? null : { url: "Non valido." };
2728
+ }
2729
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2730
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: UrlValidatorDirective, isStandalone: true, selector: "[url]", providers: [
2731
+ {
2732
+ provide: NG_VALIDATORS,
2733
+ useExisting: forwardRef(() => UrlValidatorDirective),
2734
+ multi: true,
2735
+ },
2736
+ ], ngImport: i0 }); }
2737
+ }
2738
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, decorators: [{
2739
+ type: Directive,
2740
+ args: [{
2741
+ selector: "[url]",
2742
+ providers: [
2743
+ {
2744
+ provide: NG_VALIDATORS,
2745
+ useExisting: forwardRef(() => UrlValidatorDirective),
2746
+ multi: true,
2747
+ },
2748
+ ],
2749
+ standalone: true,
2750
+ }]
2751
+ }] });
2752
+
2753
+ /**
2754
+ * Directive that validates a control using the host object's `isValid()` method
2755
+ * or a boolean expression passed via `[validIf]`.
2756
+ */
2757
+ class ValidIfDirective {
2758
+ constructor() {
2759
+ /** When `true`, the control is considered valid regardless of the bound value. */
2760
+ this.validIf = input(false, /* @ts-ignore */
2761
+ ...(ngDevMode ? [{ debugName: "validIf" }] : /* istanbul ignore next */ []));
2762
+ }
2763
+ /**
2764
+ * Validates the control value against a boolean flag or the value's own `isValid()` method.
2765
+ * @param control - The form control to validate.
2766
+ */
2767
+ validate(control) {
2768
+ let isValid = false;
2769
+ const c = control.value ? control.value : null;
2770
+ if (!c) {
2771
+ isValid = this.validIf() === true;
2772
+ }
2773
+ else {
2774
+ try {
2775
+ isValid = c.isValid();
2776
+ }
2777
+ catch { }
2778
+ }
2779
+ return isValid ? null : { validIf: "Non valido." };
2780
+ }
2781
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2782
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidIfDirective, isStandalone: true, selector: "[validIf]", inputs: { validIf: { classPropertyName: "validIf", publicName: "validIf", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2783
+ {
2784
+ provide: NG_VALIDATORS,
2785
+ useExisting: forwardRef(() => ValidIfDirective),
2786
+ multi: true,
2787
+ },
2788
+ ], ngImport: i0 }); }
2789
+ }
2790
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, decorators: [{
2791
+ type: Directive,
2792
+ args: [{
2793
+ selector: "[validIf]",
2794
+ providers: [
2795
+ {
2796
+ provide: NG_VALIDATORS,
2797
+ useExisting: forwardRef(() => ValidIfDirective),
2798
+ multi: true,
2799
+ },
2800
+ ],
2801
+ standalone: true,
2802
+ }]
2803
+ }], propDecorators: { validIf: [{ type: i0.Input, args: [{ isSignal: true, alias: "validIf", required: false }] }] } });
2804
+
2805
+ /**
2806
+ * Directive that delegates validation to an externally provided validator function.
2807
+ * Bind `[validator]="myFn"` where `myFn` is `(c: AbstractControl) => ValidationErrors | null`.
2808
+ */
2809
+ class ValidatorDirective {
2810
+ constructor() {
2811
+ /** The custom validator function to apply. */
2812
+ this.validator = input(undefined, /* @ts-ignore */
2813
+ ...(ngDevMode ? [{ debugName: "validator" }] : /* istanbul ignore next */ []));
2814
+ }
2815
+ /**
2816
+ * Invokes the provided validator function against the given control.
2817
+ * Returns `null` (valid) when no function is bound.
2818
+ * @param control - The form control to validate.
2819
+ */
2820
+ validate(control) {
2821
+ const fn = this.validator();
2822
+ return fn ? fn(control) : null;
2823
+ }
2824
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2825
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidatorDirective, isStandalone: true, selector: "[validator]", inputs: { validator: { classPropertyName: "validator", publicName: "validator", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }], ngImport: i0 }); }
2826
+ }
2827
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, decorators: [{
2828
+ type: Directive,
2829
+ args: [{
2830
+ selector: '[validator]',
2831
+ providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }],
2832
+ standalone: true,
2833
+ }]
2834
+ }], propDecorators: { validator: [{ type: i0.Input, args: [{ isSignal: true, alias: "validator", required: false }] }] } });
2835
+
2836
+ /**
2837
+ * Pipe that converts plain-text newlines (`\r\n`, `\r`, `\n`) to HTML `<br>` tags
2838
+ * and marks the result as trusted HTML so Angular does not escape it.
2839
+ *
2840
+ * Usage: `{{ text | formatHtml }}`
2841
+ */
2842
+ class FormatHtmlPipe {
2843
+ constructor() {
2844
+ this.sanitizer = inject(DomSanitizer);
2845
+ }
2846
+ /**
2847
+ * Transforms a plain-text string into sanitized HTML by replacing newline characters
2848
+ * with `<br>` tags.
2849
+ * @param value - The input string to transform. Treated as an empty string when `undefined`.
2850
+ * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
2851
+ */
2852
+ transform(value) {
2853
+ const escaped = (value ?? '')
2854
+ .replaceAll('&', '&amp;')
2855
+ .replaceAll('<', '&lt;')
2856
+ .replaceAll('>', '&gt;')
2857
+ .replaceAll('"', '&quot;');
2858
+ return this.sanitizer.bypassSecurityTrustHtml(escaped.replaceAll(/(?:\r\n|\r|\n)/g, '<br>'));
2859
+ }
2860
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2861
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, isStandalone: true, name: "formatHtml" }); }
2862
+ }
2863
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, decorators: [{
2864
+ type: Pipe,
2865
+ args: [{
2866
+ name: 'formatHtml',
2867
+ standalone: true
2868
+ }]
2869
+ }] });
2870
+
2871
+ /**
2872
+ * Pipe that converts a Markdown string to sanitized HTML using `SystemUtils.markdownToHtml`.
2873
+ *
2874
+ * Usage: `{{ text | formatMarkdown }}`
2875
+ */
2876
+ class FormatMarkdownPipe {
2877
+ constructor() {
2878
+ this.sanitizer = inject(DomSanitizer);
2879
+ }
2880
+ /**
2881
+ * Transforms a Markdown string into sanitized HTML.
2882
+ * @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
2883
+ * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
2884
+ */
2885
+ transform(value) {
2886
+ return this.sanitizer.bypassSecurityTrustHtml(SystemUtils.markdownToHtml(value ?? ''));
2887
+ }
2888
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2889
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
2890
+ }
2891
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
2892
+ type: Pipe,
2893
+ args: [{
2894
+ name: 'formatMarkdown',
2895
+ standalone: true
2896
+ }]
2897
+ }] });
2898
+
2899
+ /**
2900
+ * General-purpose formatting pipe that converts a raw value to a locale-aware string
2901
+ * based on the specified format type.
2902
+ *
2903
+ * Supported types: `'date'` / `'D'`, `'currency'` / `'C'`, `'number'` / `'N'`,
2904
+ * `'number0'` / `'N0'`, `'percentage'` / `'P'`.
2905
+ *
2906
+ * Usage: `{{ value | format:'currency' }}`
2907
+ */
2908
+ class FormatPipe {
2909
+ /** Shared formatters: building an `Intl.NumberFormat` per call is expensive. */
2910
+ static { this.currencyFormat = new Intl.NumberFormat('it-IT', { style: 'currency', currency: 'EUR' }); }
2911
+ static { this.numberFormat = new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 2 }); }
2912
+ static { this.number0Format = new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 }); }
2913
+ static { this.percentFormat = new Intl.NumberFormat('it-IT', { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0 }); }
2914
+ /**
2915
+ * Formats a value according to the specified type and optional pattern.
2916
+ * Returns `undefined` when the value is `null` or `undefined`, or when the type is unrecognised.
2917
+ * @param value - The raw value to format.
2918
+ * @param type - The format type identifier (default: `'date'`).
2919
+ * @param pattern - The date pattern used when `type` is `'date'` (default: `'dd/MM/yyyy'`).
2920
+ * @returns A formatted string, or `undefined` when the value cannot be formatted.
2921
+ */
2922
+ transform(value, type = 'date', pattern = 'dd/MM/yyyy') {
2923
+ if (value === undefined || value === null)
2924
+ return undefined;
2925
+ switch (type) {
2926
+ case 'D':
2927
+ case 'date': {
2928
+ const d = SystemUtils.parseDate(value, it);
2929
+ if (d)
2930
+ return format(d, pattern, { locale: it });
2931
+ break;
2932
+ }
2933
+ case 'C':
2934
+ case 'currency':
2935
+ return FormatPipe.currencyFormat.format(value);
2936
+ case 'N':
2937
+ case 'number':
2938
+ return FormatPipe.numberFormat.format(value);
2939
+ case 'N0':
2940
+ case 'number0':
2941
+ return FormatPipe.number0Format.format(value);
2942
+ case 'P':
2943
+ case 'percentage':
2944
+ return FormatPipe.percentFormat.format(value);
2945
+ }
2946
+ return undefined;
2947
+ }
2948
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2949
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, isStandalone: true, name: "format" }); }
2950
+ }
2951
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, decorators: [{
2952
+ type: Pipe,
2953
+ args: [{
2954
+ name: 'format',
2955
+ standalone: true
2956
+ }]
2957
+ }] });
2739
2958
 
2740
2959
  /**
2741
- * Directive that validates a control value as a well-formed URL.
2742
- * Apply `url` to a text input that expects a URL.
2960
+ * Pipe that applies a global regex replacement on a string and returns the result
2961
+ * as sanitized HTML. When `regexValue` is `'\n'` and no `replaceValue` is given,
2962
+ * newlines are replaced with `<br>` tags.
2963
+ *
2964
+ * Usage: `{{ text | replace:'\n':'' }}`
2743
2965
  */
2744
- class UrlValidatorDirective {
2966
+ class ReplacePipe {
2967
+ constructor() {
2968
+ this.sanitizer = inject(DomSanitizer);
2969
+ }
2745
2970
  /**
2746
- * Validates that the control value is a well-formed URL.
2747
- * Returns `null` (valid) when the control is empty.
2748
- * @param control - The form control to validate.
2971
+ * Replaces all occurrences of `regexValue` in `value` with `replaceValue`.
2972
+ * Returns `undefined` when `value` is empty or `undefined`.
2973
+ * @param value - The source string to process.
2974
+ * @param regexValue - The regex pattern string to match (applied with the global flag).
2975
+ * @param replaceValue - The replacement string. Defaults to `'<br>'` when `regexValue` is `'\n'` and this is falsy.
2976
+ * @returns A `SafeHtml` value with all matches replaced, or `undefined` when the input is empty.
2749
2977
  */
2750
- validate(control) {
2751
- const input = control.value;
2752
- if (!input || input.length === 0)
2753
- return null;
2754
- return SystemUtils.parseUrl(input) ? null : { url: "Non valido." };
2978
+ transform(value, regexValue, replaceValue) {
2979
+ if (!value)
2980
+ return undefined;
2981
+ const replacement = (regexValue === '\n' && !replaceValue) ? '<br>' : (replaceValue ?? '');
2982
+ return this.sanitizer.bypassSecurityTrustHtml(value.replace(new RegExp(regexValue, 'g'), replacement));
2755
2983
  }
2756
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2757
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: UrlValidatorDirective, isStandalone: true, selector: "[url]", providers: [
2758
- {
2759
- provide: NG_VALIDATORS,
2760
- useExisting: forwardRef(() => UrlValidatorDirective),
2761
- multi: true,
2762
- },
2763
- ], ngImport: i0 }); }
2984
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2985
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, isStandalone: true, name: "replace" }); }
2764
2986
  }
2765
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, decorators: [{
2766
- type: Directive,
2987
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, decorators: [{
2988
+ type: Pipe,
2767
2989
  args: [{
2768
- selector: "[url]",
2769
- providers: [
2770
- {
2771
- provide: NG_VALIDATORS,
2772
- useExisting: forwardRef(() => UrlValidatorDirective),
2773
- multi: true,
2774
- },
2775
- ],
2776
- standalone: true,
2990
+ name: 'replace',
2991
+ standalone: true
2777
2992
  }]
2778
2993
  }] });
2779
2994
 
2780
2995
  /**
2781
- * Directive that validates a control using the host object's `isValid()` method
2782
- * or a boolean expression passed via `[validIf]`.
2996
+ * Pipe that marks an HTML string as trusted so Angular does not escape it when
2997
+ * bound via `[innerHTML]`.
2998
+ *
2999
+ * Usage: `<div [innerHTML]="html | safeHtml"></div>`
2783
3000
  */
2784
- class ValidIfDirective {
3001
+ class SafeHtmlPipe {
2785
3002
  constructor() {
2786
- /** When `true`, the control is considered valid regardless of the bound value. */
2787
- this.validIf = input(false, /* @ts-ignore */
2788
- ...(ngDevMode ? [{ debugName: "validIf" }] : /* istanbul ignore next */ []));
3003
+ this.sanitizer = inject(DomSanitizer);
2789
3004
  }
2790
3005
  /**
2791
- * Validates the control value against a boolean flag or the value's own `isValid()` method.
2792
- * @param control - The form control to validate.
3006
+ * Bypasses Angular's HTML sanitization and returns a `SafeHtml` instance.
3007
+ * @param value - The raw HTML string to trust. Treated as an empty string when `undefined`.
3008
+ * @returns A `SafeHtml` value that can be bound to `[innerHTML]` without escaping.
2793
3009
  */
2794
- validate(control) {
2795
- let isValid = false;
2796
- const c = control.value ? control.value : null;
2797
- if (!c) {
2798
- isValid = this.validIf() === true;
2799
- }
2800
- else {
2801
- try {
2802
- isValid = c.isValid();
2803
- }
2804
- catch { }
2805
- }
2806
- return isValid ? null : { validIf: "Non valido." };
3010
+ transform(value) {
3011
+ return this.sanitizer.bypassSecurityTrustHtml(value ?? '');
2807
3012
  }
2808
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2809
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidIfDirective, isStandalone: true, selector: "[validIf]", inputs: { validIf: { classPropertyName: "validIf", publicName: "validIf", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2810
- {
2811
- provide: NG_VALIDATORS,
2812
- useExisting: forwardRef(() => ValidIfDirective),
2813
- multi: true,
2814
- },
2815
- ], ngImport: i0 }); }
3013
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3014
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, isStandalone: true, name: "safeHtml" }); }
2816
3015
  }
2817
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, decorators: [{
2818
- type: Directive,
3016
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, decorators: [{
3017
+ type: Pipe,
2819
3018
  args: [{
2820
- selector: "[validIf]",
2821
- providers: [
2822
- {
2823
- provide: NG_VALIDATORS,
2824
- useExisting: forwardRef(() => ValidIfDirective),
2825
- multi: true,
2826
- },
2827
- ],
2828
- standalone: true,
3019
+ name: 'safeHtml',
3020
+ standalone: true
2829
3021
  }]
2830
- }], propDecorators: { validIf: [{ type: i0.Input, args: [{ isSignal: true, alias: "validIf", required: false }] }] } });
3022
+ }] });
2831
3023
 
2832
3024
  /**
2833
- * Directive that delegates validation to an externally provided validator function.
2834
- * Bind `[validator]="myFn"` where `myFn` is `(c: AbstractControl) => ValidationErrors | null`.
3025
+ * Pipe that marks a URL string as a trusted resource URL so Angular does not block it
3026
+ * when bound to attributes such as `[src]` or `[href]` on iframes, objects, or embeds.
3027
+ *
3028
+ * Usage: `<iframe [src]="url | safeUrl"></iframe>`
2835
3029
  */
2836
- class ValidatorDirective {
3030
+ class SafeUrlPipe {
2837
3031
  constructor() {
2838
- /** The custom validator function to apply. */
2839
- this.validator = input(undefined, /* @ts-ignore */
2840
- ...(ngDevMode ? [{ debugName: "validator" }] : /* istanbul ignore next */ []));
3032
+ this.sanitizer = inject(DomSanitizer);
2841
3033
  }
2842
3034
  /**
2843
- * Invokes the provided validator function against the given control.
2844
- * Returns `null` (valid) when no function is bound.
2845
- * @param control - The form control to validate.
3035
+ * Bypasses Angular's resource-URL sanitization and returns a `SafeResourceUrl` instance.
3036
+ * @param value - The URL string to trust. Treated as an empty string when `undefined`.
3037
+ * @returns A `SafeResourceUrl` that can be bound to resource URL attributes without blocking.
2846
3038
  */
2847
- validate(control) {
2848
- const fn = this.validator();
2849
- return fn ? fn(control) : null;
3039
+ transform(value) {
3040
+ return this.sanitizer.bypassSecurityTrustResourceUrl(value ?? '');
2850
3041
  }
2851
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2852
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidatorDirective, isStandalone: true, selector: "[validator]", inputs: { validator: { classPropertyName: "validator", publicName: "validator", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }], ngImport: i0 }); }
3042
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3043
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, isStandalone: true, name: "safeUrl" }); }
2853
3044
  }
2854
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, decorators: [{
2855
- type: Directive,
3045
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, decorators: [{
3046
+ type: Pipe,
2856
3047
  args: [{
2857
- selector: '[validator]',
2858
- providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }],
2859
- standalone: true,
3048
+ name: 'safeUrl',
3049
+ standalone: true
2860
3050
  }]
2861
- }], propDecorators: { validator: [{ type: i0.Input, args: [{ isSignal: true, alias: "validator", required: false }] }] } });
3051
+ }] });
2862
3052
 
2863
3053
  /**
2864
- * Pipe that converts a Markdown string to sanitized HTML using `SystemUtils.markdownToHtml`.
3054
+ * Impure pipe that filters an array using a caller-provided predicate function.
3055
+ * Because the pipe is impure it re-evaluates on every change-detection cycle,
3056
+ * which is necessary when the predicate's captured state changes.
2865
3057
  *
2866
- * Usage: `{{ text | formatMarkdown }}`
3058
+ * Usage: `*ngFor="let item of items | callback:myFilter"`
2867
3059
  */
2868
- class FormatMarkdownPipe {
2869
- constructor() {
2870
- this.sanitizer = inject(DomSanitizer);
2871
- }
3060
+ class SearchCallbackPipe {
2872
3061
  /**
2873
- * Transforms a Markdown string into sanitized HTML.
2874
- * @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
2875
- * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
3062
+ * Filters `items` by applying `callback` to each element.
3063
+ * Returns the original array unchanged when either argument is falsy.
3064
+ * @param items - The source array to filter. May be `undefined`.
3065
+ * @param callback - A predicate function that returns `true` for items to keep.
3066
+ * @returns A new filtered array, the original array when no callback is provided,
3067
+ * or `undefined` when `items` is `undefined`.
2876
3068
  */
2877
- transform(value) {
2878
- return this.sanitizer.bypassSecurityTrustHtml(SystemUtils.markdownToHtml(value ?? ''));
3069
+ transform(items, callback) {
3070
+ if (!items || !callback)
3071
+ return items;
3072
+ return items.filter(item => callback(item));
2879
3073
  }
2880
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2881
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
3074
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3075
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, isStandalone: true, name: "callback", pure: false }); }
2882
3076
  }
2883
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
3077
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, decorators: [{
2884
3078
  type: Pipe,
2885
3079
  args: [{
2886
- name: 'formatMarkdown',
3080
+ name: 'callback',
3081
+ pure: false,
2887
3082
  standalone: true
2888
3083
  }]
2889
3084
  }] });
2890
3085
 
2891
- const Breakpoints = {
2892
- XXSmall: '(max-width: 349.98px)',
2893
- XSmall: '(max-width: 599.98px)',
2894
- Small: '(min-width: 600px) and (max-width: 959.98px)',
2895
- SmallMedium: '(min-width: 600px) and (max-width: 1059.98px)',
2896
- Medium: '(min-width: 960px) and (max-width: 1279.98px)',
2897
- MediumLarge: '(min-width: 960px) and (max-width: 1459.98px)',
2898
- Large: '(min-width: 1460px) and (max-width: 1919.98px)'
2899
- };
3086
+ /**
3087
+ * Pure pipe that filters an array of searchable items against a text query.
3088
+ * Note: being pure, it re-runs only when the array REFERENCE or the query
3089
+ * changes; mutate-in-place updates of the array are not detected.
3090
+ *
3091
+ * Each item is matched either via its `searchBag.name` property (when present)
3092
+ * or by converting the item itself to a lowercase string. The optional `metadata`
3093
+ * argument is updated in-place with the total item count and the filtered count,
3094
+ * making it usable in the template alongside `*ngFor`.
3095
+ *
3096
+ * Usage:
3097
+ * ```html
3098
+ * <div *ngFor="let item of items | search:filterText:meta">...</div>
3099
+ * <div>Showing {{ meta.count }} of {{ meta.total }}</div>
3100
+ * ```
3101
+ */
3102
+ class SearchFilterPipe {
3103
+ /**
3104
+ * Filters `items` by performing a case-insensitive substring match against `value`.
3105
+ * When `items` or `value` is falsy the original array is returned unfiltered.
3106
+ * @param items - The source array to filter. May be `undefined`.
3107
+ * @param value - The search text to match against each item. May be `undefined`.
3108
+ * @param metadata - Optional object that is updated with `total` and `count` after filtering.
3109
+ * @returns The filtered array, the original array when no filter text is given,
3110
+ * or `undefined` when `items` is `undefined`.
3111
+ */
3112
+ transform(items, value, metadata) {
3113
+ metadata ??= { total: 0, count: 0 };
3114
+ if (!items || !value) {
3115
+ metadata.total = items?.length ?? 0;
3116
+ metadata.count = metadata.total;
3117
+ return items;
3118
+ }
3119
+ const query = value.toLowerCase();
3120
+ const result = items.filter(item => {
3121
+ if (!item)
3122
+ return false;
3123
+ const text = item.searchBag?.name ?? (typeof item === 'string' ? item : undefined);
3124
+ return text?.toLowerCase().includes(query) ?? false;
3125
+ });
3126
+ metadata.total = items.length;
3127
+ metadata.count = result.length;
3128
+ return result;
3129
+ }
3130
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3131
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, isStandalone: true, name: "search" }); }
3132
+ }
3133
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, decorators: [{
3134
+ type: Pipe,
3135
+ args: [{
3136
+ name: 'search',
3137
+ standalone: true
3138
+ }]
3139
+ }] });
2900
3140
 
2901
3141
  const UtilsMessages = {
2902
3142
  /**
@@ -2927,20 +3167,32 @@ class SelectableModel {
2927
3167
  }
2928
3168
  /**
2929
3169
  * @param allowMultiSelect - When `true` (default), multiple items can be selected simultaneously.
2930
- * @param lookupFieldName - Name of the note used as the unique key when searching the internal list (default: `'id'`).
3170
+ * @param lookupFieldName - Name of the field used as the unique key when searching the internal list
3171
+ * (default: `'id'`). Keys of `T` are autocompleted; plain strings remain accepted.
2931
3172
  */
2932
3173
  constructor(allowMultiSelect = true, lookupFieldName = 'id') {
2933
3174
  /**
2934
3175
  * Emits whenever the selection changes.
2935
3176
  * Carries the affected item, or `undefined` when the whole selection is cleared.
3177
+ *
3178
+ * Note: kept as `EventEmitter` (instead of a plain RxJS `Subject`) for
3179
+ * backward compatibility with existing consumers; `EventEmitter` extends
3180
+ * `Subject`, so `.subscribe()`, `pipe(...)` and `takeUntilDestroyed` all
3181
+ * work as expected.
2936
3182
  */
2937
3183
  this.changed = new EventEmitter();
2938
3184
  this._all = signal([], /* @ts-ignore */
2939
3185
  ...(ngDevMode ? [{ debugName: "_all" }] : /* istanbul ignore next */ []));
2940
- /** Signal that is `true` when at least one item is selected. */
3186
+ /** Signal that is `true` when at least one item is tracked in the internal list. */
2941
3187
  this.hasValue = computed(() => this._all().length > 0, /* @ts-ignore */
2942
3188
  ...(ngDevMode ? [{ debugName: "hasValue" }] : /* istanbul ignore next */ []));
2943
- this._current = new SelectionModel(allowMultiSelect, []);
3189
+ /** Signal with the number of items currently tracked in the internal list. */
3190
+ this.count = computed(() => this._all().length, /* @ts-ignore */
3191
+ ...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
3192
+ this._current = new SelectionModel(allowMultiSelect, [], true,
3193
+ // Compare by lookup key so different instances of the same logical item
3194
+ // (e.g. after a data reload) are treated as the same selection entry.
3195
+ (a, b) => a[lookupFieldName] === b[lookupFieldName]);
2944
3196
  this._lookupFieldName = lookupFieldName;
2945
3197
  }
2946
3198
  /**
@@ -3071,6 +3323,27 @@ class BroadcastChannelManager {
3071
3323
  get currentBus() {
3072
3324
  return this.channel?.name;
3073
3325
  }
3326
+ /**
3327
+ * Observable that emits EVERY message bag received on the channel,
3328
+ * regardless of `messageId`. Completed by {@link dispose}.
3329
+ * Prefer {@link observe} to listen for a single message type.
3330
+ */
3331
+ get messages() {
3332
+ return this.subject.asObservable();
3333
+ }
3334
+ /**
3335
+ * Returns a typed Observable that emits only the messages with the given `messageId`.
3336
+ *
3337
+ * Unlike {@link subscribe} (one callback per id, replaced on re-registration),
3338
+ * this supports any number of concurrent subscribers and integrates with the
3339
+ * RxJS lifecycle (`takeUntilDestroyed`, `async` pipe, ...). Completed by {@link dispose}.
3340
+ *
3341
+ * @param messageId - The message type identifier to listen for.
3342
+ * @returns An Observable of the matching typed message bags.
3343
+ */
3344
+ observe(messageId) {
3345
+ return this.subject.pipe(filter(bag => bag.messageId === messageId), map(bag => bag));
3346
+ }
3074
3347
  /**
3075
3348
  * Opens a `BroadcastChannel` with the given name (when the API is available).
3076
3349
  * @param bus - The channel name to open (default: `'ARS-CHANNEL'`).
@@ -3163,6 +3436,7 @@ class BroadcastChannelManager {
3163
3436
  }
3164
3437
  }
3165
3438
 
3439
+ const CHANNEL_NAME = 'D2693418-B18F-41BB-BAE9-41BB5407957A-CHANNEL';
3166
3440
  /**
3167
3441
  * Application-level messaging service that combines two transports:
3168
3442
  * - An in-process RxJS `Subject` for same-tab communication (`sendMessage` / `getMessage`).
@@ -3171,15 +3445,15 @@ class BroadcastChannelManager {
3171
3445
  class BroadcastService {
3172
3446
  constructor() {
3173
3447
  this.subject = new Subject();
3174
- this.channel = new BroadcastChannelManager('ARS-CHANNEL');
3448
+ this.channel = new BroadcastChannelManager(CHANNEL_NAME);
3175
3449
  }
3176
3450
  /**
3177
3451
  * Creates a new standalone `BroadcastChannelManager` instance bound to the shared ARS channel.
3178
3452
  * Useful when a caller needs direct channel access outside the service.
3179
- * @returns A new `BroadcastChannelManager` connected to `'ARS-CHANNEL'`.
3453
+ * @returns A new `BroadcastChannelManager` connected to `'D2693418-B18F-41BB-BAE9-41BB5407957A-CHANNEL'`.
3180
3454
  */
3181
3455
  static createChannel() {
3182
- return new BroadcastChannelManager('ARS-CHANNEL');
3456
+ return new BroadcastChannelManager(CHANNEL_NAME);
3183
3457
  }
3184
3458
  ngOnDestroy() {
3185
3459
  this.channel?.dispose();
@@ -3224,6 +3498,16 @@ class BroadcastService {
3224
3498
  unsubscribeChannelMessage(id) {
3225
3499
  this.channel.unsubscribe(id);
3226
3500
  }
3501
+ /**
3502
+ * Returns a typed Observable of the cross-tab channel messages with the given `id`.
3503
+ * Supports multiple concurrent subscribers and the RxJS lifecycle operators,
3504
+ * unlike `subscribeChannelMessage` (single callback per id).
3505
+ * @param id - The message type identifier to listen for.
3506
+ * @returns An Observable of the matching typed message bags.
3507
+ */
3508
+ observeChannelMessage(id) {
3509
+ return this.channel.observe(id);
3510
+ }
3227
3511
  /**
3228
3512
  * Returns an `Observable` that emits every in-process message published via `sendMessage`.
3229
3513
  * @returns An observable stream of `BroadcastMessageInfo` objects.
@@ -3231,6 +3515,14 @@ class BroadcastService {
3231
3515
  getMessage() {
3232
3516
  return this.subject.asObservable();
3233
3517
  }
3518
+ /**
3519
+ * Returns a typed `Observable` of the in-process messages with the given `id`.
3520
+ * @param id - The message type identifier to listen for.
3521
+ * @returns An Observable emitting the typed `data` payload of each matching message.
3522
+ */
3523
+ observeMessage(id) {
3524
+ return this.subject.pipe(filter$1(info => info.id === id), map$1(info => info.data));
3525
+ }
3234
3526
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: BroadcastService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3235
3527
  static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.1", ngImport: i0, type: BroadcastService }); }
3236
3528
  }
@@ -3324,7 +3616,7 @@ class ScreenService {
3324
3616
  * Modern Chromium-based Edge reports a different user-agent and will return `false`.
3325
3617
  */
3326
3618
  get isIEOrEdge() {
3327
- return /msie\s|trident\/|edge\//i.test(window.navigator.userAgent);
3619
+ return SystemUtils.isBrowser() && /msie\s|trident\/|edge\//i.test(window.navigator.userAgent);
3328
3620
  }
3329
3621
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ScreenService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3330
3622
  static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.1", ngImport: i0, type: ScreenService }); }
@@ -3379,7 +3671,9 @@ class ThemeService {
3379
3671
  constructor() {
3380
3672
  this.broadcastChannel = new BroadcastChannelManager('THEME-SERVICE');
3381
3673
  this.broadcastMessage = '$theme-changed';
3382
- this.prefersColorSchemeMediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
3674
+ this.prefersColorSchemeMediaQueryList = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
3675
+ ? window.matchMedia('(prefers-color-scheme: dark)')
3676
+ : undefined;
3383
3677
  this.themeChanged = new BehaviorSubject('auto');
3384
3678
  this.renderFactory2 = inject(RendererFactory2);
3385
3679
  this.renderer = this.renderFactory2.createRenderer(null, null);
@@ -3412,10 +3706,12 @@ class ThemeService {
3412
3706
  * @param theme - The initial theme to apply (defaults to the value stored in `localStorage`).
3413
3707
  */
3414
3708
  initialize(theme = this.getPreferredTheme()) {
3415
- this.prefersColorSchemeMediaQueryList.onchange = () => {
3416
- if (this.auto())
3417
- this.setTheme(this.theme());
3418
- };
3709
+ if (this.prefersColorSchemeMediaQueryList) {
3710
+ this.prefersColorSchemeMediaQueryList.onchange = () => {
3711
+ if (this.auto())
3712
+ this.setTheme(this.theme());
3713
+ };
3714
+ }
3419
3715
  this.broadcastChannel.subscribe({
3420
3716
  messageId: this.broadcastMessage,
3421
3717
  action: (bag) => {
@@ -3441,10 +3737,11 @@ class ThemeService {
3441
3737
  * @returns `'light'` or `'dark'` — never `'auto'`.
3442
3738
  */
3443
3739
  getTheme() {
3444
- if (this.auto()) {
3445
- return this.prefersColorSchemeMediaQueryList.matches ? 'dark' : 'light';
3740
+ const current = this.theme();
3741
+ if (current === 'auto') {
3742
+ return this.prefersColorSchemeMediaQueryList?.matches ? 'dark' : 'light';
3446
3743
  }
3447
- return this.getPreferredTheme();
3744
+ return current;
3448
3745
  }
3449
3746
  /**
3450
3747
  * Reads the theme stored in `localStorage` and validates it.
@@ -3452,6 +3749,8 @@ class ThemeService {
3452
3749
  * @returns The persisted `ThemeType`, or `'auto'` as default.
3453
3750
  */
3454
3751
  getPreferredTheme() {
3752
+ if (typeof localStorage === 'undefined')
3753
+ return 'auto';
3455
3754
  const stored = localStorage.getItem('preferred-theme');
3456
3755
  return stored && this.isTheme(stored) ? stored : 'auto';
3457
3756
  }
@@ -3472,9 +3771,11 @@ class ThemeService {
3472
3771
  this.renderer.removeClass(document.body, 'light');
3473
3772
  this.renderer.removeClass(document.body, 'dark');
3474
3773
  this.theme.set(theme);
3475
- localStorage.setItem('preferred-theme', theme);
3774
+ if (typeof localStorage !== 'undefined') {
3775
+ localStorage.setItem('preferred-theme', theme);
3776
+ }
3476
3777
  const effectiveClass = this.auto()
3477
- ? (this.prefersColorSchemeMediaQueryList.matches ? 'dark' : 'light')
3778
+ ? (this.prefersColorSchemeMediaQueryList?.matches ? 'dark' : 'light')
3478
3779
  : theme;
3479
3780
  this.renderer.addClass(document.body, effectiveClass);
3480
3781
  this.themeChanged.next(this.getTheme());
@@ -3488,12 +3789,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
3488
3789
  }], ctorParameters: () => [] });
3489
3790
 
3490
3791
  /*
3491
- * Public API Surface of ars-utils
3792
+ * Public API Surface of scm-utils
3492
3793
  */
3493
3794
 
3494
3795
  /**
3495
3796
  * Generated bundle index. Do not edit.
3496
3797
  */
3497
3798
 
3498
- export { AutoFocusDirective, Breakpoints, BroadcastChannelManager, BroadcastService, CopyClipboardDirective, DateFnsAdapter, DateFormat, DateInterval, DateIntervalChangeDirective, DeleteModel, EmailsValidatorDirective, EnvironmentService, EqualsValidatorDirective, FileInfo, FileSizeValidatorDirective, FormatHtmlPipe, FormatMarkdownPipe, FormatPipe, GroupModel, GuidValidatorDirective, IDModel, ImportModel, MAT_DATE_FNS_FORMATS, MaxTermsValidatorDirective, NotEmptyValidatorDirective, NotEqualValidatorDirective, NotFutureValidatorDirective, PasswordValidatorDirective, QueryModel, RelationModel, RemoveFocusDirective, ReplacePipe, SafeHtmlPipe, SafeUrlPipe, ScreenService, SearchCallbackPipe, SearchFilterPipe, SelectableModel, SplashService, SqlDateValidatorDirective, SystemUtils, ThemeService, TimeValidatorDirective, UpdateRelationsModel, UrlValidatorDirective, UtilsMessages, ValidIfDirective, ValidatorDirective, ValueModel, provideArsCore, provideArsDateFns };
3799
+ export { AutoFocusDirective, BroadcastChannelManager, BroadcastService, CHANNEL_NAME, CopyClipboardDirective, DateFnsAdapter, DateFormat, DateInterval, DateIntervalChangeDirective, DeleteModel, EmailsValidatorDirective, EnvironmentService, EqualsValidatorDirective, FileInfo, FileSizeValidatorDirective, FormatHtmlPipe, FormatMarkdownPipe, FormatPipe, GroupModel, GuidValidatorDirective, IDModel, ImportModel, MAT_DATE_FNS_FORMATS, MaxTermsValidatorDirective, NotEmptyValidatorDirective, NotEqualValidatorDirective, NotFutureValidatorDirective, PasswordValidatorDirective, QueryModel, RelationModel, RemoveFocusDirective, ReplacePipe, SafeHtmlPipe, SafeUrlPipe, ScreenService, SearchCallbackPipe, SearchFilterPipe, SelectableModel, SplashService, SqlDateValidatorDirective, SystemUtils, ThemeService, TimeValidatorDirective, UpdateRelationsModel, UrlValidatorDirective, UtilsMessages, ValidIfDirective, ValidatorDirective, ValueModel, provideArsDateFns };
3499
3800
  //# sourceMappingURL=arsedizioni-ars-utils-core.mjs.map