web-mojo 2.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +198 -0
- package/README.md +510 -0
- package/dist/admin.cjs.js +2 -0
- package/dist/admin.cjs.js.map +1 -0
- package/dist/admin.css +621 -0
- package/dist/admin.es.js +7973 -0
- package/dist/admin.es.js.map +1 -0
- package/dist/auth.cjs.js +2 -0
- package/dist/auth.cjs.js.map +1 -0
- package/dist/auth.css +804 -0
- package/dist/auth.es.js +2168 -0
- package/dist/auth.es.js.map +1 -0
- package/dist/charts.cjs.js +2 -0
- package/dist/charts.cjs.js.map +1 -0
- package/dist/charts.css +1002 -0
- package/dist/charts.es.js +16 -0
- package/dist/charts.es.js.map +1 -0
- package/dist/chunks/ContextMenu-BrHqj0fn.js +80 -0
- package/dist/chunks/ContextMenu-BrHqj0fn.js.map +1 -0
- package/dist/chunks/ContextMenu-gEcpSz56.js +2 -0
- package/dist/chunks/ContextMenu-gEcpSz56.js.map +1 -0
- package/dist/chunks/DataView-DPryYpEW.js +2 -0
- package/dist/chunks/DataView-DPryYpEW.js.map +1 -0
- package/dist/chunks/DataView-DjZQrpba.js +843 -0
- package/dist/chunks/DataView-DjZQrpba.js.map +1 -0
- package/dist/chunks/Dialog-BsRx4eg3.js +2 -0
- package/dist/chunks/Dialog-BsRx4eg3.js.map +1 -0
- package/dist/chunks/Dialog-DSlctbon.js +1377 -0
- package/dist/chunks/Dialog-DSlctbon.js.map +1 -0
- package/dist/chunks/FilePreviewView-BmFHzK5K.js +5868 -0
- package/dist/chunks/FilePreviewView-BmFHzK5K.js.map +1 -0
- package/dist/chunks/FilePreviewView-DcdRl_ta.js +2 -0
- package/dist/chunks/FilePreviewView-DcdRl_ta.js.map +1 -0
- package/dist/chunks/FormView-CmBuwKGD.js +2 -0
- package/dist/chunks/FormView-CmBuwKGD.js.map +1 -0
- package/dist/chunks/FormView-DqUBMPJ9.js +5054 -0
- package/dist/chunks/FormView-DqUBMPJ9.js.map +1 -0
- package/dist/chunks/MetricsChart-CM4CI6eA.js +2095 -0
- package/dist/chunks/MetricsChart-CM4CI6eA.js.map +1 -0
- package/dist/chunks/MetricsChart-CPidSMaN.js +2 -0
- package/dist/chunks/MetricsChart-CPidSMaN.js.map +1 -0
- package/dist/chunks/PDFViewer-BNQlnS83.js +2 -0
- package/dist/chunks/PDFViewer-BNQlnS83.js.map +1 -0
- package/dist/chunks/PDFViewer-Dyo-Oeyd.js +946 -0
- package/dist/chunks/PDFViewer-Dyo-Oeyd.js.map +1 -0
- package/dist/chunks/Page-B524zSQs.js +351 -0
- package/dist/chunks/Page-B524zSQs.js.map +1 -0
- package/dist/chunks/Page-BFgj0pAA.js +2 -0
- package/dist/chunks/Page-BFgj0pAA.js.map +1 -0
- package/dist/chunks/TokenManager-BXNva8Jk.js +287 -0
- package/dist/chunks/TokenManager-BXNva8Jk.js.map +1 -0
- package/dist/chunks/TokenManager-Bzn4guFm.js +2 -0
- package/dist/chunks/TokenManager-Bzn4guFm.js.map +1 -0
- package/dist/chunks/TopNav-D3I3_25f.js +371 -0
- package/dist/chunks/TopNav-D3I3_25f.js.map +1 -0
- package/dist/chunks/TopNav-MDjL4kV0.js +2 -0
- package/dist/chunks/TopNav-MDjL4kV0.js.map +1 -0
- package/dist/chunks/User-BalfYTEF.js +3 -0
- package/dist/chunks/User-BalfYTEF.js.map +1 -0
- package/dist/chunks/User-DwIT-CTQ.js +1937 -0
- package/dist/chunks/User-DwIT-CTQ.js.map +1 -0
- package/dist/chunks/WebApp-B6mgbNn2.js +4767 -0
- package/dist/chunks/WebApp-B6mgbNn2.js.map +1 -0
- package/dist/chunks/WebApp-DqDowtkl.js +2 -0
- package/dist/chunks/WebApp-DqDowtkl.js.map +1 -0
- package/dist/chunks/WebSocketClient-D6i85jl2.js +2 -0
- package/dist/chunks/WebSocketClient-D6i85jl2.js.map +1 -0
- package/dist/chunks/WebSocketClient-Dvl3AYx1.js +297 -0
- package/dist/chunks/WebSocketClient-Dvl3AYx1.js.map +1 -0
- package/dist/core.css +1181 -0
- package/dist/css/web-mojo.css +17 -0
- package/dist/css-manifest.json +6 -0
- package/dist/docit.cjs.js +2 -0
- package/dist/docit.cjs.js.map +1 -0
- package/dist/docit.es.js +959 -0
- package/dist/docit.es.js.map +1 -0
- package/dist/index.cjs.js +2 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.es.js +2681 -0
- package/dist/index.es.js.map +1 -0
- package/dist/lightbox.cjs.js +2 -0
- package/dist/lightbox.cjs.js.map +1 -0
- package/dist/lightbox.css +606 -0
- package/dist/lightbox.es.js +3737 -0
- package/dist/lightbox.es.js.map +1 -0
- package/dist/loader.es.js +115 -0
- package/dist/loader.umd.js +85 -0
- package/dist/portal.css +2446 -0
- package/dist/table.css +639 -0
- package/dist/toast.css +181 -0
- package/package.json +179 -0
|
@@ -0,0 +1,4767 @@
|
|
|
1
|
+
const VERSION = "2.1.45";
|
|
2
|
+
const VERSION_MAJOR = 2;
|
|
3
|
+
const VERSION_MINOR = 1;
|
|
4
|
+
const VERSION_REVISION = 45;
|
|
5
|
+
const BUILD_TIME = "2025-09-06T19:46:44.491Z";
|
|
6
|
+
const VERSION_INFO = {
|
|
7
|
+
full: VERSION,
|
|
8
|
+
major: VERSION_MAJOR,
|
|
9
|
+
minor: VERSION_MINOR,
|
|
10
|
+
revision: VERSION_REVISION,
|
|
11
|
+
buildTime: BUILD_TIME,
|
|
12
|
+
toString() {
|
|
13
|
+
return this.full;
|
|
14
|
+
},
|
|
15
|
+
compare(other) {
|
|
16
|
+
const parseVer = (v) => v.split(".").map(Number);
|
|
17
|
+
const [a1, a2, a3] = parseVer(this.full);
|
|
18
|
+
const [b1, b2, b3] = parseVer(other);
|
|
19
|
+
if (a1 !== b1) return a1 - b1;
|
|
20
|
+
if (a2 !== b2) return a2 - b2;
|
|
21
|
+
return a3 - b3;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
if (typeof window !== "undefined") {
|
|
25
|
+
window.MOJO = window.MOJO || {};
|
|
26
|
+
window.MOJO.VERSION = VERSION;
|
|
27
|
+
window.MOJO.VERSION_INFO = VERSION_INFO;
|
|
28
|
+
window.MOJO.version = VERSION;
|
|
29
|
+
}
|
|
30
|
+
const objectToString = Object.prototype.toString;
|
|
31
|
+
const isArray = Array.isArray || function(obj) {
|
|
32
|
+
return objectToString.call(obj) === "[object Array]";
|
|
33
|
+
};
|
|
34
|
+
const isFunction = function(obj) {
|
|
35
|
+
return typeof obj === "function";
|
|
36
|
+
};
|
|
37
|
+
const isObject = function(obj) {
|
|
38
|
+
return obj != null && typeof obj === "object";
|
|
39
|
+
};
|
|
40
|
+
const escapeHtml = function(string) {
|
|
41
|
+
const entityMap = {
|
|
42
|
+
"&": "&",
|
|
43
|
+
"<": "<",
|
|
44
|
+
">": ">",
|
|
45
|
+
'"': """,
|
|
46
|
+
"'": "'",
|
|
47
|
+
"/": "/",
|
|
48
|
+
"`": "`",
|
|
49
|
+
"=": "="
|
|
50
|
+
};
|
|
51
|
+
return String(string).replace(/[&<>"'`=\/]/g, function(s) {
|
|
52
|
+
return entityMap[s];
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
class Scanner {
|
|
56
|
+
constructor(string) {
|
|
57
|
+
this.string = string;
|
|
58
|
+
this.tail = string;
|
|
59
|
+
this.pos = 0;
|
|
60
|
+
}
|
|
61
|
+
eos() {
|
|
62
|
+
return this.tail === "";
|
|
63
|
+
}
|
|
64
|
+
scan(re) {
|
|
65
|
+
const match = this.tail.match(re);
|
|
66
|
+
if (!match || match.index !== 0) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
const string = match[0];
|
|
70
|
+
this.tail = this.tail.substring(string.length);
|
|
71
|
+
this.pos += string.length;
|
|
72
|
+
return string;
|
|
73
|
+
}
|
|
74
|
+
scanUntil(re) {
|
|
75
|
+
const index = this.tail.search(re);
|
|
76
|
+
let match;
|
|
77
|
+
switch (index) {
|
|
78
|
+
case -1:
|
|
79
|
+
match = this.tail;
|
|
80
|
+
this.tail = "";
|
|
81
|
+
break;
|
|
82
|
+
case 0:
|
|
83
|
+
match = "";
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
match = this.tail.substring(0, index);
|
|
87
|
+
this.tail = this.tail.substring(index);
|
|
88
|
+
}
|
|
89
|
+
this.pos += match.length;
|
|
90
|
+
return match;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
class Context {
|
|
94
|
+
constructor(view, parentContext) {
|
|
95
|
+
this.view = view;
|
|
96
|
+
this.cache = { ".": this.view };
|
|
97
|
+
this.parent = parentContext;
|
|
98
|
+
if (!this.view?._cacheId) {
|
|
99
|
+
if (this.view && typeof this.view === "object") {
|
|
100
|
+
this.view._cacheId = Math.random().toString(36).substring(2);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
push(view) {
|
|
105
|
+
return new Context(view, this);
|
|
106
|
+
}
|
|
107
|
+
lookup(name) {
|
|
108
|
+
if (this.renderCache && this.view?._cacheId) {
|
|
109
|
+
const cacheKey = `${this.view._cacheId}:${name}`;
|
|
110
|
+
if (this.renderCache.has(cacheKey)) {
|
|
111
|
+
return this.renderCache.get(cacheKey);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (name === ".") {
|
|
115
|
+
return this.view;
|
|
116
|
+
}
|
|
117
|
+
if (name && name.startsWith(".")) {
|
|
118
|
+
let actualName = name.substring(1);
|
|
119
|
+
let shouldIterate = false;
|
|
120
|
+
if (actualName.endsWith("|iter")) {
|
|
121
|
+
actualName = actualName.substring(0, actualName.length - 5);
|
|
122
|
+
shouldIterate = true;
|
|
123
|
+
}
|
|
124
|
+
if (this.view && typeof this.view === "object") {
|
|
125
|
+
let value2;
|
|
126
|
+
if (typeof this.view.getContextValue === "function") {
|
|
127
|
+
try {
|
|
128
|
+
value2 = this.view.getContextValue(actualName);
|
|
129
|
+
if (value2 !== void 0) {
|
|
130
|
+
if (isFunction(value2)) {
|
|
131
|
+
value2 = value2.call(this.view);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
value2 = void 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (value2 === void 0 && actualName in this.view) {
|
|
139
|
+
value2 = this.view[actualName];
|
|
140
|
+
if (isFunction(value2)) {
|
|
141
|
+
value2 = value2.call(this.view);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (isArray(value2)) {
|
|
145
|
+
if (shouldIterate) {
|
|
146
|
+
return value2;
|
|
147
|
+
} else {
|
|
148
|
+
return value2.length > 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (isObject(value2)) {
|
|
152
|
+
if (shouldIterate) {
|
|
153
|
+
return Object.entries(value2).map(([key, val]) => ({
|
|
154
|
+
key,
|
|
155
|
+
value: val
|
|
156
|
+
}));
|
|
157
|
+
} else {
|
|
158
|
+
return Object.keys(value2).length > 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return value2;
|
|
162
|
+
}
|
|
163
|
+
return void 0;
|
|
164
|
+
}
|
|
165
|
+
const cache = this.cache;
|
|
166
|
+
let value;
|
|
167
|
+
if (cache.hasOwnProperty(name)) {
|
|
168
|
+
value = cache[name];
|
|
169
|
+
} else {
|
|
170
|
+
let context = this;
|
|
171
|
+
let intermediateValue;
|
|
172
|
+
let names;
|
|
173
|
+
let index;
|
|
174
|
+
let lookupHit = false;
|
|
175
|
+
while (context) {
|
|
176
|
+
if (context.view && typeof context.view.getContextValue === "function") {
|
|
177
|
+
try {
|
|
178
|
+
intermediateValue = context.view.getContextValue(name);
|
|
179
|
+
if (intermediateValue !== void 0) {
|
|
180
|
+
lookupHit = true;
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {
|
|
183
|
+
lookupHit = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!lookupHit) {
|
|
187
|
+
if (name.indexOf(".") > 0) {
|
|
188
|
+
intermediateValue = context.view;
|
|
189
|
+
names = name.split(".");
|
|
190
|
+
index = 0;
|
|
191
|
+
while (intermediateValue != null && index < names.length) {
|
|
192
|
+
if (intermediateValue && typeof intermediateValue.getContextValue === "function" && index < names.length) {
|
|
193
|
+
try {
|
|
194
|
+
const remainingPath = names.slice(index).join(".");
|
|
195
|
+
intermediateValue = intermediateValue.getContextValue(remainingPath);
|
|
196
|
+
index = names.length;
|
|
197
|
+
if (intermediateValue !== void 0) {
|
|
198
|
+
lookupHit = true;
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (index === names.length - 1) {
|
|
202
|
+
lookupHit = hasProperty(intermediateValue, names[index]) || primitiveHasOwnProperty(intermediateValue, names[index]);
|
|
203
|
+
}
|
|
204
|
+
intermediateValue = intermediateValue[names[index++]];
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
if (index === names.length - 1) {
|
|
208
|
+
lookupHit = hasProperty(intermediateValue, names[index]) || primitiveHasOwnProperty(intermediateValue, names[index]);
|
|
209
|
+
}
|
|
210
|
+
intermediateValue = intermediateValue[names[index++]];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
intermediateValue = context.view[name];
|
|
215
|
+
lookupHit = hasProperty(context.view, name);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (lookupHit) {
|
|
219
|
+
value = intermediateValue;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
context = context.parent;
|
|
223
|
+
}
|
|
224
|
+
cache[name] = value;
|
|
225
|
+
}
|
|
226
|
+
if (isFunction(value)) value = value.call(this.view);
|
|
227
|
+
if (this.renderCache && this.view?._cacheId) {
|
|
228
|
+
const cacheKey = `${this.view._cacheId}:${name}`;
|
|
229
|
+
this.renderCache.set(cacheKey, value);
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function hasProperty(obj, propName) {
|
|
235
|
+
return obj != null && typeof obj === "object" && propName in obj;
|
|
236
|
+
}
|
|
237
|
+
function primitiveHasOwnProperty(primitive, propName) {
|
|
238
|
+
return primitive != null && typeof primitive !== "object" && primitive.hasOwnProperty && primitive.hasOwnProperty(propName);
|
|
239
|
+
}
|
|
240
|
+
class Writer {
|
|
241
|
+
constructor() {
|
|
242
|
+
this.templateCache = /* @__PURE__ */ new Map();
|
|
243
|
+
}
|
|
244
|
+
clearCache() {
|
|
245
|
+
this.templateCache.clear();
|
|
246
|
+
}
|
|
247
|
+
parse(template, tags) {
|
|
248
|
+
tags = tags || ["{{", "}}"];
|
|
249
|
+
const cacheKey = template + ":" + tags.join(":");
|
|
250
|
+
let tokens = this.templateCache.get(cacheKey);
|
|
251
|
+
if (tokens == null) {
|
|
252
|
+
tokens = this.parseTemplate(template, tags);
|
|
253
|
+
this.templateCache.set(cacheKey, tokens);
|
|
254
|
+
}
|
|
255
|
+
return tokens;
|
|
256
|
+
}
|
|
257
|
+
parseTemplate(template, tags) {
|
|
258
|
+
if (!template) return [];
|
|
259
|
+
const openingTag = tags[0];
|
|
260
|
+
const closingTag = tags[1];
|
|
261
|
+
const scanner = new Scanner(template);
|
|
262
|
+
const tokens = [];
|
|
263
|
+
let start, type, value, chr, token;
|
|
264
|
+
const openingTagRe = new RegExp(escapeRegExp(openingTag) + "\\s*");
|
|
265
|
+
const closingTagRe = new RegExp("\\s*" + escapeRegExp(closingTag));
|
|
266
|
+
const closingCurlyRe = new RegExp("\\s*" + escapeRegExp("}" + closingTag));
|
|
267
|
+
while (!scanner.eos()) {
|
|
268
|
+
start = scanner.pos;
|
|
269
|
+
value = scanner.scanUntil(openingTagRe);
|
|
270
|
+
if (value) {
|
|
271
|
+
for (let i = 0; i < value.length; ++i) {
|
|
272
|
+
chr = value.charAt(i);
|
|
273
|
+
if (chr === "\n") {
|
|
274
|
+
tokens.push(["text", chr]);
|
|
275
|
+
} else {
|
|
276
|
+
tokens.push(["text", chr]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!scanner.scan(openingTagRe)) break;
|
|
281
|
+
type = scanner.scan(/[#^\/>{&=!]/);
|
|
282
|
+
if (!type) type = "name";
|
|
283
|
+
scanner.scan(/\s*/);
|
|
284
|
+
if (type === "=") {
|
|
285
|
+
value = scanner.scanUntil(/\s*=/);
|
|
286
|
+
scanner.scan(/\s*=/);
|
|
287
|
+
scanner.scanUntil(closingTagRe);
|
|
288
|
+
} else if (type === "{") {
|
|
289
|
+
value = scanner.scanUntil(closingCurlyRe);
|
|
290
|
+
scanner.scan(closingCurlyRe);
|
|
291
|
+
type = "&";
|
|
292
|
+
} else {
|
|
293
|
+
value = scanner.scanUntil(closingTagRe);
|
|
294
|
+
}
|
|
295
|
+
scanner.scan(closingTagRe);
|
|
296
|
+
if (type === "#" || type === "^") {
|
|
297
|
+
token = [type, value, start, scanner.pos];
|
|
298
|
+
tokens.push(token);
|
|
299
|
+
} else if (type === "/") {
|
|
300
|
+
let openSection;
|
|
301
|
+
for (let i = tokens.length - 1; i >= 0; --i) {
|
|
302
|
+
if (tokens[i][0] === "#" || tokens[i][0] === "^") {
|
|
303
|
+
if (tokens[i][1] === value) {
|
|
304
|
+
openSection = tokens[i];
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (openSection) {
|
|
310
|
+
if (openSection.length === 4) {
|
|
311
|
+
openSection.push(scanner.pos);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
tokens.push([type, value, start, scanner.pos]);
|
|
315
|
+
} else {
|
|
316
|
+
tokens.push([type, value, start, scanner.pos]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return this.nestSections(this.squashTokens(tokens));
|
|
320
|
+
}
|
|
321
|
+
squashTokens(tokens) {
|
|
322
|
+
const squashedTokens = [];
|
|
323
|
+
let token, lastToken;
|
|
324
|
+
for (let i = 0; i < tokens.length; ++i) {
|
|
325
|
+
token = tokens[i];
|
|
326
|
+
if (token) {
|
|
327
|
+
if (token[0] === "text" && lastToken && lastToken[0] === "text") {
|
|
328
|
+
lastToken[1] += token[1];
|
|
329
|
+
lastToken[3] = token[3];
|
|
330
|
+
} else {
|
|
331
|
+
squashedTokens.push(token);
|
|
332
|
+
lastToken = token;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return squashedTokens;
|
|
337
|
+
}
|
|
338
|
+
nestSections(tokens) {
|
|
339
|
+
const nestedTokens = [];
|
|
340
|
+
let collector = nestedTokens;
|
|
341
|
+
const sections = [];
|
|
342
|
+
for (let i = 0; i < tokens.length; ++i) {
|
|
343
|
+
const token = tokens[i];
|
|
344
|
+
switch (token[0]) {
|
|
345
|
+
case "#":
|
|
346
|
+
case "^":
|
|
347
|
+
const sectionToken = [
|
|
348
|
+
token[0],
|
|
349
|
+
// type ('#' or '^')
|
|
350
|
+
token[1],
|
|
351
|
+
// section name
|
|
352
|
+
token[2],
|
|
353
|
+
// start position
|
|
354
|
+
token[3],
|
|
355
|
+
// end position after opening tag
|
|
356
|
+
[],
|
|
357
|
+
// children array
|
|
358
|
+
token[4] || null
|
|
359
|
+
// closing position (if set during parsing)
|
|
360
|
+
];
|
|
361
|
+
collector.push(sectionToken);
|
|
362
|
+
sections.push(sectionToken);
|
|
363
|
+
collector = sectionToken[4];
|
|
364
|
+
break;
|
|
365
|
+
case "/":
|
|
366
|
+
const section = sections.pop();
|
|
367
|
+
if (section) {
|
|
368
|
+
section[5] = token[2];
|
|
369
|
+
collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
default:
|
|
373
|
+
collector.push(token);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return nestedTokens;
|
|
377
|
+
}
|
|
378
|
+
render(template, view, partials, config) {
|
|
379
|
+
const tags = this.getConfigTags(config) || ["{{", "}}"];
|
|
380
|
+
const tokens = this.parse(template, tags);
|
|
381
|
+
const renderCache = /* @__PURE__ */ new Map();
|
|
382
|
+
return this.renderTokens(tokens, new Context(view), partials, template, config, renderCache);
|
|
383
|
+
}
|
|
384
|
+
renderTokens(tokens, context, partials, originalTemplate, config, renderCache) {
|
|
385
|
+
if (renderCache && !context.renderCache) {
|
|
386
|
+
context.renderCache = renderCache;
|
|
387
|
+
}
|
|
388
|
+
let buffer = "";
|
|
389
|
+
for (let i = 0; i < tokens.length; ++i) {
|
|
390
|
+
const token = tokens[i];
|
|
391
|
+
let value;
|
|
392
|
+
switch (token[0]) {
|
|
393
|
+
case "#":
|
|
394
|
+
value = context.lookup(token[1]);
|
|
395
|
+
if (!value) continue;
|
|
396
|
+
const childTokens = token[4];
|
|
397
|
+
if (!childTokens || !isArray(childTokens)) {
|
|
398
|
+
console.warn(`MUSTACHE WARNING - Section ${token[1]} has no child tokens:`, token);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (isArray(value)) {
|
|
402
|
+
for (let j = 0; j < value.length; ++j) {
|
|
403
|
+
const itemContext = context.push(value[j]);
|
|
404
|
+
if (context.renderCache) {
|
|
405
|
+
itemContext.renderCache = context.renderCache;
|
|
406
|
+
}
|
|
407
|
+
const itemResult = this.renderTokens(childTokens, itemContext, partials, originalTemplate, config, renderCache);
|
|
408
|
+
buffer += itemResult;
|
|
409
|
+
}
|
|
410
|
+
} else if (typeof value === "object" || typeof value === "string" || typeof value === "number") {
|
|
411
|
+
const pushedContext = context.push(value);
|
|
412
|
+
if (context.renderCache) {
|
|
413
|
+
pushedContext.renderCache = context.renderCache;
|
|
414
|
+
}
|
|
415
|
+
buffer += this.renderTokens(childTokens, pushedContext, partials, originalTemplate, config, renderCache);
|
|
416
|
+
} else if (isFunction(value)) {
|
|
417
|
+
const text = originalTemplate == null ? null : originalTemplate.slice(token[3], token[5]);
|
|
418
|
+
value = value.call(context.view, text, (template) => this.render(template, context.view, partials, config));
|
|
419
|
+
if (value != null) buffer += value;
|
|
420
|
+
} else if (value) {
|
|
421
|
+
buffer += this.renderTokens(childTokens, context, partials, originalTemplate, config, renderCache);
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
case "^":
|
|
425
|
+
value = context.lookup(token[1]);
|
|
426
|
+
if (!value || isArray(value) && value.length === 0) {
|
|
427
|
+
const childTokens2 = token[4];
|
|
428
|
+
if (childTokens2 && isArray(childTokens2)) {
|
|
429
|
+
buffer += this.renderTokens(childTokens2, context, partials, originalTemplate, config, renderCache);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
case ">":
|
|
434
|
+
if (!partials) continue;
|
|
435
|
+
value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
|
|
436
|
+
if (value != null) {
|
|
437
|
+
buffer += this.render(value, context.view, partials, config);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
case "&":
|
|
441
|
+
value = context.lookup(token[1]);
|
|
442
|
+
if (value != null) buffer += value;
|
|
443
|
+
break;
|
|
444
|
+
case "name":
|
|
445
|
+
value = context.lookup(token[1]);
|
|
446
|
+
if (value != null) buffer += escapeHtml(value);
|
|
447
|
+
break;
|
|
448
|
+
case "text":
|
|
449
|
+
buffer += token[1];
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return buffer;
|
|
454
|
+
}
|
|
455
|
+
getConfigTags(config) {
|
|
456
|
+
if (isObject(config) && isArray(config.tags)) {
|
|
457
|
+
return config.tags;
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function escapeRegExp(string) {
|
|
463
|
+
return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&");
|
|
464
|
+
}
|
|
465
|
+
const defaultWriter = new Writer();
|
|
466
|
+
const Mustache = {
|
|
467
|
+
name: "MOJO Mustache",
|
|
468
|
+
version: "1.0.0",
|
|
469
|
+
tags: ["{{", "}}"],
|
|
470
|
+
Scanner,
|
|
471
|
+
Context,
|
|
472
|
+
Writer,
|
|
473
|
+
escape: escapeHtml,
|
|
474
|
+
clearCache() {
|
|
475
|
+
return defaultWriter.clearCache();
|
|
476
|
+
},
|
|
477
|
+
parse(template, tags) {
|
|
478
|
+
return defaultWriter.parse(template, tags);
|
|
479
|
+
},
|
|
480
|
+
render(template, view, partials, config) {
|
|
481
|
+
if (typeof template !== "string") {
|
|
482
|
+
throw new TypeError('Invalid template! Template should be a "string"');
|
|
483
|
+
}
|
|
484
|
+
return defaultWriter.render(template, view, partials, config);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const GENERIC_AVATAR_SVG = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2NlZDRkYSI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg==";
|
|
488
|
+
class DataFormatter {
|
|
489
|
+
constructor() {
|
|
490
|
+
this.formatters = /* @__PURE__ */ new Map();
|
|
491
|
+
this.registerBuiltInFormatters();
|
|
492
|
+
}
|
|
493
|
+
escapeHtml(str) {
|
|
494
|
+
if (str === null || str === void 0) {
|
|
495
|
+
return "";
|
|
496
|
+
}
|
|
497
|
+
const map = {
|
|
498
|
+
"&": "&",
|
|
499
|
+
"<": "<",
|
|
500
|
+
">": ">",
|
|
501
|
+
'"': """,
|
|
502
|
+
"'": "'"
|
|
503
|
+
};
|
|
504
|
+
return String(str).replace(/[&<>"']/g, (m) => map[m]);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Register all built-in formatters
|
|
508
|
+
*/
|
|
509
|
+
registerBuiltInFormatters() {
|
|
510
|
+
this.register("date", this.date.bind(this));
|
|
511
|
+
this.register("time", this.time.bind(this));
|
|
512
|
+
this.register("datetime", this.datetime.bind(this));
|
|
513
|
+
this.register("relative", this.relative.bind(this));
|
|
514
|
+
this.register("fromNow", this.relative.bind(this));
|
|
515
|
+
this.register("iso", this.iso.bind(this));
|
|
516
|
+
this.register("epoch", (v) => {
|
|
517
|
+
if (v === null || v === void 0 || v === "") return v;
|
|
518
|
+
const num = parseFloat(v);
|
|
519
|
+
if (isNaN(num)) return v;
|
|
520
|
+
return num * 1e3;
|
|
521
|
+
});
|
|
522
|
+
this.register("number", this.number.bind(this));
|
|
523
|
+
this.register("currency", this.currency.bind(this));
|
|
524
|
+
this.register("percent", this.percent.bind(this));
|
|
525
|
+
this.register("filesize", this.filesize.bind(this));
|
|
526
|
+
this.register("ordinal", this.ordinal.bind(this));
|
|
527
|
+
this.register("compact", this.compact.bind(this));
|
|
528
|
+
this.register("uppercase", (v) => String(v).toUpperCase());
|
|
529
|
+
this.register("lowercase", (v) => String(v).toLowerCase());
|
|
530
|
+
this.register("capitalize", this.capitalize.bind(this));
|
|
531
|
+
this.register("truncate", this.truncate.bind(this));
|
|
532
|
+
this.register("truncate_middle", this.truncate_middle.bind(this));
|
|
533
|
+
this.register("slug", this.slug.bind(this));
|
|
534
|
+
this.register("initials", this.initials.bind(this));
|
|
535
|
+
this.register("mask", this.mask.bind(this));
|
|
536
|
+
this.register("email", this.email.bind(this));
|
|
537
|
+
this.register("phone", this.phone.bind(this));
|
|
538
|
+
this.register("url", this.url.bind(this));
|
|
539
|
+
this.register("badge", this.badge.bind(this));
|
|
540
|
+
this.register("status", this.status.bind(this));
|
|
541
|
+
this.register("boolean", this.boolean.bind(this));
|
|
542
|
+
this.register("bool", this.boolean.bind(this));
|
|
543
|
+
this.register("yesno", (v) => this.boolean(v, "Yes", "No"));
|
|
544
|
+
this.register("yesnoicon", this.yesnoicon.bind(this));
|
|
545
|
+
this.register("icon", this.icon.bind(this));
|
|
546
|
+
this.register("avatar", this.avatar.bind(this));
|
|
547
|
+
this.register("image", this.image.bind(this));
|
|
548
|
+
this.register("default", this.default.bind(this));
|
|
549
|
+
this.register("json", this.json.bind(this));
|
|
550
|
+
this.register("raw", (v) => v);
|
|
551
|
+
this.register("custom", (v, fn) => typeof fn === "function" ? fn(v) : v);
|
|
552
|
+
this.register("iter", this.iter.bind(this));
|
|
553
|
+
this.register("keys", (v) => {
|
|
554
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
555
|
+
return Object.keys(v);
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
});
|
|
559
|
+
this.register("values", (v) => {
|
|
560
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
561
|
+
return Object.values(v);
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
});
|
|
565
|
+
this.register("plural", this.plural.bind(this));
|
|
566
|
+
this.register("list", this.formatList.bind(this));
|
|
567
|
+
this.register("duration", this.duration.bind(this));
|
|
568
|
+
this.register("hash", this.hash.bind(this));
|
|
569
|
+
this.register("stripHtml", this.stripHtml.bind(this));
|
|
570
|
+
this.register("highlight", this.highlight.bind(this));
|
|
571
|
+
this.register("pre", (v) => `<pre class="bg-light p-2 rounded border">${this.escapeHtml(String(v))}</pre>`);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Register a custom formatter
|
|
575
|
+
* @param {string} name - Formatter name
|
|
576
|
+
* @param {Function} formatter - Formatter function
|
|
577
|
+
* @returns {DataFormatter} This instance for chaining
|
|
578
|
+
*/
|
|
579
|
+
register(name, formatter) {
|
|
580
|
+
if (typeof formatter !== "function") {
|
|
581
|
+
throw new Error(`Formatter must be a function, got ${typeof formatter}`);
|
|
582
|
+
}
|
|
583
|
+
this.formatters.set(name.toLowerCase(), formatter);
|
|
584
|
+
return this;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Apply a formatter
|
|
588
|
+
* @param {string} name - Formatter name
|
|
589
|
+
* @param {*} value - Value to format
|
|
590
|
+
* @param {...*} args - Additional arguments
|
|
591
|
+
* @returns {*} Formatted value
|
|
592
|
+
*/
|
|
593
|
+
apply(name, value, ...args) {
|
|
594
|
+
try {
|
|
595
|
+
const formatter = this.formatters.get(name.toLowerCase());
|
|
596
|
+
if (!formatter) {
|
|
597
|
+
console.warn(`Formatter '${name}' not found`);
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
return formatter(value, ...args);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
console.error(`Error in formatter '${name}':`, error);
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Process pipe string
|
|
608
|
+
* @param {*} value - Value to format
|
|
609
|
+
* @param {string} pipeString - Pipe string (e.g., "date('YYYY-MM-DD')|uppercase")
|
|
610
|
+
* @returns {*} Formatted value
|
|
611
|
+
*/
|
|
612
|
+
pipe(value, pipeString) {
|
|
613
|
+
if (!pipeString) return value;
|
|
614
|
+
const pipes = this.parsePipeString(pipeString);
|
|
615
|
+
return pipes.reduce((currentValue, pipe) => {
|
|
616
|
+
return this.apply(pipe.name, currentValue, ...pipe.args);
|
|
617
|
+
}, value);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Parse pipe string into formatter calls
|
|
621
|
+
* @param {string} pipeString - Pipe string
|
|
622
|
+
* @returns {Array} Array of {name, args} objects
|
|
623
|
+
*/
|
|
624
|
+
parsePipeString(pipeString) {
|
|
625
|
+
const pipes = [];
|
|
626
|
+
const tokens = pipeString.split("|").map((s) => s.trim());
|
|
627
|
+
for (const token of tokens) {
|
|
628
|
+
const parsed = this.parseFormatter(token);
|
|
629
|
+
if (parsed) {
|
|
630
|
+
pipes.push(parsed);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return pipes;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Parse individual formatter with arguments
|
|
637
|
+
* @param {string} token - Formatter token
|
|
638
|
+
* @returns {Object} {name, args} object
|
|
639
|
+
*/
|
|
640
|
+
parseFormatter(token) {
|
|
641
|
+
const match = token.match(/^([a-zA-Z_]\w*)\s*(?:\((.*)\))?$/);
|
|
642
|
+
if (!match) return null;
|
|
643
|
+
const [, name, argsString] = match;
|
|
644
|
+
const args = argsString ? this.parseArguments(argsString) : [];
|
|
645
|
+
return { name, args };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Parse formatter arguments
|
|
649
|
+
* @param {string} argsString - Arguments string
|
|
650
|
+
* @returns {Array} Parsed arguments
|
|
651
|
+
*/
|
|
652
|
+
parseArguments(argsString) {
|
|
653
|
+
const args = [];
|
|
654
|
+
let current = "";
|
|
655
|
+
let inQuotes = false;
|
|
656
|
+
let quoteChar = null;
|
|
657
|
+
let depth = 0;
|
|
658
|
+
for (let i = 0; i < argsString.length; i++) {
|
|
659
|
+
const char = argsString[i];
|
|
660
|
+
if (!inQuotes && (char === '"' || char === "'")) {
|
|
661
|
+
inQuotes = true;
|
|
662
|
+
quoteChar = char;
|
|
663
|
+
current += char;
|
|
664
|
+
} else if (inQuotes && char === quoteChar && argsString[i - 1] !== "\\") {
|
|
665
|
+
inQuotes = false;
|
|
666
|
+
quoteChar = null;
|
|
667
|
+
current += char;
|
|
668
|
+
} else if (!inQuotes && char === "{") {
|
|
669
|
+
depth++;
|
|
670
|
+
current += char;
|
|
671
|
+
} else if (!inQuotes && char === "}") {
|
|
672
|
+
depth--;
|
|
673
|
+
current += char;
|
|
674
|
+
} else if (!inQuotes && depth === 0 && char === ",") {
|
|
675
|
+
args.push(this.parseValue(current.trim()));
|
|
676
|
+
current = "";
|
|
677
|
+
} else {
|
|
678
|
+
current += char;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (current.trim()) {
|
|
682
|
+
args.push(this.parseValue(current.trim()));
|
|
683
|
+
}
|
|
684
|
+
return args;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Parse a single value
|
|
688
|
+
* @param {string} value - Value string
|
|
689
|
+
* @returns {*} Parsed value
|
|
690
|
+
*/
|
|
691
|
+
parseValue(value) {
|
|
692
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
693
|
+
return value.slice(1, -1);
|
|
694
|
+
}
|
|
695
|
+
if (value === "true") return true;
|
|
696
|
+
if (value === "false") return false;
|
|
697
|
+
if (value === "null") return null;
|
|
698
|
+
if (value === "undefined") return void 0;
|
|
699
|
+
if (!isNaN(value) && value !== "") {
|
|
700
|
+
return Number(value);
|
|
701
|
+
}
|
|
702
|
+
if (value.startsWith("{") && value.endsWith("}")) {
|
|
703
|
+
try {
|
|
704
|
+
return JSON.parse(value);
|
|
705
|
+
} catch (e) {
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return value;
|
|
709
|
+
}
|
|
710
|
+
// ============= Date/Time Formatters =============
|
|
711
|
+
/**
|
|
712
|
+
* Format date
|
|
713
|
+
* @param {*} value - Date value
|
|
714
|
+
* @param {string} format - Date format pattern
|
|
715
|
+
* @returns {string} Formatted date
|
|
716
|
+
*/
|
|
717
|
+
date(value, format = "MM/DD/YYYY") {
|
|
718
|
+
if (!value) return "";
|
|
719
|
+
value = this.normalizeEpoch(value);
|
|
720
|
+
let date;
|
|
721
|
+
if (value instanceof Date) {
|
|
722
|
+
date = value;
|
|
723
|
+
} else if (typeof value === "string") {
|
|
724
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
725
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
726
|
+
date = new Date(year, month - 1, day);
|
|
727
|
+
} else {
|
|
728
|
+
date = new Date(value);
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
date = new Date(value);
|
|
732
|
+
}
|
|
733
|
+
if (isNaN(date.getTime())) return String(value);
|
|
734
|
+
const tokens = {
|
|
735
|
+
"YYYY": date.getFullYear(),
|
|
736
|
+
"YY": String(date.getFullYear()).slice(-2),
|
|
737
|
+
"MMMM": date.toLocaleDateString("en-US", { month: "long" }),
|
|
738
|
+
"MMM": date.toLocaleDateString("en-US", { month: "short" }),
|
|
739
|
+
"MM": String(date.getMonth() + 1).padStart(2, "0"),
|
|
740
|
+
"M": date.getMonth() + 1,
|
|
741
|
+
"dddd": date.toLocaleDateString("en-US", { weekday: "long" }),
|
|
742
|
+
"ddd": date.toLocaleDateString("en-US", { weekday: "short" }),
|
|
743
|
+
"DD": String(date.getDate()).padStart(2, "0"),
|
|
744
|
+
"D": date.getDate()
|
|
745
|
+
};
|
|
746
|
+
let result = format;
|
|
747
|
+
const tokenPattern = new RegExp(`(${Object.keys(tokens).join("|")})`, "g");
|
|
748
|
+
result = result.replace(tokenPattern, (match) => tokens[match] || match);
|
|
749
|
+
return result;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Format time
|
|
753
|
+
* @param {*} value - Time value
|
|
754
|
+
* @param {string} format - Time format pattern
|
|
755
|
+
* @returns {string} Formatted time
|
|
756
|
+
*/
|
|
757
|
+
time(value, format = "HH:mm:ss") {
|
|
758
|
+
if (!value) return "";
|
|
759
|
+
value = this.normalizeEpoch(value);
|
|
760
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
761
|
+
if (isNaN(date.getTime())) return String(value);
|
|
762
|
+
const hours = date.getHours();
|
|
763
|
+
const replacements = {
|
|
764
|
+
"HH": String(hours).padStart(2, "0"),
|
|
765
|
+
"H": hours,
|
|
766
|
+
"hh": String(hours % 12 || 12).padStart(2, "0"),
|
|
767
|
+
"h": hours % 12 || 12,
|
|
768
|
+
"mm": String(date.getMinutes()).padStart(2, "0"),
|
|
769
|
+
"m": date.getMinutes(),
|
|
770
|
+
"ss": String(date.getSeconds()).padStart(2, "0"),
|
|
771
|
+
"s": date.getSeconds(),
|
|
772
|
+
"A": hours >= 12 ? "PM" : "AM",
|
|
773
|
+
"a": hours >= 12 ? "pm" : "am"
|
|
774
|
+
};
|
|
775
|
+
let result = format;
|
|
776
|
+
const sortedKeys = Object.keys(replacements).sort((a, b) => b.length - a.length);
|
|
777
|
+
for (const key of sortedKeys) {
|
|
778
|
+
result = result.replace(new RegExp(key, "g"), replacements[key]);
|
|
779
|
+
}
|
|
780
|
+
return result;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Format date and time
|
|
784
|
+
* @param {*} value - DateTime value
|
|
785
|
+
* @param {string} dateFormat - Date format
|
|
786
|
+
* @param {string} timeFormat - Time format
|
|
787
|
+
* @returns {string} Formatted datetime
|
|
788
|
+
*/
|
|
789
|
+
datetime(value, dateFormat = "MM/DD/YYYY", timeFormat = "HH:mm") {
|
|
790
|
+
value = this.normalizeEpoch(value);
|
|
791
|
+
const dateStr = this.date(value, dateFormat);
|
|
792
|
+
const timeStr = this.time(value, timeFormat);
|
|
793
|
+
return dateStr && timeStr ? `${dateStr} ${timeStr}` : "";
|
|
794
|
+
}
|
|
795
|
+
normalizeEpoch(value) {
|
|
796
|
+
if (typeof value !== "number") value = Number(value);
|
|
797
|
+
if (isNaN(value)) return "";
|
|
798
|
+
if (value < 1e11) {
|
|
799
|
+
return value * 1e3;
|
|
800
|
+
} else if (value > 1e12 && value < 1e13) {
|
|
801
|
+
return value;
|
|
802
|
+
} else {
|
|
803
|
+
throw new Error("Value doesn't look like epoch seconds or ms");
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Format relative time
|
|
808
|
+
* @param {*} value - Date value
|
|
809
|
+
* @param {boolean} short - Use short format
|
|
810
|
+
* @returns {string} Relative time string
|
|
811
|
+
*/
|
|
812
|
+
relative(value, short = false) {
|
|
813
|
+
if (!value) return "";
|
|
814
|
+
value = this.normalizeEpoch(value);
|
|
815
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
816
|
+
if (isNaN(date.getTime())) return String(value);
|
|
817
|
+
const now2 = /* @__PURE__ */ new Date();
|
|
818
|
+
const diffMs = now2 - date;
|
|
819
|
+
const diffSecs = Math.floor(diffMs / 1e3);
|
|
820
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
821
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
822
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
823
|
+
if (short) {
|
|
824
|
+
if (diffDays > 365) return Math.floor(diffDays / 365) + "y";
|
|
825
|
+
if (diffDays > 30) return Math.floor(diffDays / 30) + "mo";
|
|
826
|
+
if (diffDays > 7) return Math.floor(diffDays / 7) + "w";
|
|
827
|
+
if (diffDays > 0) return diffDays + "d";
|
|
828
|
+
if (diffHours > 0) return diffHours + "h";
|
|
829
|
+
if (diffMins > 0) return diffMins + "m";
|
|
830
|
+
return "now";
|
|
831
|
+
}
|
|
832
|
+
if (diffDays > 365) {
|
|
833
|
+
const years = Math.floor(diffDays / 365);
|
|
834
|
+
return years + " year" + (years > 1 ? "s" : "") + " ago";
|
|
835
|
+
}
|
|
836
|
+
if (diffDays > 30) {
|
|
837
|
+
const months = Math.floor(diffDays / 30);
|
|
838
|
+
return months + " month" + (months > 1 ? "s" : "") + " ago";
|
|
839
|
+
}
|
|
840
|
+
if (diffDays > 7) {
|
|
841
|
+
const weeks = Math.floor(diffDays / 7);
|
|
842
|
+
return weeks + " week" + (weeks > 1 ? "s" : "") + " ago";
|
|
843
|
+
}
|
|
844
|
+
if (diffDays === 1) return "yesterday";
|
|
845
|
+
if (diffDays > 0) return diffDays + " days ago";
|
|
846
|
+
if (diffHours > 0) return diffHours + " hour" + (diffHours > 1 ? "s" : "") + " ago";
|
|
847
|
+
if (diffMins > 0) return diffMins + " minute" + (diffMins > 1 ? "s" : "") + " ago";
|
|
848
|
+
if (diffSecs > 30) return diffSecs + " seconds ago";
|
|
849
|
+
return "just now";
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Format ISO date
|
|
853
|
+
* @param {*} value - Date value
|
|
854
|
+
* @param {boolean} dateOnly - Return date only
|
|
855
|
+
* @returns {string} ISO date string
|
|
856
|
+
*/
|
|
857
|
+
iso(value, dateOnly = false) {
|
|
858
|
+
if (!value) return "";
|
|
859
|
+
value = this.normalizeEpoch(value);
|
|
860
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
861
|
+
if (isNaN(date.getTime())) return String(value);
|
|
862
|
+
if (dateOnly) {
|
|
863
|
+
return date.toISOString().split("T")[0];
|
|
864
|
+
}
|
|
865
|
+
return date.toISOString();
|
|
866
|
+
}
|
|
867
|
+
// ============= Number Formatters =============
|
|
868
|
+
/**
|
|
869
|
+
* Format number
|
|
870
|
+
* @param {*} value - Number value
|
|
871
|
+
* @param {number} decimals - Decimal places
|
|
872
|
+
* @param {string} locale - Locale string
|
|
873
|
+
* @returns {string} Formatted number
|
|
874
|
+
*/
|
|
875
|
+
number(value, decimals = 2, locale = "en-US") {
|
|
876
|
+
const num = parseFloat(value);
|
|
877
|
+
if (isNaN(num)) return String(value);
|
|
878
|
+
return num.toLocaleString(locale, {
|
|
879
|
+
minimumFractionDigits: decimals,
|
|
880
|
+
maximumFractionDigits: decimals
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Format currency
|
|
885
|
+
* @param {*} value - Number value
|
|
886
|
+
* @param {string} symbol - Currency symbol
|
|
887
|
+
* @param {number} decimals - Decimal places
|
|
888
|
+
* @returns {string} Formatted currency
|
|
889
|
+
*/
|
|
890
|
+
currency(value, symbol = "$", decimals = 2) {
|
|
891
|
+
const num = parseFloat(value);
|
|
892
|
+
if (isNaN(num)) return String(value);
|
|
893
|
+
const formatted = this.number(num, decimals);
|
|
894
|
+
return symbol + formatted;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Format percentage
|
|
898
|
+
* @param {*} value - Number value
|
|
899
|
+
* @param {number} decimals - Decimal places
|
|
900
|
+
* @param {boolean} multiply - Multiply by 100
|
|
901
|
+
* @returns {string} Formatted percentage
|
|
902
|
+
*/
|
|
903
|
+
percent(value, decimals = 0, multiply = true) {
|
|
904
|
+
const num = parseFloat(value);
|
|
905
|
+
if (isNaN(num)) return String(value);
|
|
906
|
+
const percent = multiply ? num * 100 : num;
|
|
907
|
+
return this.number(percent, decimals) + "%";
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Format file size
|
|
911
|
+
* @param {*} value - Size in bytes
|
|
912
|
+
* @param {boolean} binary - Use binary units (1024)
|
|
913
|
+
* @param {number} decimals - Decimal places
|
|
914
|
+
* @returns {string} Formatted file size
|
|
915
|
+
*/
|
|
916
|
+
filesize(value, binary = false, decimals = 1) {
|
|
917
|
+
const bytes = parseInt(value);
|
|
918
|
+
if (isNaN(bytes)) return String(value);
|
|
919
|
+
const units = binary ? ["B", "KiB", "MiB", "GiB", "TiB"] : ["B", "KB", "MB", "GB", "TB"];
|
|
920
|
+
const divisor = binary ? 1024 : 1e3;
|
|
921
|
+
let size = bytes;
|
|
922
|
+
let unitIndex = 0;
|
|
923
|
+
while (size >= divisor && unitIndex < units.length - 1) {
|
|
924
|
+
size /= divisor;
|
|
925
|
+
unitIndex++;
|
|
926
|
+
}
|
|
927
|
+
const decimalPlaces = unitIndex === 0 ? 0 : decimals;
|
|
928
|
+
return `${size.toFixed(decimalPlaces)} ${units[unitIndex]}`;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Format ordinal number
|
|
932
|
+
* @param {*} value - Number value
|
|
933
|
+
* @param {boolean} suffixOnly - Return suffix only
|
|
934
|
+
* @returns {string} Ordinal number
|
|
935
|
+
*/
|
|
936
|
+
ordinal(value, suffixOnly = false) {
|
|
937
|
+
const num = parseInt(value);
|
|
938
|
+
if (isNaN(num)) return String(value);
|
|
939
|
+
const j = num % 10;
|
|
940
|
+
const k = num % 100;
|
|
941
|
+
let suffix = "th";
|
|
942
|
+
if (j === 1 && k !== 11) suffix = "st";
|
|
943
|
+
else if (j === 2 && k !== 12) suffix = "nd";
|
|
944
|
+
else if (j === 3 && k !== 13) suffix = "rd";
|
|
945
|
+
return suffixOnly ? suffix : num + suffix;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Format compact number
|
|
949
|
+
* @param {*} value - Number value
|
|
950
|
+
* @param {number} decimals - Decimal places
|
|
951
|
+
* @returns {string} Compact number
|
|
952
|
+
*/
|
|
953
|
+
compact(value, decimals = 1) {
|
|
954
|
+
const num = parseFloat(value);
|
|
955
|
+
if (isNaN(num)) return String(value);
|
|
956
|
+
const abs = Math.abs(num);
|
|
957
|
+
const sign = num < 0 ? "-" : "";
|
|
958
|
+
if (abs >= 1e9) {
|
|
959
|
+
return sign + (abs / 1e9).toFixed(decimals) + "B";
|
|
960
|
+
}
|
|
961
|
+
if (abs >= 1e6) {
|
|
962
|
+
return sign + (abs / 1e6).toFixed(decimals) + "M";
|
|
963
|
+
}
|
|
964
|
+
if (abs >= 1e3) {
|
|
965
|
+
return sign + (abs / 1e3).toFixed(decimals) + "K";
|
|
966
|
+
}
|
|
967
|
+
return String(num);
|
|
968
|
+
}
|
|
969
|
+
// ============= String Formatters =============
|
|
970
|
+
/**
|
|
971
|
+
* Capitalize string
|
|
972
|
+
* @param {*} value - String value
|
|
973
|
+
* @param {boolean} all - Capitalize all words (default: true). If false, only capitalizes first letter
|
|
974
|
+
* @returns {string} Capitalized string
|
|
975
|
+
*/
|
|
976
|
+
capitalize(value, all = true) {
|
|
977
|
+
const str = String(value);
|
|
978
|
+
if (!str) return "";
|
|
979
|
+
if (all) {
|
|
980
|
+
return str.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
981
|
+
}
|
|
982
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Truncate string
|
|
986
|
+
* @param {*} value - String value
|
|
987
|
+
* @param {number} length - Max length
|
|
988
|
+
* @param {string} suffix - Suffix to append
|
|
989
|
+
* @returns {string} Truncated string
|
|
990
|
+
*/
|
|
991
|
+
truncate(value, length = 50, suffix = "...") {
|
|
992
|
+
const str = String(value);
|
|
993
|
+
if (str.length <= length) return str;
|
|
994
|
+
return str.substring(0, length) + suffix;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Truncate string in the middle
|
|
998
|
+
* @param {*} value - String value
|
|
999
|
+
* @param {number} size - The total number of characters to keep (half for the start, half for the end).
|
|
1000
|
+
* @param {string} replace - The character(s) to use for the middle part.
|
|
1001
|
+
* @returns {string} Truncated string
|
|
1002
|
+
*/
|
|
1003
|
+
truncate_middle(value, size = 8, replace = "***") {
|
|
1004
|
+
const str = String(value);
|
|
1005
|
+
if (str.length <= size) {
|
|
1006
|
+
return str;
|
|
1007
|
+
}
|
|
1008
|
+
const halfSize = Math.floor(size / 2);
|
|
1009
|
+
const front = str.substring(0, halfSize);
|
|
1010
|
+
const back = str.substring(str.length - halfSize);
|
|
1011
|
+
return `${front}${replace}${back}`;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Create slug from string
|
|
1015
|
+
* @param {*} value - String value
|
|
1016
|
+
* @param {string} separator - Word separator
|
|
1017
|
+
* @returns {string} Slug
|
|
1018
|
+
*/
|
|
1019
|
+
slug(value, separator = "-") {
|
|
1020
|
+
const str = String(value);
|
|
1021
|
+
return str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, separator).replace(new RegExp(`${separator}+`, "g"), separator).replace(new RegExp(`^${separator}|${separator}$`, "g"), "");
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Get initials from string
|
|
1025
|
+
* @param {*} value - String value
|
|
1026
|
+
* @param {number} count - Number of initials
|
|
1027
|
+
* @returns {string} Initials
|
|
1028
|
+
*/
|
|
1029
|
+
initials(value, count = 2) {
|
|
1030
|
+
const str = String(value);
|
|
1031
|
+
const words = str.split(/\s+/).filter((w) => w.length > 0);
|
|
1032
|
+
return words.slice(0, count).map((word) => word.charAt(0).toUpperCase()).join("");
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Mask string
|
|
1036
|
+
* @param {*} value - String value
|
|
1037
|
+
* @param {string} char - Mask character
|
|
1038
|
+
* @param {number} showLast - Number of chars to show at end
|
|
1039
|
+
* @returns {string} Masked string
|
|
1040
|
+
*/
|
|
1041
|
+
mask(value, char = "*", showLast = 4) {
|
|
1042
|
+
const str = String(value);
|
|
1043
|
+
if (str.length <= showLast) return str;
|
|
1044
|
+
const masked = char.repeat(Math.max(0, str.length - showLast));
|
|
1045
|
+
const visible = str.slice(-showLast);
|
|
1046
|
+
return masked + visible;
|
|
1047
|
+
}
|
|
1048
|
+
// ============= HTML/Web Formatters =============
|
|
1049
|
+
/**
|
|
1050
|
+
* Format email
|
|
1051
|
+
* @param {*} value - Email value
|
|
1052
|
+
* @param {Object} options - Options
|
|
1053
|
+
* @returns {string} Formatted email
|
|
1054
|
+
*/
|
|
1055
|
+
email(value, options = {}) {
|
|
1056
|
+
const email = String(value).trim();
|
|
1057
|
+
if (!email) return "";
|
|
1058
|
+
if (options.link === false) {
|
|
1059
|
+
return email;
|
|
1060
|
+
}
|
|
1061
|
+
const subject = options.subject ? `?subject=${encodeURIComponent(options.subject)}` : "";
|
|
1062
|
+
const body = options.body ? `&body=${encodeURIComponent(options.body)}` : "";
|
|
1063
|
+
const className = options.class ? ` class="${options.class}"` : "";
|
|
1064
|
+
return `<a href="mailto:${email}${subject}${body}"${className}>${email}</a>`;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Format phone number
|
|
1068
|
+
* @param {*} value - Phone value
|
|
1069
|
+
* @param {string} format - Format type
|
|
1070
|
+
* @param {boolean} link - Create tel link
|
|
1071
|
+
* @returns {string} Formatted phone
|
|
1072
|
+
*/
|
|
1073
|
+
phone(value, format = "US", link = true) {
|
|
1074
|
+
let phone = String(value).replace(/\D/g, "");
|
|
1075
|
+
let formatted = phone;
|
|
1076
|
+
if (format === "US") {
|
|
1077
|
+
if (phone.length === 10) {
|
|
1078
|
+
formatted = `(${phone.slice(0, 3)}) ${phone.slice(3, 6)}-${phone.slice(6)}`;
|
|
1079
|
+
} else if (phone.length === 11 && phone[0] === "1") {
|
|
1080
|
+
formatted = `+1 (${phone.slice(1, 4)}) ${phone.slice(4, 7)}-${phone.slice(7)}`;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (!link) {
|
|
1084
|
+
return formatted;
|
|
1085
|
+
}
|
|
1086
|
+
return `<a href="tel:${phone}">${formatted}</a>`;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Format URL
|
|
1090
|
+
* @param {*} value - URL value
|
|
1091
|
+
* @param {string} text - Link text
|
|
1092
|
+
* @param {boolean} newWindow - Open in new window
|
|
1093
|
+
* @returns {string} Formatted URL
|
|
1094
|
+
*/
|
|
1095
|
+
url(value, text = null, newWindow = true) {
|
|
1096
|
+
let url = String(value).trim();
|
|
1097
|
+
if (!url) return "";
|
|
1098
|
+
if (!/^https?:\/\//.test(url)) {
|
|
1099
|
+
url = "https://" + url;
|
|
1100
|
+
}
|
|
1101
|
+
const linkText = text || url;
|
|
1102
|
+
const target = newWindow ? ' target="_blank"' : "";
|
|
1103
|
+
const rel = newWindow ? ' rel="noopener noreferrer"' : "";
|
|
1104
|
+
return `<a href="${url}"${target}${rel}>${linkText}</a>`;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Format as badge
|
|
1108
|
+
* @param {*} value - Badge text
|
|
1109
|
+
* @param {string} type - Badge type
|
|
1110
|
+
* @returns {string} Badge HTML
|
|
1111
|
+
*/
|
|
1112
|
+
badge(value, type = "auto") {
|
|
1113
|
+
if (Array.isArray(value)) {
|
|
1114
|
+
return value.map((item) => this.badge(item, type)).join(" ");
|
|
1115
|
+
}
|
|
1116
|
+
const text = String(value);
|
|
1117
|
+
const badgeType = type === "auto" ? this.inferBadgeType(text) : type;
|
|
1118
|
+
const className = badgeType ? `bg-${badgeType}` : "bg-secondary";
|
|
1119
|
+
return `<span class="badge ${className}">${text}</span>`;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Infer badge type from text
|
|
1123
|
+
* @param {string} text - Badge text
|
|
1124
|
+
* @returns {string} Badge type
|
|
1125
|
+
*/
|
|
1126
|
+
inferBadgeType(text) {
|
|
1127
|
+
const lowered = text.toLowerCase();
|
|
1128
|
+
if (["active", "success", "complete", "completed", "approved", "done", "true", "on", "yes"].includes(lowered)) return "success";
|
|
1129
|
+
if (["error", "failed", "rejected", "deleted", "cancelled", "false", "off", "no"].includes(lowered)) return "danger";
|
|
1130
|
+
if (["warning", "pending", "review", "processing", "uploading"].includes(lowered)) return "warning";
|
|
1131
|
+
if (["info", "new", "draft"].includes(lowered)) return "info";
|
|
1132
|
+
if (["inactive", "disabled", "archived", "suspended"].includes(lowered)) return "secondary";
|
|
1133
|
+
return "secondary";
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Format status
|
|
1137
|
+
* @param {*} value - Status value
|
|
1138
|
+
* @param {Object} icons - Icon mapping
|
|
1139
|
+
* @param {Object} colors - Color mapping
|
|
1140
|
+
* @returns {string} Status HTML
|
|
1141
|
+
*/
|
|
1142
|
+
status(value, icons = {}, colors = {}) {
|
|
1143
|
+
const status = String(value).toLowerCase();
|
|
1144
|
+
const defaultIcons = {
|
|
1145
|
+
"active": "✓",
|
|
1146
|
+
"inactive": "✗",
|
|
1147
|
+
"pending": "⏳",
|
|
1148
|
+
"success": "✓",
|
|
1149
|
+
"error": "✗",
|
|
1150
|
+
"warning": "⚠"
|
|
1151
|
+
};
|
|
1152
|
+
const defaultColors = {
|
|
1153
|
+
"active": "success",
|
|
1154
|
+
"inactive": "secondary",
|
|
1155
|
+
"pending": "warning",
|
|
1156
|
+
"success": "success",
|
|
1157
|
+
"error": "danger",
|
|
1158
|
+
"warning": "warning"
|
|
1159
|
+
};
|
|
1160
|
+
const icon = icons[status] || defaultIcons[status] || "";
|
|
1161
|
+
const color = colors[status] || defaultColors[status] || "secondary";
|
|
1162
|
+
return `<span class="text-${color}">${icon}${icon ? " " : ""}${value}</span>`;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Format boolean
|
|
1166
|
+
* @param {*} value - Boolean value
|
|
1167
|
+
* @param {string} trueText - Text for true
|
|
1168
|
+
* @param {string} falseText - Text for false
|
|
1169
|
+
* @returns {string} Boolean text
|
|
1170
|
+
*/
|
|
1171
|
+
boolean(value, trueText = "True", falseText = "False") {
|
|
1172
|
+
return value ? trueText : falseText;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Format icon
|
|
1176
|
+
* @param {*} value - Icon key
|
|
1177
|
+
* @param {Object} mapping - Icon mapping
|
|
1178
|
+
* @returns {string} Icon HTML
|
|
1179
|
+
*/
|
|
1180
|
+
icon(value, mapping = {}) {
|
|
1181
|
+
const key = String(value).toLowerCase();
|
|
1182
|
+
const icon = mapping[key] || "";
|
|
1183
|
+
return icon ? `<i class="${icon}"></i>` : "";
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Format boolean as a yes/no icon
|
|
1187
|
+
* @param {*} value - Boolean value
|
|
1188
|
+
* @returns {string} Icon HTML
|
|
1189
|
+
*/
|
|
1190
|
+
yesnoicon(value) {
|
|
1191
|
+
if (value) {
|
|
1192
|
+
return '<i class="bi bi-check-circle-fill text-success"></i>';
|
|
1193
|
+
}
|
|
1194
|
+
return '<i class="bi bi-x-circle-fill text-danger"></i>';
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Format value as Bootstrap 5 image with optional rendition support
|
|
1198
|
+
* @param {string|object} value - URL string or file object with renditions
|
|
1199
|
+
* @param {string} rendition - Desired rendition (thumbnail, thumbnail_sm, etc.)
|
|
1200
|
+
* @param {string} classes - Additional CSS classes
|
|
1201
|
+
* @param {string} alt - Alt text for the image
|
|
1202
|
+
* @returns {string} Bootstrap image HTML
|
|
1203
|
+
*/
|
|
1204
|
+
image(value, rendition = "thumbnail", classes = "img-fluid", alt = "") {
|
|
1205
|
+
const url = this._extractImageUrl(value, rendition);
|
|
1206
|
+
if (!url) return "";
|
|
1207
|
+
return `<img src="${url}" class="${classes}" alt="${alt}" />`;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Format value as Bootstrap 5 avatar (circular image)
|
|
1211
|
+
* @param {string|object} value - URL string or file object with renditions
|
|
1212
|
+
* @param {string} size - Avatar size (xs, sm, md, lg, xl)
|
|
1213
|
+
* @param {string} classes - Additional CSS classes
|
|
1214
|
+
* @param {string} alt - Alt text for the avatar
|
|
1215
|
+
* @returns {string} Bootstrap avatar HTML
|
|
1216
|
+
*/
|
|
1217
|
+
avatar(value, size = "md", classes = "rounded-circle", alt = "") {
|
|
1218
|
+
const url = this._extractImageUrl(value, "square_sm") || GENERIC_AVATAR_SVG;
|
|
1219
|
+
const sizeClasses = {
|
|
1220
|
+
"xs": "width: 1.5rem; height: 1.5rem;",
|
|
1221
|
+
"sm": "width: 2rem; height: 2rem;",
|
|
1222
|
+
"md": "width: 3rem; height: 3rem;",
|
|
1223
|
+
"lg": "width: 4rem; height: 4rem;",
|
|
1224
|
+
"xl": "width: 5rem; height: 5rem;"
|
|
1225
|
+
};
|
|
1226
|
+
const sizeStyle = sizeClasses[size] || sizeClasses["md"];
|
|
1227
|
+
const baseClasses = "object-fit-cover";
|
|
1228
|
+
const allClasses = `${baseClasses} ${classes}`.trim();
|
|
1229
|
+
return `<img src="${url}" class="${allClasses}" style="${sizeStyle}" alt="${alt}" />`;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Helper method to extract image URL from string or file object
|
|
1233
|
+
* @param {string|object} value - URL string or file object with renditions
|
|
1234
|
+
* @param {string} preferredRendition - Preferred rendition name
|
|
1235
|
+
* @returns {string|null} Image URL or null if not found
|
|
1236
|
+
*/
|
|
1237
|
+
_extractImageUrl(value, preferredRendition = "thumbnail") {
|
|
1238
|
+
if (!value) return null;
|
|
1239
|
+
if (typeof value === "string") {
|
|
1240
|
+
return value;
|
|
1241
|
+
}
|
|
1242
|
+
if (typeof value === "object") {
|
|
1243
|
+
if (value.attributes) value = value.attributes;
|
|
1244
|
+
if (preferredRendition === "thumbnail" && value.thumbnail && typeof value.thumbnail === "string") {
|
|
1245
|
+
return value.thumbnail;
|
|
1246
|
+
}
|
|
1247
|
+
if (value.renditions && typeof value.renditions === "object") {
|
|
1248
|
+
const rendition = value.renditions[preferredRendition];
|
|
1249
|
+
if (rendition && rendition.url) {
|
|
1250
|
+
return rendition.url;
|
|
1251
|
+
}
|
|
1252
|
+
const availableRenditions = Object.values(value.renditions);
|
|
1253
|
+
if (availableRenditions.length > 0 && availableRenditions[0].url) {
|
|
1254
|
+
return availableRenditions[0].url;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (value.url) {
|
|
1258
|
+
return value.url;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
// ============= Utility Formatters =============
|
|
1264
|
+
/**
|
|
1265
|
+
* Apply default value
|
|
1266
|
+
* @param {*} value - Value
|
|
1267
|
+
* @param {*} defaultValue - Default value
|
|
1268
|
+
* @returns {*} Value or default
|
|
1269
|
+
*/
|
|
1270
|
+
default(value, defaultValue = "") {
|
|
1271
|
+
return value === null || value === void 0 || value === "" ? defaultValue : value;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Format as JSON
|
|
1275
|
+
* @param {*} value - Value to stringify
|
|
1276
|
+
* @param {number} indent - Indentation
|
|
1277
|
+
* @returns {string} JSON string
|
|
1278
|
+
*/
|
|
1279
|
+
/**
|
|
1280
|
+
* Format pluralization based on count
|
|
1281
|
+
* @param {number} count - The count value
|
|
1282
|
+
* @param {string} singular - Singular form of the word
|
|
1283
|
+
* @param {string|null} plural - Plural form (defaults to singular + 's')
|
|
1284
|
+
* @param {boolean} includeCount - Whether to include the count in output
|
|
1285
|
+
* @returns {string} Formatted plural string
|
|
1286
|
+
*/
|
|
1287
|
+
plural(count, singular, plural = null, includeCount = true) {
|
|
1288
|
+
if (count === null || count === void 0 || singular === null || singular === void 0) {
|
|
1289
|
+
return includeCount ? `${count} ${singular}` : singular || "";
|
|
1290
|
+
}
|
|
1291
|
+
const num = parseInt(count);
|
|
1292
|
+
if (isNaN(num)) {
|
|
1293
|
+
return includeCount ? `${count} ${singular}` : singular || "";
|
|
1294
|
+
}
|
|
1295
|
+
const word = Math.abs(num) === 1 ? singular : plural || singular + "s";
|
|
1296
|
+
return includeCount ? `${num} ${word}` : word;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Format array as a human-readable list
|
|
1300
|
+
* @param {Array} array - Array to format
|
|
1301
|
+
* @param {Object} options - Formatting options
|
|
1302
|
+
* @returns {string} Formatted list string
|
|
1303
|
+
*/
|
|
1304
|
+
formatList(array, options = {}) {
|
|
1305
|
+
if (!Array.isArray(array)) {
|
|
1306
|
+
return String(array);
|
|
1307
|
+
}
|
|
1308
|
+
const { conjunction = "and", limit = null, moreText = "others" } = options;
|
|
1309
|
+
if (array.length === 0) return "";
|
|
1310
|
+
if (array.length === 1) return String(array[0]);
|
|
1311
|
+
let items = array.slice();
|
|
1312
|
+
let hasMore = false;
|
|
1313
|
+
if (limit && array.length > limit) {
|
|
1314
|
+
items = array.slice(0, limit);
|
|
1315
|
+
hasMore = true;
|
|
1316
|
+
}
|
|
1317
|
+
if (hasMore) {
|
|
1318
|
+
const remaining = array.length - limit;
|
|
1319
|
+
return `${items.join(", ")}, ${conjunction} ${remaining} ${moreText}`;
|
|
1320
|
+
}
|
|
1321
|
+
if (items.length === 2) {
|
|
1322
|
+
return `${items[0]} ${conjunction} ${items[1]}`;
|
|
1323
|
+
}
|
|
1324
|
+
return `${items.slice(0, -1).join(", ")}, ${conjunction} ${items[items.length - 1]}`;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Format duration in milliseconds to human-readable format
|
|
1328
|
+
* @param {number} milliseconds - Duration in milliseconds
|
|
1329
|
+
* @param {Object} options - Formatting options
|
|
1330
|
+
* @returns {string} Formatted duration string
|
|
1331
|
+
*/
|
|
1332
|
+
duration(milliseconds, options = {}) {
|
|
1333
|
+
const { short = false, precision = 2 } = options;
|
|
1334
|
+
if (milliseconds === null || milliseconds === void 0) return "";
|
|
1335
|
+
const ms = parseInt(milliseconds);
|
|
1336
|
+
if (isNaN(ms)) return String(milliseconds);
|
|
1337
|
+
const units = [
|
|
1338
|
+
{ name: "day", short: "d", value: 864e5 },
|
|
1339
|
+
{ name: "hour", short: "h", value: 36e5 },
|
|
1340
|
+
{ name: "minute", short: "m", value: 6e4 },
|
|
1341
|
+
{ name: "second", short: "s", value: 1e3 }
|
|
1342
|
+
];
|
|
1343
|
+
if (ms === 0) return short ? "0s" : "0 seconds";
|
|
1344
|
+
const absMs = Math.abs(ms);
|
|
1345
|
+
const sign = ms < 0 ? "-" : "";
|
|
1346
|
+
const parts = [];
|
|
1347
|
+
let remaining = absMs;
|
|
1348
|
+
for (const unit of units) {
|
|
1349
|
+
if (remaining >= unit.value) {
|
|
1350
|
+
const count = Math.floor(remaining / unit.value);
|
|
1351
|
+
remaining = remaining % unit.value;
|
|
1352
|
+
const unitName = short ? unit.short : count === 1 ? unit.name : unit.name + "s";
|
|
1353
|
+
parts.push(short ? `${count}${unitName}` : `${count} ${unitName}`);
|
|
1354
|
+
if (parts.length >= precision) break;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (parts.length === 0) {
|
|
1358
|
+
return short ? `${Math.round(absMs)}ms` : `${Math.round(absMs)} milliseconds`;
|
|
1359
|
+
}
|
|
1360
|
+
return sign + (short ? parts.join("") : parts.join(" "));
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Format long strings/IDs with truncation
|
|
1364
|
+
* @param {string} value - Value to format
|
|
1365
|
+
* @param {number} length - Maximum length before truncation
|
|
1366
|
+
* @param {string} prefix - Prefix to add
|
|
1367
|
+
* @param {string} suffix - Suffix for truncated strings
|
|
1368
|
+
* @returns {string} Formatted hash string
|
|
1369
|
+
*/
|
|
1370
|
+
hash(value, length = 8, prefix = "", suffix = "...") {
|
|
1371
|
+
if (value === null || value === void 0) return "";
|
|
1372
|
+
const str = String(value);
|
|
1373
|
+
if (str.length <= length) return prefix + str;
|
|
1374
|
+
return prefix + str.substring(0, length) + suffix;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Strip HTML tags from text
|
|
1378
|
+
* @param {string} html - HTML string to strip
|
|
1379
|
+
* @returns {string} Plain text without HTML tags
|
|
1380
|
+
*/
|
|
1381
|
+
stripHtml(html) {
|
|
1382
|
+
if (html === null || html === void 0) return "";
|
|
1383
|
+
return String(html).replace(/<[^>]*>/g, "");
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Highlight search terms in text
|
|
1387
|
+
* @param {string} text - Text to search in
|
|
1388
|
+
* @param {string} searchTerm - Term to highlight
|
|
1389
|
+
* @param {string} className - CSS class for highlighting
|
|
1390
|
+
* @returns {string} Text with highlighted terms
|
|
1391
|
+
*/
|
|
1392
|
+
highlight(text, searchTerm, className = "highlight") {
|
|
1393
|
+
if (text === null || text === void 0 || !searchTerm) {
|
|
1394
|
+
return String(text || "");
|
|
1395
|
+
}
|
|
1396
|
+
const escapedTerm = String(searchTerm).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1397
|
+
const regex = new RegExp(`(${escapedTerm})`, "gi");
|
|
1398
|
+
return String(text).replace(regex, `<mark class="${className}">$1</mark>`);
|
|
1399
|
+
}
|
|
1400
|
+
json(value, indent = 2) {
|
|
1401
|
+
try {
|
|
1402
|
+
return JSON.stringify(value, null, indent);
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
return String(value);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Check if formatter exists
|
|
1409
|
+
* @param {string} name - Formatter name
|
|
1410
|
+
* @returns {boolean} True if exists
|
|
1411
|
+
*/
|
|
1412
|
+
has(name) {
|
|
1413
|
+
return this.formatters.has(name.toLowerCase());
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Remove a formatter
|
|
1417
|
+
* @param {string} name - Formatter name
|
|
1418
|
+
* @returns {boolean} True if removed
|
|
1419
|
+
*/
|
|
1420
|
+
unregister(name) {
|
|
1421
|
+
return this.formatters.delete(name.toLowerCase());
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Get all formatter names
|
|
1425
|
+
* @returns {Array} Formatter names
|
|
1426
|
+
*/
|
|
1427
|
+
listFormatters() {
|
|
1428
|
+
return Array.from(this.formatters.keys()).sort();
|
|
1429
|
+
}
|
|
1430
|
+
iter(v) {
|
|
1431
|
+
if (v === null || v === void 0) {
|
|
1432
|
+
return [];
|
|
1433
|
+
}
|
|
1434
|
+
if (Array.isArray(v)) {
|
|
1435
|
+
return v;
|
|
1436
|
+
}
|
|
1437
|
+
if (typeof v === "object") {
|
|
1438
|
+
return Object.entries(v).map(([key, value]) => ({
|
|
1439
|
+
key,
|
|
1440
|
+
value
|
|
1441
|
+
}));
|
|
1442
|
+
}
|
|
1443
|
+
return [{ key: "0", value: v }];
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
const dataFormatter = new DataFormatter();
|
|
1447
|
+
window.dataFormatter = dataFormatter;
|
|
1448
|
+
class MOJOUtils {
|
|
1449
|
+
/**
|
|
1450
|
+
* Get data from context with support for:
|
|
1451
|
+
* - Dot notation (e.g., "user.name")
|
|
1452
|
+
* - Pipe formatting (e.g., "name|uppercase")
|
|
1453
|
+
* - Combined (e.g., "user.name|uppercase|truncate(10)")
|
|
1454
|
+
*
|
|
1455
|
+
* @param {object} context - The data context to search in
|
|
1456
|
+
* @param {string} key - The key path with optional pipes
|
|
1457
|
+
* @returns {*} The value, possibly formatted
|
|
1458
|
+
*/
|
|
1459
|
+
static getContextData(context, key) {
|
|
1460
|
+
if (!key || context == null) {
|
|
1461
|
+
return void 0;
|
|
1462
|
+
}
|
|
1463
|
+
let field = key;
|
|
1464
|
+
let pipes = "";
|
|
1465
|
+
let parenDepth = 0;
|
|
1466
|
+
let pipeIndex = -1;
|
|
1467
|
+
for (let i = 0; i < key.length; i++) {
|
|
1468
|
+
const char = key[i];
|
|
1469
|
+
if (char === "(") parenDepth++;
|
|
1470
|
+
else if (char === ")") parenDepth--;
|
|
1471
|
+
else if (char === "|" && parenDepth === 0) {
|
|
1472
|
+
pipeIndex = i;
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (pipeIndex > -1) {
|
|
1477
|
+
field = key.substring(0, pipeIndex).trim();
|
|
1478
|
+
pipes = key.substring(pipeIndex + 1).trim();
|
|
1479
|
+
}
|
|
1480
|
+
const value = this.getNestedValue(context, field);
|
|
1481
|
+
if (pipes) {
|
|
1482
|
+
return dataFormatter.pipe(value, pipes);
|
|
1483
|
+
}
|
|
1484
|
+
return value;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Get nested value from object using dot notation
|
|
1488
|
+
* IMPORTANT: Never calls get() on the top-level context to avoid recursion
|
|
1489
|
+
* But DOES call get() on nested objects if they have that method
|
|
1490
|
+
*
|
|
1491
|
+
* @param {object} context - The object to search in
|
|
1492
|
+
* @param {string} path - Dot notation path
|
|
1493
|
+
* @returns {*} The value at the path
|
|
1494
|
+
*/
|
|
1495
|
+
static getNestedValue(context, path) {
|
|
1496
|
+
if (!path || context == null) {
|
|
1497
|
+
return void 0;
|
|
1498
|
+
}
|
|
1499
|
+
if (!path.includes(".")) {
|
|
1500
|
+
if (path in context) {
|
|
1501
|
+
const value = context[path];
|
|
1502
|
+
if (typeof value === "function") {
|
|
1503
|
+
return value.call(context);
|
|
1504
|
+
}
|
|
1505
|
+
return value;
|
|
1506
|
+
}
|
|
1507
|
+
return void 0;
|
|
1508
|
+
}
|
|
1509
|
+
const keys = path.split(".");
|
|
1510
|
+
let current = context;
|
|
1511
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1512
|
+
const key = keys[i];
|
|
1513
|
+
if (current == null) {
|
|
1514
|
+
return void 0;
|
|
1515
|
+
}
|
|
1516
|
+
if (i === 0) {
|
|
1517
|
+
if (current.hasOwnProperty(key)) {
|
|
1518
|
+
const value = current[key];
|
|
1519
|
+
if (typeof value === "function") {
|
|
1520
|
+
current = value.call(context);
|
|
1521
|
+
} else {
|
|
1522
|
+
current = value;
|
|
1523
|
+
}
|
|
1524
|
+
} else {
|
|
1525
|
+
return void 0;
|
|
1526
|
+
}
|
|
1527
|
+
} else {
|
|
1528
|
+
if (current && typeof current.getContextValue === "function") {
|
|
1529
|
+
const remainingPath = keys.slice(i).join(".");
|
|
1530
|
+
return current.getContextValue(remainingPath);
|
|
1531
|
+
}
|
|
1532
|
+
if (Array.isArray(current) && !isNaN(key)) {
|
|
1533
|
+
current = current[parseInt(key)];
|
|
1534
|
+
} else if (current.hasOwnProperty(key)) {
|
|
1535
|
+
current = current[key];
|
|
1536
|
+
} else if (typeof current[key] === "function") {
|
|
1537
|
+
current = current[key].call(current);
|
|
1538
|
+
} else {
|
|
1539
|
+
return void 0;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return current;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Check if a value is null or undefined
|
|
1547
|
+
* @param {*} value - Value to check
|
|
1548
|
+
* @returns {boolean} True if null or undefined
|
|
1549
|
+
*/
|
|
1550
|
+
static isNullOrUndefined(value) {
|
|
1551
|
+
return value === null || value === void 0;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Deep clone an object
|
|
1555
|
+
* @param {*} obj - Object to clone
|
|
1556
|
+
* @returns {*} Cloned object
|
|
1557
|
+
*/
|
|
1558
|
+
static deepClone(obj) {
|
|
1559
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
1560
|
+
if (obj instanceof Date) return new Date(obj.getTime());
|
|
1561
|
+
if (obj instanceof Array) return obj.map((item) => this.deepClone(item));
|
|
1562
|
+
if (obj instanceof Object) {
|
|
1563
|
+
const clonedObj = {};
|
|
1564
|
+
for (const key in obj) {
|
|
1565
|
+
if (obj.hasOwnProperty(key)) {
|
|
1566
|
+
clonedObj[key] = this.deepClone(obj[key]);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return clonedObj;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Merge objects deeply
|
|
1574
|
+
* @param {object} target - Target object
|
|
1575
|
+
* @param {...object} sources - Source objects to merge
|
|
1576
|
+
* @returns {object} Merged object
|
|
1577
|
+
*/
|
|
1578
|
+
static deepMerge(target, ...sources) {
|
|
1579
|
+
if (!sources.length) return target;
|
|
1580
|
+
const source = sources.shift();
|
|
1581
|
+
if (this.isObject(target) && this.isObject(source)) {
|
|
1582
|
+
for (const key in source) {
|
|
1583
|
+
if (this.isObject(source[key])) {
|
|
1584
|
+
if (!target[key]) Object.assign(target, { [key]: {} });
|
|
1585
|
+
this.deepMerge(target[key], source[key]);
|
|
1586
|
+
} else {
|
|
1587
|
+
Object.assign(target, { [key]: source[key] });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return this.deepMerge(target, ...sources);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Check if value is a plain object
|
|
1595
|
+
* @param {*} item - Value to check
|
|
1596
|
+
* @returns {boolean} True if plain object
|
|
1597
|
+
*/
|
|
1598
|
+
static isObject(item) {
|
|
1599
|
+
return item && typeof item === "object" && !Array.isArray(item);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Debounce function calls
|
|
1603
|
+
* @param {function} func - Function to debounce
|
|
1604
|
+
* @param {number} wait - Wait time in milliseconds
|
|
1605
|
+
* @returns {function} Debounced function
|
|
1606
|
+
*/
|
|
1607
|
+
static debounce(func, wait) {
|
|
1608
|
+
let timeout;
|
|
1609
|
+
return function executedFunction(...args) {
|
|
1610
|
+
const later = () => {
|
|
1611
|
+
clearTimeout(timeout);
|
|
1612
|
+
func(...args);
|
|
1613
|
+
};
|
|
1614
|
+
clearTimeout(timeout);
|
|
1615
|
+
timeout = setTimeout(later, wait);
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Throttle function calls
|
|
1620
|
+
* @param {function} func - Function to throttle
|
|
1621
|
+
* @param {number} limit - Time limit in milliseconds
|
|
1622
|
+
* @returns {function} Throttled function
|
|
1623
|
+
*/
|
|
1624
|
+
static throttle(func, limit) {
|
|
1625
|
+
let inThrottle;
|
|
1626
|
+
return function(...args) {
|
|
1627
|
+
if (!inThrottle) {
|
|
1628
|
+
func.apply(this, args);
|
|
1629
|
+
inThrottle = true;
|
|
1630
|
+
setTimeout(() => inThrottle = false, limit);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Generate a unique ID
|
|
1636
|
+
* @param {string} prefix - Optional prefix for the ID
|
|
1637
|
+
* @returns {string} Unique ID
|
|
1638
|
+
*/
|
|
1639
|
+
static generateId(prefix = "") {
|
|
1640
|
+
const timestamp = Date.now().toString(36);
|
|
1641
|
+
const randomStr = Math.random().toString(36).substr(2, 9);
|
|
1642
|
+
return prefix ? `${prefix}_${timestamp}_${randomStr}` : `${timestamp}_${randomStr}`;
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Escape HTML special characters
|
|
1646
|
+
* @param {string} str - String to escape
|
|
1647
|
+
* @returns {string} Escaped string
|
|
1648
|
+
*/
|
|
1649
|
+
static escapeHtml(str) {
|
|
1650
|
+
const entityMap = {
|
|
1651
|
+
"&": "&",
|
|
1652
|
+
"<": "<",
|
|
1653
|
+
">": ">",
|
|
1654
|
+
'"': """,
|
|
1655
|
+
"'": "'",
|
|
1656
|
+
"/": "/",
|
|
1657
|
+
"`": "`",
|
|
1658
|
+
"=": "="
|
|
1659
|
+
};
|
|
1660
|
+
return String(str).replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Check password strength and provide detailed feedback
|
|
1664
|
+
* @param {string} password - Password to check
|
|
1665
|
+
* @returns {object} Password strength analysis
|
|
1666
|
+
*/
|
|
1667
|
+
static checkPasswordStrength(password) {
|
|
1668
|
+
if (!password || typeof password !== "string") {
|
|
1669
|
+
return {
|
|
1670
|
+
score: 0,
|
|
1671
|
+
strength: "invalid",
|
|
1672
|
+
feedback: ["Password must be a non-empty string"],
|
|
1673
|
+
details: {
|
|
1674
|
+
length: 0,
|
|
1675
|
+
hasLowercase: false,
|
|
1676
|
+
hasUppercase: false,
|
|
1677
|
+
hasNumbers: false,
|
|
1678
|
+
hasSpecialChars: false,
|
|
1679
|
+
hasCommonPatterns: false,
|
|
1680
|
+
isCommonPassword: false
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
const feedback = [];
|
|
1685
|
+
const details = {
|
|
1686
|
+
length: password.length,
|
|
1687
|
+
hasLowercase: /[a-z]/.test(password),
|
|
1688
|
+
hasUppercase: /[A-Z]/.test(password),
|
|
1689
|
+
hasNumbers: /[0-9]/.test(password),
|
|
1690
|
+
hasSpecialChars: /[^a-zA-Z0-9]/.test(password),
|
|
1691
|
+
hasCommonPatterns: false,
|
|
1692
|
+
isCommonPassword: false
|
|
1693
|
+
};
|
|
1694
|
+
let score = 0;
|
|
1695
|
+
if (password.length < 6) {
|
|
1696
|
+
feedback.push("Password should be at least 6 characters long");
|
|
1697
|
+
} else if (password.length < 8) {
|
|
1698
|
+
score += 1;
|
|
1699
|
+
feedback.push("Consider using at least 8 characters for better security");
|
|
1700
|
+
} else if (password.length < 12) {
|
|
1701
|
+
score += 2;
|
|
1702
|
+
} else {
|
|
1703
|
+
score += 3;
|
|
1704
|
+
}
|
|
1705
|
+
if (details.hasLowercase) score += 1;
|
|
1706
|
+
else feedback.push("Include lowercase letters");
|
|
1707
|
+
if (details.hasUppercase) score += 1;
|
|
1708
|
+
else feedback.push("Include uppercase letters");
|
|
1709
|
+
if (details.hasNumbers) score += 1;
|
|
1710
|
+
else feedback.push("Include numbers");
|
|
1711
|
+
if (details.hasSpecialChars) score += 2;
|
|
1712
|
+
else feedback.push("Include special characters (!@#$%^&* etc.)");
|
|
1713
|
+
const commonPatterns = [
|
|
1714
|
+
/123/,
|
|
1715
|
+
// Sequential numbers
|
|
1716
|
+
/abc/i,
|
|
1717
|
+
// Sequential letters
|
|
1718
|
+
/qwerty/i,
|
|
1719
|
+
// Keyboard patterns
|
|
1720
|
+
/asdf/i,
|
|
1721
|
+
// Keyboard patterns
|
|
1722
|
+
/(.)\1{2,}/,
|
|
1723
|
+
// Repeated characters (aaa, 111)
|
|
1724
|
+
/password/i,
|
|
1725
|
+
// Contains "password"
|
|
1726
|
+
/admin/i,
|
|
1727
|
+
// Contains "admin"
|
|
1728
|
+
/user/i,
|
|
1729
|
+
// Contains "user"
|
|
1730
|
+
/login/i
|
|
1731
|
+
// Contains "login"
|
|
1732
|
+
];
|
|
1733
|
+
for (const pattern of commonPatterns) {
|
|
1734
|
+
if (pattern.test(password)) {
|
|
1735
|
+
details.hasCommonPatterns = true;
|
|
1736
|
+
score -= 1;
|
|
1737
|
+
feedback.push("Avoid common patterns and dictionary words");
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const commonPasswords = [
|
|
1742
|
+
"123456",
|
|
1743
|
+
"password",
|
|
1744
|
+
"123456789",
|
|
1745
|
+
"12345678",
|
|
1746
|
+
"12345",
|
|
1747
|
+
"1234567",
|
|
1748
|
+
"1234567890",
|
|
1749
|
+
"qwerty",
|
|
1750
|
+
"abc123",
|
|
1751
|
+
"111111",
|
|
1752
|
+
"123123",
|
|
1753
|
+
"admin",
|
|
1754
|
+
"letmein",
|
|
1755
|
+
"welcome",
|
|
1756
|
+
"monkey",
|
|
1757
|
+
"password123",
|
|
1758
|
+
"123qwe",
|
|
1759
|
+
"qwerty123",
|
|
1760
|
+
"000000",
|
|
1761
|
+
"dragon",
|
|
1762
|
+
"sunshine",
|
|
1763
|
+
"princess",
|
|
1764
|
+
"azerty",
|
|
1765
|
+
"1234",
|
|
1766
|
+
"iloveyou",
|
|
1767
|
+
"trustno1",
|
|
1768
|
+
"superman",
|
|
1769
|
+
"shadow",
|
|
1770
|
+
"master",
|
|
1771
|
+
"jennifer"
|
|
1772
|
+
];
|
|
1773
|
+
if (commonPasswords.includes(password.toLowerCase())) {
|
|
1774
|
+
details.isCommonPassword = true;
|
|
1775
|
+
score = Math.max(0, score - 3);
|
|
1776
|
+
feedback.push("This password is too common and easily guessed");
|
|
1777
|
+
}
|
|
1778
|
+
let strength;
|
|
1779
|
+
if (score < 2) {
|
|
1780
|
+
strength = "very-weak";
|
|
1781
|
+
} else if (score < 4) {
|
|
1782
|
+
strength = "weak";
|
|
1783
|
+
} else if (score < 6) {
|
|
1784
|
+
strength = "fair";
|
|
1785
|
+
} else if (score < 8) {
|
|
1786
|
+
strength = "good";
|
|
1787
|
+
} else {
|
|
1788
|
+
strength = "strong";
|
|
1789
|
+
}
|
|
1790
|
+
if (score >= 7 && feedback.length === 0) {
|
|
1791
|
+
feedback.push("Strong password! Consider using a password manager.");
|
|
1792
|
+
} else if (score >= 5 && feedback.length <= 1) {
|
|
1793
|
+
feedback.push("Good password strength. Consider adding more variety.");
|
|
1794
|
+
}
|
|
1795
|
+
return {
|
|
1796
|
+
score: Math.max(0, score),
|
|
1797
|
+
strength,
|
|
1798
|
+
feedback,
|
|
1799
|
+
details
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Generate a secure password with customizable options
|
|
1804
|
+
* @param {object} options - Password generation options
|
|
1805
|
+
* @param {number} options.length - Password length (default: 12)
|
|
1806
|
+
* @param {boolean} options.includeLowercase - Include lowercase letters (default: true)
|
|
1807
|
+
* @param {boolean} options.includeUppercase - Include uppercase letters (default: true)
|
|
1808
|
+
* @param {boolean} options.includeNumbers - Include numbers (default: true)
|
|
1809
|
+
* @param {boolean} options.includeSpecialChars - Include special characters (default: true)
|
|
1810
|
+
* @param {string} options.customChars - Custom character set to use
|
|
1811
|
+
* @param {boolean} options.excludeAmbiguous - Exclude ambiguous characters like 0, O, l, I (default: false)
|
|
1812
|
+
* @returns {string} Generated password
|
|
1813
|
+
*/
|
|
1814
|
+
static generatePassword(options = {}) {
|
|
1815
|
+
const defaults = {
|
|
1816
|
+
length: 12,
|
|
1817
|
+
includeLowercase: true,
|
|
1818
|
+
includeUppercase: true,
|
|
1819
|
+
includeNumbers: true,
|
|
1820
|
+
includeSpecialChars: true,
|
|
1821
|
+
customChars: "",
|
|
1822
|
+
excludeAmbiguous: false
|
|
1823
|
+
};
|
|
1824
|
+
const config = { ...defaults, ...options };
|
|
1825
|
+
if (config.length < 4) {
|
|
1826
|
+
throw new Error("Password length must be at least 4 characters");
|
|
1827
|
+
}
|
|
1828
|
+
let lowercase = "abcdefghijklmnopqrstuvwxyz";
|
|
1829
|
+
let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
1830
|
+
let numbers = "0123456789";
|
|
1831
|
+
let specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
|
1832
|
+
if (config.excludeAmbiguous) {
|
|
1833
|
+
lowercase = lowercase.replace(/[il]/g, "");
|
|
1834
|
+
uppercase = uppercase.replace(/[IOL]/g, "");
|
|
1835
|
+
numbers = numbers.replace(/[01]/g, "");
|
|
1836
|
+
specialChars = specialChars.replace(/[|]/g, "");
|
|
1837
|
+
}
|
|
1838
|
+
let charPool = "";
|
|
1839
|
+
const requiredChars = [];
|
|
1840
|
+
if (config.customChars) {
|
|
1841
|
+
charPool = config.customChars;
|
|
1842
|
+
} else {
|
|
1843
|
+
if (config.includeLowercase) {
|
|
1844
|
+
charPool += lowercase;
|
|
1845
|
+
requiredChars.push(lowercase[Math.floor(Math.random() * lowercase.length)]);
|
|
1846
|
+
}
|
|
1847
|
+
if (config.includeUppercase) {
|
|
1848
|
+
charPool += uppercase;
|
|
1849
|
+
requiredChars.push(uppercase[Math.floor(Math.random() * uppercase.length)]);
|
|
1850
|
+
}
|
|
1851
|
+
if (config.includeNumbers) {
|
|
1852
|
+
charPool += numbers;
|
|
1853
|
+
requiredChars.push(numbers[Math.floor(Math.random() * numbers.length)]);
|
|
1854
|
+
}
|
|
1855
|
+
if (config.includeSpecialChars) {
|
|
1856
|
+
charPool += specialChars;
|
|
1857
|
+
requiredChars.push(specialChars[Math.floor(Math.random() * specialChars.length)]);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (!charPool) {
|
|
1861
|
+
throw new Error("No character types selected for password generation");
|
|
1862
|
+
}
|
|
1863
|
+
let password = "";
|
|
1864
|
+
for (const char of requiredChars) {
|
|
1865
|
+
password += char;
|
|
1866
|
+
}
|
|
1867
|
+
for (let i = password.length; i < config.length; i++) {
|
|
1868
|
+
password += charPool[Math.floor(Math.random() * charPool.length)];
|
|
1869
|
+
}
|
|
1870
|
+
return password.split("").sort(() => Math.random() - 0.5).join("");
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Parse query string into object
|
|
1874
|
+
* @param {string} queryString - Query string to parse
|
|
1875
|
+
* @returns {object} Parsed query parameters
|
|
1876
|
+
*/
|
|
1877
|
+
static parseQueryString(queryString) {
|
|
1878
|
+
const params = {};
|
|
1879
|
+
const searchParams = new URLSearchParams(queryString);
|
|
1880
|
+
for (const [key, value] of searchParams.entries()) {
|
|
1881
|
+
params[key] = value;
|
|
1882
|
+
}
|
|
1883
|
+
return params;
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Convert object to query string
|
|
1887
|
+
* @param {object} params - Parameters object
|
|
1888
|
+
* @returns {string} Query string
|
|
1889
|
+
*/
|
|
1890
|
+
static toQueryString(params) {
|
|
1891
|
+
return new URLSearchParams(params).toString();
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Wrap data objects to provide get() method support
|
|
1895
|
+
* This ensures pipe formatting works in all contexts
|
|
1896
|
+
* @param {*} data - Data to wrap
|
|
1897
|
+
* @param {object} rootContext - Optional root context for nested access
|
|
1898
|
+
* @returns {*} Wrapped data if object/array, otherwise original
|
|
1899
|
+
*/
|
|
1900
|
+
static wrapData(data, rootContext = null, depth = 3) {
|
|
1901
|
+
if (!data || typeof data !== "object") {
|
|
1902
|
+
return data;
|
|
1903
|
+
}
|
|
1904
|
+
if (data instanceof Date || data instanceof RegExp || data instanceof Error) {
|
|
1905
|
+
return data;
|
|
1906
|
+
}
|
|
1907
|
+
if (depth <= 0) {
|
|
1908
|
+
return data;
|
|
1909
|
+
}
|
|
1910
|
+
if (typeof data.getContextValue === "function") {
|
|
1911
|
+
return data;
|
|
1912
|
+
}
|
|
1913
|
+
if (Array.isArray(data)) {
|
|
1914
|
+
return data.map((item) => {
|
|
1915
|
+
if (item && typeof item === "object" && !item.getContextValue) {
|
|
1916
|
+
return new DataWrapper(item, rootContext);
|
|
1917
|
+
}
|
|
1918
|
+
return item;
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
return new DataWrapper(data, rootContext);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
class DataWrapper {
|
|
1925
|
+
constructor(data, rootContext = null) {
|
|
1926
|
+
Object.defineProperty(this, "_data", {
|
|
1927
|
+
value: data,
|
|
1928
|
+
writable: false,
|
|
1929
|
+
enumerable: false,
|
|
1930
|
+
configurable: false
|
|
1931
|
+
});
|
|
1932
|
+
Object.defineProperty(this, "_rootContext", {
|
|
1933
|
+
value: rootContext,
|
|
1934
|
+
writable: false,
|
|
1935
|
+
enumerable: false,
|
|
1936
|
+
configurable: false
|
|
1937
|
+
});
|
|
1938
|
+
if (data && typeof data === "object") {
|
|
1939
|
+
for (const key in data) {
|
|
1940
|
+
if (data.hasOwnProperty(key)) {
|
|
1941
|
+
const value = data[key];
|
|
1942
|
+
this[key] = MOJOUtils.wrapData(value, rootContext);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Get value with pipe support
|
|
1949
|
+
* @param {string} key - Key with optional pipes
|
|
1950
|
+
* @returns {*} Value, possibly formatted
|
|
1951
|
+
*/
|
|
1952
|
+
getContextValue(key) {
|
|
1953
|
+
let field = key;
|
|
1954
|
+
let pipes = "";
|
|
1955
|
+
let parenDepth = 0;
|
|
1956
|
+
let pipeIndex = -1;
|
|
1957
|
+
for (let i = 0; i < key.length; i++) {
|
|
1958
|
+
const char = key[i];
|
|
1959
|
+
if (char === "(") parenDepth++;
|
|
1960
|
+
else if (char === ")") parenDepth--;
|
|
1961
|
+
else if (char === "|" && parenDepth === 0) {
|
|
1962
|
+
pipeIndex = i;
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
if (pipeIndex > -1) {
|
|
1967
|
+
field = key.substring(0, pipeIndex).trim();
|
|
1968
|
+
pipes = key.substring(pipeIndex + 1).trim();
|
|
1969
|
+
}
|
|
1970
|
+
let value;
|
|
1971
|
+
if (this._data && this._data.hasOwnProperty(field)) {
|
|
1972
|
+
if (field in this && field !== "_data" && field !== "_rootContext") {
|
|
1973
|
+
value = this[field];
|
|
1974
|
+
} else {
|
|
1975
|
+
value = MOJOUtils.getNestedValue(this._data, field);
|
|
1976
|
+
}
|
|
1977
|
+
} else {
|
|
1978
|
+
value = void 0;
|
|
1979
|
+
}
|
|
1980
|
+
if (pipes && value !== void 0) {
|
|
1981
|
+
return dataFormatter.pipe(value, pipes);
|
|
1982
|
+
}
|
|
1983
|
+
return value;
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Check if wrapper has a property
|
|
1987
|
+
* @param {string} key - Property key
|
|
1988
|
+
* @returns {boolean} True if property exists
|
|
1989
|
+
*/
|
|
1990
|
+
has(key) {
|
|
1991
|
+
return this._data && this._data.hasOwnProperty(key);
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Get the raw wrapped data
|
|
1995
|
+
* @returns {object} The original data object
|
|
1996
|
+
*/
|
|
1997
|
+
toJSON() {
|
|
1998
|
+
return this._data;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
MOJOUtils.DataWrapper = DataWrapper;
|
|
2002
|
+
if (typeof window !== "undefined") {
|
|
2003
|
+
window.utils = MOJOUtils;
|
|
2004
|
+
}
|
|
2005
|
+
class EventDelegate {
|
|
2006
|
+
constructor(view) {
|
|
2007
|
+
this.view = view;
|
|
2008
|
+
this.domListeners = [];
|
|
2009
|
+
this.debounceTimers = /* @__PURE__ */ new Map();
|
|
2010
|
+
}
|
|
2011
|
+
bind(rootEl) {
|
|
2012
|
+
this.unbind();
|
|
2013
|
+
if (!rootEl) return;
|
|
2014
|
+
const onClick = async (event) => {
|
|
2015
|
+
const actionEl = event.target.closest("[data-action]");
|
|
2016
|
+
if (actionEl && this.shouldHandle(actionEl, event)) {
|
|
2017
|
+
const action = actionEl.getAttribute("data-action");
|
|
2018
|
+
const handled = await this.dispatch(action, event, actionEl);
|
|
2019
|
+
if (handled) {
|
|
2020
|
+
event.preventDefault();
|
|
2021
|
+
event.stopPropagation();
|
|
2022
|
+
event.handledByChild = true;
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const navEl = event.target.closest("a[href], [data-page]");
|
|
2027
|
+
if (navEl && !navEl.hasAttribute("data-action") && this.shouldHandle(navEl, event)) {
|
|
2028
|
+
if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) return;
|
|
2029
|
+
if (navEl.tagName === "A") {
|
|
2030
|
+
const href = navEl.getAttribute("href");
|
|
2031
|
+
if (href && href !== "#" && !href.startsWith("#") && (this.view.isExternalLink(href) || navEl.hasAttribute("data-external"))) return;
|
|
2032
|
+
}
|
|
2033
|
+
event.preventDefault();
|
|
2034
|
+
event.stopPropagation();
|
|
2035
|
+
event.handledByChild = true;
|
|
2036
|
+
if (navEl.hasAttribute("data-page")) await this.view.handlePageNavigation(navEl);
|
|
2037
|
+
else await this.view.handleHrefNavigation(navEl);
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
const onChange = (event) => {
|
|
2041
|
+
const el = event.target.closest("[data-change-action]");
|
|
2042
|
+
if (!el || !this.shouldHandle(el, event)) return;
|
|
2043
|
+
const action = el.getAttribute("data-change-action");
|
|
2044
|
+
this.dispatchChange(action, event, el).then((handled) => {
|
|
2045
|
+
if (handled) {
|
|
2046
|
+
event.stopPropagation();
|
|
2047
|
+
event.handledByChild = true;
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
};
|
|
2051
|
+
const onInput = (event) => {
|
|
2052
|
+
const el = event.target.closest("[data-change-action]");
|
|
2053
|
+
if (!el || !this.shouldHandle(el, event)) return;
|
|
2054
|
+
const liveSearch = event.target.matches('[data-filter="live-search"]');
|
|
2055
|
+
if (!liveSearch) return;
|
|
2056
|
+
const action = el.getAttribute("data-change-action");
|
|
2057
|
+
const debounceMs = parseInt(el.getAttribute("data-filter-debounce")) || 300;
|
|
2058
|
+
const timerId = `${action}-${el.getAttribute("data-container") || "default"}`;
|
|
2059
|
+
if (this.debounceTimers.has(timerId)) {
|
|
2060
|
+
clearTimeout(this.debounceTimers.get(timerId));
|
|
2061
|
+
}
|
|
2062
|
+
const timer = setTimeout(() => {
|
|
2063
|
+
this.debounceTimers.delete(timerId);
|
|
2064
|
+
this.dispatchChange(action, event, el).then((handled) => {
|
|
2065
|
+
if (handled) {
|
|
2066
|
+
event.stopPropagation();
|
|
2067
|
+
event.handledByChild = true;
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
}, debounceMs);
|
|
2071
|
+
this.debounceTimers.set(timerId, timer);
|
|
2072
|
+
};
|
|
2073
|
+
const onEnterKey = (event) => {
|
|
2074
|
+
if (event.key !== "Enter" || !event.target.matches('[data-filter="search"]')) return;
|
|
2075
|
+
const el = event.target.closest("[data-change-action]");
|
|
2076
|
+
if (!el || !this.shouldHandle(el, event)) return;
|
|
2077
|
+
event.preventDefault();
|
|
2078
|
+
const action = el.getAttribute("data-change-action");
|
|
2079
|
+
this.dispatch(action, event, el).then((handled) => {
|
|
2080
|
+
if (handled) {
|
|
2081
|
+
event.stopPropagation();
|
|
2082
|
+
event.handledByChild = true;
|
|
2083
|
+
}
|
|
2084
|
+
});
|
|
2085
|
+
};
|
|
2086
|
+
const onSubmit = (event) => {
|
|
2087
|
+
const form = event.target.closest("form[data-action]");
|
|
2088
|
+
if (!form || !this.shouldHandle(form, event)) return;
|
|
2089
|
+
event.preventDefault();
|
|
2090
|
+
const action = form.getAttribute("data-action");
|
|
2091
|
+
this.dispatch(action, event, form);
|
|
2092
|
+
};
|
|
2093
|
+
rootEl.addEventListener("click", onClick);
|
|
2094
|
+
rootEl.addEventListener("change", onChange);
|
|
2095
|
+
rootEl.addEventListener("input", onInput);
|
|
2096
|
+
rootEl.addEventListener("keydown", onEnterKey);
|
|
2097
|
+
rootEl.addEventListener("submit", onSubmit);
|
|
2098
|
+
this.domListeners.push(
|
|
2099
|
+
{ el: rootEl, type: "click", fn: onClick },
|
|
2100
|
+
{ el: rootEl, type: "change", fn: onChange },
|
|
2101
|
+
{ el: rootEl, type: "input", fn: onInput },
|
|
2102
|
+
{ el: rootEl, type: "keydown", fn: onEnterKey },
|
|
2103
|
+
{ el: rootEl, type: "submit", fn: onSubmit }
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
unbind() {
|
|
2107
|
+
for (const { el, type, fn } of this.domListeners) el.removeEventListener(type, fn);
|
|
2108
|
+
this.domListeners = [];
|
|
2109
|
+
for (const timer of this.debounceTimers.values()) {
|
|
2110
|
+
clearTimeout(timer);
|
|
2111
|
+
}
|
|
2112
|
+
this.debounceTimers.clear();
|
|
2113
|
+
}
|
|
2114
|
+
hideDropdown(element) {
|
|
2115
|
+
const dropdownMenu = element.closest(".dropdown-menu");
|
|
2116
|
+
const dropdown = dropdownMenu.closest(".dropdown");
|
|
2117
|
+
const dropdownBtn = dropdown.querySelector('[data-bs-toggle="dropdown"]');
|
|
2118
|
+
if (dropdownBtn && window.bootstrap?.Dropdown) {
|
|
2119
|
+
const dropdownInstance = window.bootstrap.Dropdown.getInstance(dropdownBtn);
|
|
2120
|
+
dropdownInstance?.hide();
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
async dispatch(action, event, el) {
|
|
2124
|
+
const v = this.view;
|
|
2125
|
+
const cap = (s) => s.includes("-") ? s.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") : s[0].toUpperCase() + s.slice(1);
|
|
2126
|
+
const specific = `handleAction${cap(action)}`;
|
|
2127
|
+
if (typeof v[specific] === "function") {
|
|
2128
|
+
try {
|
|
2129
|
+
event.preventDefault();
|
|
2130
|
+
await v[specific](event, el);
|
|
2131
|
+
return true;
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
console.error(`Error in action ${action}:`, e);
|
|
2134
|
+
v.handleActionError(action, e, event, el);
|
|
2135
|
+
return true;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const generic = `onAction${cap(action)}`;
|
|
2139
|
+
if (typeof v[generic] === "function") {
|
|
2140
|
+
try {
|
|
2141
|
+
if (await v[generic](event, el)) {
|
|
2142
|
+
const isInDropdown = !!el.closest(".dropdown-menu");
|
|
2143
|
+
if (isInDropdown) this.hideDropdown(el);
|
|
2144
|
+
event.preventDefault();
|
|
2145
|
+
event.stopPropagation();
|
|
2146
|
+
return true;
|
|
2147
|
+
}
|
|
2148
|
+
return false;
|
|
2149
|
+
} catch (e) {
|
|
2150
|
+
console.error(`Error in action ${action}:`, e);
|
|
2151
|
+
v.handleActionError(action, e, event, el);
|
|
2152
|
+
return true;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
const passThru = `onPassThruAction${cap(action)}`;
|
|
2156
|
+
if (typeof v[passThru] === "function") {
|
|
2157
|
+
try {
|
|
2158
|
+
await v[passThru](event, el);
|
|
2159
|
+
return false;
|
|
2160
|
+
} catch (e) {
|
|
2161
|
+
console.error(`Error in action ${action}:`, e);
|
|
2162
|
+
v.handleActionError(action, e, event, el);
|
|
2163
|
+
return true;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (typeof v.onActionDefault === "function") {
|
|
2167
|
+
try {
|
|
2168
|
+
return await v.onActionDefault(action, event, el);
|
|
2169
|
+
} catch (e) {
|
|
2170
|
+
console.error(`Error in default action handler for ${action}:`, e);
|
|
2171
|
+
v.handleActionError(action, e, event, el);
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
v.emit?.(`action:${action}`, { action, event, element: el });
|
|
2176
|
+
return false;
|
|
2177
|
+
}
|
|
2178
|
+
async dispatchChange(action, event, el) {
|
|
2179
|
+
const v = this.view;
|
|
2180
|
+
const cap = (s) => s.includes("-") ? s.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") : s[0].toUpperCase() + s.slice(1);
|
|
2181
|
+
const changeHandler = `onChange${cap(action)}`;
|
|
2182
|
+
if (typeof v[changeHandler] === "function") {
|
|
2183
|
+
try {
|
|
2184
|
+
await v[changeHandler](event, el);
|
|
2185
|
+
return true;
|
|
2186
|
+
} catch (e) {
|
|
2187
|
+
console.error(`Error in onChange ${action}:`, e);
|
|
2188
|
+
v.handleActionError?.(action, e, event, el);
|
|
2189
|
+
return true;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return await this.dispatch(action, event, el);
|
|
2193
|
+
}
|
|
2194
|
+
shouldHandle(el, event) {
|
|
2195
|
+
if (this.owns(el)) return true;
|
|
2196
|
+
if (this.contains(el) && !event.handledByChild) return true;
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
owns(el) {
|
|
2200
|
+
const root = this.view.element;
|
|
2201
|
+
if (!root || !root.contains(el)) return false;
|
|
2202
|
+
for (const child of Object.values(this.view.children)) {
|
|
2203
|
+
if (child.element && child.element.contains(el)) return false;
|
|
2204
|
+
}
|
|
2205
|
+
return true;
|
|
2206
|
+
}
|
|
2207
|
+
contains(el) {
|
|
2208
|
+
return !!this.view.element && this.view.element.contains(el);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const EventEmitter = {
|
|
2212
|
+
/**
|
|
2213
|
+
* Add an event listener
|
|
2214
|
+
* @param {string} event - Event name to listen for
|
|
2215
|
+
* @param {Function} callback - Function to call when event is emitted
|
|
2216
|
+
* @param {Object} [context] - Context to bind the callback to (optional)
|
|
2217
|
+
* @returns {Object} This instance for method chaining
|
|
2218
|
+
*
|
|
2219
|
+
* @example
|
|
2220
|
+
* // With context binding
|
|
2221
|
+
* model.on('change', this.handleChange, this);
|
|
2222
|
+
*
|
|
2223
|
+
* // Without context (traditional)
|
|
2224
|
+
* model.on('change', (data) => console.log(data));
|
|
2225
|
+
*/
|
|
2226
|
+
on(event, callback, context) {
|
|
2227
|
+
if (!this._listeners) this._listeners = {};
|
|
2228
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
2229
|
+
const listener = {
|
|
2230
|
+
callback,
|
|
2231
|
+
context,
|
|
2232
|
+
fn: context ? callback.bind(context) : callback
|
|
2233
|
+
};
|
|
2234
|
+
this._listeners[event].push(listener);
|
|
2235
|
+
return this;
|
|
2236
|
+
},
|
|
2237
|
+
/**
|
|
2238
|
+
* Remove an event listener
|
|
2239
|
+
* @param {string} event - Event name
|
|
2240
|
+
* @param {Function} [callback] - Specific callback to remove. If omitted, removes all listeners for the event
|
|
2241
|
+
* @param {Object} [context] - Context that was used when adding the listener
|
|
2242
|
+
* @returns {Object} This instance for method chaining
|
|
2243
|
+
*
|
|
2244
|
+
* @example
|
|
2245
|
+
* // Remove specific listener with context
|
|
2246
|
+
* model.off('change', this.handleChange, this);
|
|
2247
|
+
*
|
|
2248
|
+
* // Remove specific callback (any context)
|
|
2249
|
+
* model.off('change', myHandler);
|
|
2250
|
+
*
|
|
2251
|
+
* // Remove all listeners for an event
|
|
2252
|
+
* model.off('change');
|
|
2253
|
+
*/
|
|
2254
|
+
off(event, callback, context) {
|
|
2255
|
+
if (!this._listeners || !this._listeners[event]) return this;
|
|
2256
|
+
if (!callback) {
|
|
2257
|
+
delete this._listeners[event];
|
|
2258
|
+
} else {
|
|
2259
|
+
this._listeners[event] = this._listeners[event].filter((listener) => {
|
|
2260
|
+
if (listener.callback !== callback) return true;
|
|
2261
|
+
if (arguments.length === 3 && listener.context !== context) return true;
|
|
2262
|
+
return false;
|
|
2263
|
+
});
|
|
2264
|
+
if (this._listeners[event].length === 0) {
|
|
2265
|
+
delete this._listeners[event];
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return this;
|
|
2269
|
+
},
|
|
2270
|
+
/**
|
|
2271
|
+
* Add a one-time event listener that automatically removes itself after being called
|
|
2272
|
+
* @param {string} event - Event name to listen for
|
|
2273
|
+
* @param {Function} callback - Function to call when event is emitted (called only once)
|
|
2274
|
+
* @param {Object} [context] - Context to bind the callback to (optional)
|
|
2275
|
+
* @returns {Object} This instance for method chaining
|
|
2276
|
+
*
|
|
2277
|
+
* @example
|
|
2278
|
+
* model.once('ready', this.handleReady, this);
|
|
2279
|
+
*/
|
|
2280
|
+
once(event, callback, context) {
|
|
2281
|
+
const onceWrapper = (...args) => {
|
|
2282
|
+
this.off(event, onceWrapper);
|
|
2283
|
+
const fn = context ? callback.bind(context) : callback;
|
|
2284
|
+
fn.apply(context || this, args);
|
|
2285
|
+
};
|
|
2286
|
+
this.on(event, onceWrapper);
|
|
2287
|
+
return this;
|
|
2288
|
+
},
|
|
2289
|
+
/**
|
|
2290
|
+
* Emit an event to all registered listeners
|
|
2291
|
+
* @param {string} event - Event name to emit
|
|
2292
|
+
* @param {...*} args - Arguments to pass to event listeners
|
|
2293
|
+
* @returns {Object} This instance for method chaining
|
|
2294
|
+
*
|
|
2295
|
+
* @example
|
|
2296
|
+
* // Emit with single argument
|
|
2297
|
+
* model.emit('change', { field: 'name', value: 'John' });
|
|
2298
|
+
*
|
|
2299
|
+
* // Emit with multiple arguments
|
|
2300
|
+
* model.emit('update', oldValue, newValue, timestamp);
|
|
2301
|
+
*
|
|
2302
|
+
* // Emit without arguments
|
|
2303
|
+
* model.emit('ready');
|
|
2304
|
+
*/
|
|
2305
|
+
emit(event, ...args) {
|
|
2306
|
+
if (!this._listeners || !this._listeners[event]) return this;
|
|
2307
|
+
const listeners = this._listeners[event].slice();
|
|
2308
|
+
for (const listener of listeners) {
|
|
2309
|
+
try {
|
|
2310
|
+
listener.fn.apply(listener.context || this, args);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
if (console && console.error) {
|
|
2313
|
+
console.error(`Error in ${event} event handler:`, error);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
return this;
|
|
2318
|
+
}
|
|
2319
|
+
};
|
|
2320
|
+
if (typeof window !== "undefined") {
|
|
2321
|
+
window.Mustache = Mustache;
|
|
2322
|
+
}
|
|
2323
|
+
class View {
|
|
2324
|
+
// ---------------------------------------------
|
|
2325
|
+
// Construction & defaults
|
|
2326
|
+
// ---------------------------------------------
|
|
2327
|
+
constructor(opts = {}) {
|
|
2328
|
+
this.tagName = opts.tagName ?? "div";
|
|
2329
|
+
this.className = opts.className ?? "mojo-view";
|
|
2330
|
+
this.style = opts.style ?? null;
|
|
2331
|
+
this.id = opts.id ?? View._genId();
|
|
2332
|
+
this.containerId = opts.containerId ?? null;
|
|
2333
|
+
this.container = opts.container ?? null;
|
|
2334
|
+
if (typeof this.container === "string") {
|
|
2335
|
+
this.containerId = this.container;
|
|
2336
|
+
this.container = null;
|
|
2337
|
+
}
|
|
2338
|
+
this.parent = opts.parent ?? null;
|
|
2339
|
+
this.children = opts.children ?? {};
|
|
2340
|
+
this.template = opts.template || opts.templateUrl || "";
|
|
2341
|
+
this.data = opts.data ?? {};
|
|
2342
|
+
this.isRendering = false;
|
|
2343
|
+
this.lastRenderTime = 0;
|
|
2344
|
+
this.mounted = false;
|
|
2345
|
+
this.debug = opts.debug ?? false;
|
|
2346
|
+
this.app = opts.app ?? null;
|
|
2347
|
+
this.cacheTemplate = opts.cacheTemplate ?? true;
|
|
2348
|
+
this.options = { ...opts };
|
|
2349
|
+
this.element = this._ensureElement();
|
|
2350
|
+
this.events = new EventDelegate(this);
|
|
2351
|
+
if (opts.model) this.setModel(opts.model);
|
|
2352
|
+
}
|
|
2353
|
+
// ---------------------------------------------
|
|
2354
|
+
// Lifecycle hooks (overridable)
|
|
2355
|
+
// ---------------------------------------------
|
|
2356
|
+
async onInit() {
|
|
2357
|
+
}
|
|
2358
|
+
async onInitView() {
|
|
2359
|
+
if (this.initialized) return;
|
|
2360
|
+
this.initialized = true;
|
|
2361
|
+
await this.onInit();
|
|
2362
|
+
}
|
|
2363
|
+
async onBeforeRender() {
|
|
2364
|
+
}
|
|
2365
|
+
async onAfterRender() {
|
|
2366
|
+
}
|
|
2367
|
+
async onBeforeMount() {
|
|
2368
|
+
}
|
|
2369
|
+
async onAfterMount() {
|
|
2370
|
+
}
|
|
2371
|
+
async onBeforeUnmount() {
|
|
2372
|
+
}
|
|
2373
|
+
async onAfterUnmount() {
|
|
2374
|
+
}
|
|
2375
|
+
async onBeforeDestroy() {
|
|
2376
|
+
}
|
|
2377
|
+
async onAfterDestroy() {
|
|
2378
|
+
}
|
|
2379
|
+
// ---------------------------------------------
|
|
2380
|
+
// Public API
|
|
2381
|
+
// ---------------------------------------------
|
|
2382
|
+
setModel(model = {}) {
|
|
2383
|
+
let isDiff = model !== this.model;
|
|
2384
|
+
if (!isDiff) return this;
|
|
2385
|
+
if (this.model && this.model.off) {
|
|
2386
|
+
this.model.off("change", this._onModelChange, this);
|
|
2387
|
+
}
|
|
2388
|
+
this.model = model;
|
|
2389
|
+
if (this.model && this.model.on) {
|
|
2390
|
+
this.model.on("change", this._onModelChange, this);
|
|
2391
|
+
}
|
|
2392
|
+
for (const id in this.children) {
|
|
2393
|
+
const child = this.children[id];
|
|
2394
|
+
if (child && typeof child.setModel === "function") {
|
|
2395
|
+
child.setModel(model);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
if (isDiff) {
|
|
2399
|
+
this._onModelChange();
|
|
2400
|
+
}
|
|
2401
|
+
return this;
|
|
2402
|
+
}
|
|
2403
|
+
_onModelChange() {
|
|
2404
|
+
if (this.isMounted()) {
|
|
2405
|
+
this.render();
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
setTemplate(tpl) {
|
|
2409
|
+
this.template = tpl ?? "";
|
|
2410
|
+
return this;
|
|
2411
|
+
}
|
|
2412
|
+
addChild(childView) {
|
|
2413
|
+
try {
|
|
2414
|
+
if (!childView || typeof childView !== "object") return this;
|
|
2415
|
+
childView.parent = this;
|
|
2416
|
+
if (this.getApp()) childView.app = this.app;
|
|
2417
|
+
this.children[childView.id] = childView;
|
|
2418
|
+
} catch (e) {
|
|
2419
|
+
View._warn("addChild error", e);
|
|
2420
|
+
}
|
|
2421
|
+
return childView;
|
|
2422
|
+
}
|
|
2423
|
+
removeChild(idOrView) {
|
|
2424
|
+
try {
|
|
2425
|
+
const id = typeof idOrView === "string" ? idOrView : idOrView && idOrView.id;
|
|
2426
|
+
if (!id) return this;
|
|
2427
|
+
const child = this.children[id];
|
|
2428
|
+
if (child) {
|
|
2429
|
+
Promise.resolve(child.destroy()).catch((err) => View._warn("removeChild destroy error", err));
|
|
2430
|
+
delete this.children[id];
|
|
2431
|
+
}
|
|
2432
|
+
} catch (e) {
|
|
2433
|
+
View._warn("removeChild error", e);
|
|
2434
|
+
}
|
|
2435
|
+
return this;
|
|
2436
|
+
}
|
|
2437
|
+
getChild(id) {
|
|
2438
|
+
return this.children[id];
|
|
2439
|
+
}
|
|
2440
|
+
async updateData(newData, rerender = false) {
|
|
2441
|
+
Object.assign(this.data, newData);
|
|
2442
|
+
if (rerender && this.isMounted()) {
|
|
2443
|
+
await this.render();
|
|
2444
|
+
}
|
|
2445
|
+
return this;
|
|
2446
|
+
}
|
|
2447
|
+
toggleClass(className, force) {
|
|
2448
|
+
if (force === void 0) {
|
|
2449
|
+
force = !this.element.classList.contains(className);
|
|
2450
|
+
}
|
|
2451
|
+
this.element.classList.toggle(className, force);
|
|
2452
|
+
return this;
|
|
2453
|
+
}
|
|
2454
|
+
addClass(className) {
|
|
2455
|
+
this.element.classList.add(className);
|
|
2456
|
+
return this;
|
|
2457
|
+
}
|
|
2458
|
+
setClass(className) {
|
|
2459
|
+
this.element.className = className;
|
|
2460
|
+
return this;
|
|
2461
|
+
}
|
|
2462
|
+
removeClass(className) {
|
|
2463
|
+
this.element.classList.remove(className);
|
|
2464
|
+
return this;
|
|
2465
|
+
}
|
|
2466
|
+
canRender() {
|
|
2467
|
+
if (this.options.renderCooldown > 0 && now - this.lastRenderTime < this.options.renderCooldown) {
|
|
2468
|
+
View._warn(`View ${this.id}: Render called too quickly, cooldown active`);
|
|
2469
|
+
return false;
|
|
2470
|
+
}
|
|
2471
|
+
if (this.options.noAppend) {
|
|
2472
|
+
if (this.parent) {
|
|
2473
|
+
if (!this.parent.contains(this.containerId || this.container)) {
|
|
2474
|
+
return false;
|
|
2475
|
+
} else if (this.containerId && !document.getElementById(this.containerId)) {
|
|
2476
|
+
return false;
|
|
2477
|
+
} else if (this.container && !document.contains(this.container)) {
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
return true;
|
|
2483
|
+
}
|
|
2484
|
+
// ---------------------------------------------
|
|
2485
|
+
// Render flow
|
|
2486
|
+
// ---------------------------------------------
|
|
2487
|
+
async render(allowMount = true, container = null) {
|
|
2488
|
+
const now2 = Date.now();
|
|
2489
|
+
if (!this.canRender()) {
|
|
2490
|
+
return this;
|
|
2491
|
+
}
|
|
2492
|
+
this.isRendering = true;
|
|
2493
|
+
this.lastRenderTime = now2;
|
|
2494
|
+
try {
|
|
2495
|
+
if (!this.initialized) await this.onInitView();
|
|
2496
|
+
this.events.unbind();
|
|
2497
|
+
await this.onBeforeRender();
|
|
2498
|
+
if (this.getViewData) {
|
|
2499
|
+
this.data = await this.getViewData();
|
|
2500
|
+
}
|
|
2501
|
+
const html = await this.renderTemplate();
|
|
2502
|
+
this.element.innerHTML = html;
|
|
2503
|
+
if (allowMount && !this.isMounted()) {
|
|
2504
|
+
await this.mount(container);
|
|
2505
|
+
}
|
|
2506
|
+
await this._renderChildren();
|
|
2507
|
+
await this.onAfterRender();
|
|
2508
|
+
this.events.bind(this.element);
|
|
2509
|
+
} catch (e) {
|
|
2510
|
+
View._warn(`Render error in ${this.id}`, e);
|
|
2511
|
+
} finally {
|
|
2512
|
+
this.isRendering = false;
|
|
2513
|
+
}
|
|
2514
|
+
return this;
|
|
2515
|
+
}
|
|
2516
|
+
async _renderChildren() {
|
|
2517
|
+
for (const id in this.children) {
|
|
2518
|
+
const child = this.children[id];
|
|
2519
|
+
if (!child) continue;
|
|
2520
|
+
child.parent = this;
|
|
2521
|
+
await Promise.resolve(child.render()).catch((err) => View._warn(`Child render error (${id})`, err));
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
async _unmountChildren() {
|
|
2525
|
+
for (const id in this.children) {
|
|
2526
|
+
const child = this.children[id];
|
|
2527
|
+
if (!child) continue;
|
|
2528
|
+
child.unbindEvents();
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
isMounted() {
|
|
2532
|
+
return this.element?.isConnected;
|
|
2533
|
+
}
|
|
2534
|
+
getChildElementById(id, root = null) {
|
|
2535
|
+
const cleanId = id.startsWith("#") ? id.substring(1) : id;
|
|
2536
|
+
if (root) {
|
|
2537
|
+
return root.querySelector(`#${cleanId}`);
|
|
2538
|
+
}
|
|
2539
|
+
return this.element.querySelector(`#${cleanId}`);
|
|
2540
|
+
}
|
|
2541
|
+
getChildElement(id) {
|
|
2542
|
+
if (id.startsWith("#")) {
|
|
2543
|
+
return this.getChildElementById(id);
|
|
2544
|
+
}
|
|
2545
|
+
let el = this.element?.querySelector(`[data-container="${id}"]`);
|
|
2546
|
+
if (!el) {
|
|
2547
|
+
return this.getChildElementById(id);
|
|
2548
|
+
}
|
|
2549
|
+
return el;
|
|
2550
|
+
}
|
|
2551
|
+
getContainer() {
|
|
2552
|
+
if (this.replaceById) {
|
|
2553
|
+
if (this.parent) {
|
|
2554
|
+
return this.parent.getChildElementById(this.id);
|
|
2555
|
+
}
|
|
2556
|
+
return null;
|
|
2557
|
+
}
|
|
2558
|
+
if (!this.containerId) return null;
|
|
2559
|
+
if (this.parent) {
|
|
2560
|
+
return this.parent.getChildElement(this.containerId);
|
|
2561
|
+
}
|
|
2562
|
+
return this.getChildElementById(this.containerId, document.body);
|
|
2563
|
+
}
|
|
2564
|
+
async mount(container = null) {
|
|
2565
|
+
await this.onBeforeMount();
|
|
2566
|
+
if (!container) {
|
|
2567
|
+
container = this.getContainer();
|
|
2568
|
+
}
|
|
2569
|
+
if (this.containerId && !container) {
|
|
2570
|
+
console.error(`Container not found for ${this.containerId}`);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (container && this.replaceById) {
|
|
2574
|
+
container.replaceWith(this.element);
|
|
2575
|
+
} else if (container) {
|
|
2576
|
+
container.replaceChildren(this.element);
|
|
2577
|
+
} else if (!this.containerId && this.parent) {
|
|
2578
|
+
this.parent.element.appendChild(this.element);
|
|
2579
|
+
} else if (!this.containerId && !this.parent) {
|
|
2580
|
+
document.body.appendChild(this.element);
|
|
2581
|
+
} else {
|
|
2582
|
+
console.error(`Container not found for ${this.containerId}`);
|
|
2583
|
+
}
|
|
2584
|
+
await this.onAfterMount();
|
|
2585
|
+
this.mounted = true;
|
|
2586
|
+
}
|
|
2587
|
+
async unmount() {
|
|
2588
|
+
if (!this.element || !this.element.parentNode) return;
|
|
2589
|
+
await this.onBeforeUnmount();
|
|
2590
|
+
await this._unmountChildren();
|
|
2591
|
+
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
|
|
2592
|
+
this.events.unbind();
|
|
2593
|
+
await this.onAfterUnmount();
|
|
2594
|
+
this.mounted = false;
|
|
2595
|
+
}
|
|
2596
|
+
// FIX #1: make destroy async (it already awaited hooks)
|
|
2597
|
+
async destroy() {
|
|
2598
|
+
try {
|
|
2599
|
+
this.events.unbind();
|
|
2600
|
+
for (const id in this.children) {
|
|
2601
|
+
const ch = this.children[id];
|
|
2602
|
+
if (ch) {
|
|
2603
|
+
await Promise.resolve(ch.destroy()).catch((err) => View._warn(`Child destroy error (${id})`, err));
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
this.mounted = false;
|
|
2607
|
+
if (this.element && this.element.parentNode) {
|
|
2608
|
+
await this.onBeforeDestroy();
|
|
2609
|
+
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
|
|
2610
|
+
await this.onAfterDestroy();
|
|
2611
|
+
}
|
|
2612
|
+
} catch (e) {
|
|
2613
|
+
View._warn(`Destroy error in ${this.id}`, e);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
// ---------------------------------------------
|
|
2617
|
+
// DOM helpers
|
|
2618
|
+
// ---------------------------------------------
|
|
2619
|
+
_ensureElement() {
|
|
2620
|
+
try {
|
|
2621
|
+
if (this.element && this.element.tagName?.toLowerCase() === this.tagName) {
|
|
2622
|
+
this._syncAttrs();
|
|
2623
|
+
return this.element;
|
|
2624
|
+
}
|
|
2625
|
+
const el = document.createElement(this.tagName);
|
|
2626
|
+
this.element = el;
|
|
2627
|
+
this.el = el;
|
|
2628
|
+
this._syncAttrs();
|
|
2629
|
+
return el;
|
|
2630
|
+
} catch (e) {
|
|
2631
|
+
View._warn("ensureElement error", e);
|
|
2632
|
+
const el = document.createElement("div");
|
|
2633
|
+
el.id = this.id || View._genId();
|
|
2634
|
+
return el;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
_syncAttrs() {
|
|
2638
|
+
try {
|
|
2639
|
+
if (!this.element) return;
|
|
2640
|
+
if (this.id) this.element.id = this.id;
|
|
2641
|
+
this.element.className = this.className || "";
|
|
2642
|
+
if (this.style == null) {
|
|
2643
|
+
this.element.removeAttribute("style");
|
|
2644
|
+
} else {
|
|
2645
|
+
this.element.style.cssText = String(this.style);
|
|
2646
|
+
}
|
|
2647
|
+
} catch (e) {
|
|
2648
|
+
View._warn("_syncAttrs error", e);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
bindEvents() {
|
|
2652
|
+
this.events.bind(this.element);
|
|
2653
|
+
}
|
|
2654
|
+
unbindEvents() {
|
|
2655
|
+
this.events.unbind();
|
|
2656
|
+
}
|
|
2657
|
+
// ---------------------------------------------
|
|
2658
|
+
// Template helpers
|
|
2659
|
+
// ---------------------------------------------
|
|
2660
|
+
async renderTemplate() {
|
|
2661
|
+
const templateContent = await this.getTemplate();
|
|
2662
|
+
if (!templateContent) return "";
|
|
2663
|
+
const partials = this.getPartials();
|
|
2664
|
+
return Mustache.render(templateContent, this, partials);
|
|
2665
|
+
}
|
|
2666
|
+
renderTemplateString(template, context, partials) {
|
|
2667
|
+
return Mustache.render(template, context, partials);
|
|
2668
|
+
}
|
|
2669
|
+
getPartials() {
|
|
2670
|
+
return {};
|
|
2671
|
+
}
|
|
2672
|
+
async getTemplate() {
|
|
2673
|
+
if (this._templateCache && this.cacheTemplate) {
|
|
2674
|
+
return this._templateCache;
|
|
2675
|
+
}
|
|
2676
|
+
const template = this.template || this.templateUrl;
|
|
2677
|
+
if (!template) {
|
|
2678
|
+
throw new Error("Template not found");
|
|
2679
|
+
}
|
|
2680
|
+
let templateContent = "";
|
|
2681
|
+
if (typeof template === "string") {
|
|
2682
|
+
if (template.includes("<") || template.includes("{")) {
|
|
2683
|
+
templateContent = template;
|
|
2684
|
+
} else {
|
|
2685
|
+
try {
|
|
2686
|
+
let templatePath = template;
|
|
2687
|
+
if (!this.app) this.app = this.getApp();
|
|
2688
|
+
if (this.app && this.app.basePath) {
|
|
2689
|
+
if (!templatePath.startsWith("/") && !templatePath.startsWith("http://") && !templatePath.startsWith("https://")) {
|
|
2690
|
+
const base = this.app.basePath.endsWith("/") ? this.app.basePath.slice(0, -1) : this.app.basePath;
|
|
2691
|
+
templatePath = `${base}/${templatePath}`;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const response = await fetch(templatePath);
|
|
2695
|
+
if (!response.ok) {
|
|
2696
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2697
|
+
}
|
|
2698
|
+
templateContent = await response.text();
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
View._warn(`Failed to load template from ${template}: ${error}`);
|
|
2701
|
+
this.showError?.(`Failed to load template from ${template}: ${error.message}`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
} else if (typeof template === "function") {
|
|
2705
|
+
templateContent = await this.template(this.data, this.state);
|
|
2706
|
+
}
|
|
2707
|
+
if (this.cacheTemplate && templateContent) {
|
|
2708
|
+
this._templateCache = templateContent;
|
|
2709
|
+
}
|
|
2710
|
+
return templateContent;
|
|
2711
|
+
}
|
|
2712
|
+
getContextValue(path) {
|
|
2713
|
+
const value = MOJOUtils.getContextData(this, path);
|
|
2714
|
+
if (path && path.startsWith("data.") && value && typeof value === "object") {
|
|
2715
|
+
return MOJOUtils.wrapData(value, this);
|
|
2716
|
+
}
|
|
2717
|
+
if (path && path.startsWith("model.") && path !== "model" && value && typeof value === "object" && typeof value.getContextValue !== "function") {
|
|
2718
|
+
return MOJOUtils.wrapData(value, null);
|
|
2719
|
+
}
|
|
2720
|
+
return value;
|
|
2721
|
+
}
|
|
2722
|
+
// 9) Navigation Helpers ------------------------------------------------------
|
|
2723
|
+
async handlePageNavigation(element) {
|
|
2724
|
+
const pageName = element.getAttribute("data-page");
|
|
2725
|
+
const paramsAttr = element.getAttribute("data-params");
|
|
2726
|
+
let params = {};
|
|
2727
|
+
if (paramsAttr) {
|
|
2728
|
+
try {
|
|
2729
|
+
params = JSON.parse(paramsAttr);
|
|
2730
|
+
} catch (error) {
|
|
2731
|
+
console.warn("Invalid JSON in data-params:", paramsAttr);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
const app = this.getApp();
|
|
2735
|
+
if (app) {
|
|
2736
|
+
app.showPage(pageName, params);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
const router = this.findRouter();
|
|
2740
|
+
if (router && typeof router.navigateToPage === "function") {
|
|
2741
|
+
await router.navigateToPage(pageName, params);
|
|
2742
|
+
} else {
|
|
2743
|
+
console.error(`No router found for page navigation to '${pageName}'`);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
async handleHrefNavigation(element) {
|
|
2747
|
+
const href = element.getAttribute("href");
|
|
2748
|
+
if (this.isExternalLink(href) || element.hasAttribute("data-external")) return;
|
|
2749
|
+
const router = this.findRouter();
|
|
2750
|
+
if (router) {
|
|
2751
|
+
if (router.options && router.options.mode === "param" && href.startsWith("?")) {
|
|
2752
|
+
const fullPath = "/" + href;
|
|
2753
|
+
await router.navigate(fullPath);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
if (router.options && router.options.mode === "hash" && href.startsWith("#")) {
|
|
2757
|
+
await router.navigate(href);
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
const routePath = this.hrefToRoutePath(href);
|
|
2761
|
+
await router.navigate(routePath);
|
|
2762
|
+
} else {
|
|
2763
|
+
console.warn("No router found for navigation, using default behavior");
|
|
2764
|
+
window.location.href = href;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
isExternalLink(href) {
|
|
2768
|
+
if (!href) return true;
|
|
2769
|
+
if (href.startsWith("/") && this.getApp()) {
|
|
2770
|
+
if (href.startsWith(this.findRouter().basePath)) return false;
|
|
2771
|
+
return true;
|
|
2772
|
+
}
|
|
2773
|
+
return href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//");
|
|
2774
|
+
}
|
|
2775
|
+
hrefToRoutePath(href) {
|
|
2776
|
+
if (href.startsWith("/")) {
|
|
2777
|
+
const router = this.findRouter();
|
|
2778
|
+
if (router && router.options && router.options.base) {
|
|
2779
|
+
const base = router.options.base;
|
|
2780
|
+
if (href.startsWith(base)) return href.substring(base.length) || "/";
|
|
2781
|
+
}
|
|
2782
|
+
return href;
|
|
2783
|
+
}
|
|
2784
|
+
return href.startsWith("./") ? href.substring(2) : href;
|
|
2785
|
+
}
|
|
2786
|
+
findRouter() {
|
|
2787
|
+
this.getApp();
|
|
2788
|
+
return this.app?.router || null;
|
|
2789
|
+
}
|
|
2790
|
+
getApp() {
|
|
2791
|
+
if (this.app) return this.app;
|
|
2792
|
+
const apps = [
|
|
2793
|
+
window.__app__,
|
|
2794
|
+
window.matchUUID ? window[window.matchUUID]() : window[window.matchUUID],
|
|
2795
|
+
window.MOJO?.app,
|
|
2796
|
+
window.APP,
|
|
2797
|
+
window.app,
|
|
2798
|
+
window.WebApp
|
|
2799
|
+
];
|
|
2800
|
+
this.app = apps.find((app) => app && typeof app.showPage === "function") || null;
|
|
2801
|
+
return this.app;
|
|
2802
|
+
}
|
|
2803
|
+
handleActionError(action, err, evt, el) {
|
|
2804
|
+
this.showError(`Action '${action}' failed: ${err}`, evt, el);
|
|
2805
|
+
}
|
|
2806
|
+
// ---------------------------------------------
|
|
2807
|
+
// Utilities
|
|
2808
|
+
// ---------------------------------------------
|
|
2809
|
+
/**
|
|
2810
|
+
* Escape HTML characters
|
|
2811
|
+
* @param {string} str - String to escape
|
|
2812
|
+
* @returns {string} Escaped string
|
|
2813
|
+
*/
|
|
2814
|
+
escapeHtml(str) {
|
|
2815
|
+
if (typeof str !== "string") return str;
|
|
2816
|
+
const div = document.createElement("div");
|
|
2817
|
+
div.textContent = str;
|
|
2818
|
+
return div.innerHTML;
|
|
2819
|
+
}
|
|
2820
|
+
contains(el) {
|
|
2821
|
+
if (typeof el === "string") {
|
|
2822
|
+
if (!this.element) return false;
|
|
2823
|
+
el = document.getElementById(el);
|
|
2824
|
+
}
|
|
2825
|
+
if (!el) return false;
|
|
2826
|
+
return this.element.contains(el);
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Show error message
|
|
2830
|
+
* @param {string} message - Error message
|
|
2831
|
+
*/
|
|
2832
|
+
async showError(message) {
|
|
2833
|
+
console.error(`View ${this.id} error:`, message);
|
|
2834
|
+
const app = this.getApp ? this.getApp() : this.app || null;
|
|
2835
|
+
if (app && typeof app.showError === "function") {
|
|
2836
|
+
await app.showError(message);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
alert(`Error: ${message}`);
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* Show success message
|
|
2843
|
+
* @param {string} message - Success message
|
|
2844
|
+
*/
|
|
2845
|
+
async showSuccess(message) {
|
|
2846
|
+
if (this.debug) {
|
|
2847
|
+
console.log(`View ${this.id} success:`, message);
|
|
2848
|
+
}
|
|
2849
|
+
const app = this.getApp ? this.getApp() : this.app || null;
|
|
2850
|
+
if (app && typeof app.showSuccess === "function") {
|
|
2851
|
+
await app.showSuccess(message);
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
alert(`Success: ${message}`);
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Show info message
|
|
2858
|
+
* @param {string} message - Info message
|
|
2859
|
+
*/
|
|
2860
|
+
async showInfo(message) {
|
|
2861
|
+
console.info(`View ${this.id} info:`, message);
|
|
2862
|
+
const app = this.getApp ? this.getApp() : this.app || null;
|
|
2863
|
+
if (app && typeof app.showInfo === "function") {
|
|
2864
|
+
await app.showInfo(message);
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
alert(`Info: ${message}`);
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Show warning message
|
|
2871
|
+
* @param {string} message - Warning message
|
|
2872
|
+
*/
|
|
2873
|
+
async showWarning(message) {
|
|
2874
|
+
console.warn(`View ${this.id} warning:`, message);
|
|
2875
|
+
const app = this.getApp ? this.getApp() : this.app || null;
|
|
2876
|
+
if (app && typeof app.showWarning === "function") {
|
|
2877
|
+
await app.showWarning(message);
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
alert(`Warning: ${message}`);
|
|
2881
|
+
}
|
|
2882
|
+
static _genId() {
|
|
2883
|
+
return `view_${Math.random().toString(36).substr(2, 9)}`;
|
|
2884
|
+
}
|
|
2885
|
+
static _warn(msg, err) {
|
|
2886
|
+
try {
|
|
2887
|
+
if (err) console.warn(`[View] ${msg}:`, err);
|
|
2888
|
+
else console.warn(`[View] ${msg}`);
|
|
2889
|
+
} catch {
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
Object.assign(View.prototype, EventEmitter);
|
|
2894
|
+
class Router {
|
|
2895
|
+
constructor(options = {}) {
|
|
2896
|
+
this.defaultRoute = options.defaultRoute || "home";
|
|
2897
|
+
this.routes = [];
|
|
2898
|
+
this.currentRoute = null;
|
|
2899
|
+
this.eventEmitter = options.eventEmitter || null;
|
|
2900
|
+
this.boundHandlePopState = this.handlePopState.bind(this);
|
|
2901
|
+
}
|
|
2902
|
+
start() {
|
|
2903
|
+
window.addEventListener("popstate", this.boundHandlePopState);
|
|
2904
|
+
this.handleCurrentLocation();
|
|
2905
|
+
}
|
|
2906
|
+
stop() {
|
|
2907
|
+
window.removeEventListener("popstate", this.boundHandlePopState);
|
|
2908
|
+
}
|
|
2909
|
+
addRoute(pattern, pageName) {
|
|
2910
|
+
this.routes.push({
|
|
2911
|
+
pattern: this.normalizePattern(pattern),
|
|
2912
|
+
regex: this.patternToRegex(pattern),
|
|
2913
|
+
pageName,
|
|
2914
|
+
paramNames: this.extractParamNames(pattern)
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
async navigate(path, options = {}) {
|
|
2918
|
+
const { replace = false, state = null, trigger = true } = options;
|
|
2919
|
+
const { pageName, queryParams } = this.parseInput(path);
|
|
2920
|
+
if (trigger) {
|
|
2921
|
+
await this.handleRouteChange(pageName, queryParams);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
// Browser navigation
|
|
2925
|
+
back() {
|
|
2926
|
+
window.history.back();
|
|
2927
|
+
}
|
|
2928
|
+
forward() {
|
|
2929
|
+
window.history.forward();
|
|
2930
|
+
}
|
|
2931
|
+
// Get current route info
|
|
2932
|
+
getCurrentRoute() {
|
|
2933
|
+
return this.currentRoute;
|
|
2934
|
+
}
|
|
2935
|
+
getCurrentPath() {
|
|
2936
|
+
const { pageName, queryParams } = this.parseCurrentUrl();
|
|
2937
|
+
return this.buildPublicUrl(pageName, queryParams);
|
|
2938
|
+
}
|
|
2939
|
+
// Private methods
|
|
2940
|
+
handlePopState(_event) {
|
|
2941
|
+
this.handleCurrentLocation();
|
|
2942
|
+
}
|
|
2943
|
+
async handleCurrentLocation() {
|
|
2944
|
+
const { pageName, queryParams } = this.parseCurrentUrl();
|
|
2945
|
+
await this.handleRouteChange(pageName, queryParams);
|
|
2946
|
+
}
|
|
2947
|
+
async handleRouteChange(pageName, queryParams) {
|
|
2948
|
+
const routePath = "/" + pageName;
|
|
2949
|
+
const route = this.matchRoute(routePath);
|
|
2950
|
+
const publicUrl = this.buildPublicUrl(pageName, queryParams);
|
|
2951
|
+
if (!route) {
|
|
2952
|
+
console.log("No route matched for page:", pageName);
|
|
2953
|
+
if (this.eventEmitter) {
|
|
2954
|
+
this.eventEmitter.emit("route:notfound", { path: publicUrl });
|
|
2955
|
+
}
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
this.currentRoute = route;
|
|
2959
|
+
if (this.eventEmitter) {
|
|
2960
|
+
this.eventEmitter.emit("route:changed", {
|
|
2961
|
+
path: publicUrl,
|
|
2962
|
+
pageName: route.pageName,
|
|
2963
|
+
params: route.params,
|
|
2964
|
+
query: queryParams,
|
|
2965
|
+
route
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
return route;
|
|
2969
|
+
}
|
|
2970
|
+
matchRoute(path) {
|
|
2971
|
+
for (const route of this.routes) {
|
|
2972
|
+
const match = path.match(route.regex);
|
|
2973
|
+
if (match) {
|
|
2974
|
+
const params = {};
|
|
2975
|
+
route.paramNames.forEach((name, index) => {
|
|
2976
|
+
params[name] = match[index + 1];
|
|
2977
|
+
});
|
|
2978
|
+
return {
|
|
2979
|
+
...route,
|
|
2980
|
+
params,
|
|
2981
|
+
path
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
return null;
|
|
2986
|
+
}
|
|
2987
|
+
// Parse any input format and extract page name + query params
|
|
2988
|
+
parseInput(input) {
|
|
2989
|
+
let pageName = this.defaultRoute;
|
|
2990
|
+
let queryParams = {};
|
|
2991
|
+
if (!input) {
|
|
2992
|
+
return { pageName, queryParams };
|
|
2993
|
+
}
|
|
2994
|
+
try {
|
|
2995
|
+
if (input.includes("?")) {
|
|
2996
|
+
const [pathPart, queryPart] = input.split("?", 2);
|
|
2997
|
+
const urlParams = new URLSearchParams(queryPart);
|
|
2998
|
+
if (urlParams.has("page")) {
|
|
2999
|
+
pageName = urlParams.get("page") || this.defaultRoute;
|
|
3000
|
+
for (const [key, value] of urlParams) {
|
|
3001
|
+
if (key !== "page") {
|
|
3002
|
+
queryParams[key] = value;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
} else {
|
|
3006
|
+
if (pathPart.startsWith("/")) {
|
|
3007
|
+
pageName = pathPart.substring(1) || this.defaultRoute;
|
|
3008
|
+
} else {
|
|
3009
|
+
pageName = pathPart || this.defaultRoute;
|
|
3010
|
+
}
|
|
3011
|
+
for (const [key, value] of urlParams) {
|
|
3012
|
+
queryParams[key] = value;
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
} else if (input.startsWith("/")) {
|
|
3016
|
+
pageName = input.substring(1) || this.defaultRoute;
|
|
3017
|
+
} else {
|
|
3018
|
+
pageName = input;
|
|
3019
|
+
}
|
|
3020
|
+
} catch (error) {
|
|
3021
|
+
console.warn("Failed to parse input:", input, error);
|
|
3022
|
+
pageName = this.defaultRoute;
|
|
3023
|
+
queryParams = {};
|
|
3024
|
+
}
|
|
3025
|
+
return { pageName, queryParams };
|
|
3026
|
+
}
|
|
3027
|
+
// Parse current browser URL
|
|
3028
|
+
parseCurrentUrl() {
|
|
3029
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
3030
|
+
const pageName = urlParams.get("page") || this.defaultRoute;
|
|
3031
|
+
const queryParams = {};
|
|
3032
|
+
for (const [key, value] of urlParams) {
|
|
3033
|
+
if (key !== "page") {
|
|
3034
|
+
queryParams[key] = value;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
return { pageName, queryParams };
|
|
3038
|
+
}
|
|
3039
|
+
// Build public URL format: ?page=admin&group=123
|
|
3040
|
+
buildPublicUrl(pageName, queryParams = {}) {
|
|
3041
|
+
const urlParams = new URLSearchParams();
|
|
3042
|
+
urlParams.set("page", pageName);
|
|
3043
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
3044
|
+
if (value !== null && value !== void 0 && value !== "") {
|
|
3045
|
+
urlParams.set(key, String(value));
|
|
3046
|
+
}
|
|
3047
|
+
});
|
|
3048
|
+
return "?" + urlParams.toString();
|
|
3049
|
+
}
|
|
3050
|
+
// Update browser URL
|
|
3051
|
+
updateBrowserUrl(pageName, queryParams, replace, state) {
|
|
3052
|
+
const currentUrl = new URL(window.location.origin + window.location.pathname);
|
|
3053
|
+
currentUrl.searchParams.set("page", pageName);
|
|
3054
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
3055
|
+
if (value !== null && value !== void 0 && value !== "") {
|
|
3056
|
+
currentUrl.searchParams.set(key, String(value));
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
const url = currentUrl.toString();
|
|
3060
|
+
if (replace) {
|
|
3061
|
+
window.history.replaceState(state, "", url);
|
|
3062
|
+
} else {
|
|
3063
|
+
window.history.pushState(state, "", url);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
// Route pattern utilities
|
|
3067
|
+
patternToRegex(pattern) {
|
|
3068
|
+
let regexPattern = pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&").replace(/\/:([^/?]+)\?/g, "(?:/([^/]+))?").replace(/:([^/]+)/g, "([^/]+)");
|
|
3069
|
+
return new RegExp(`^${regexPattern}$`);
|
|
3070
|
+
}
|
|
3071
|
+
extractParamNames(pattern) {
|
|
3072
|
+
const matches = pattern.match(/:([^/?]+)\??/g) || [];
|
|
3073
|
+
return matches.map((match) => match.replace(/[:?]/g, ""));
|
|
3074
|
+
}
|
|
3075
|
+
normalizePattern(pattern) {
|
|
3076
|
+
return pattern.startsWith("/") ? pattern : `/${pattern}`;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Update URL parameters without triggering navigation
|
|
3080
|
+
* @param {object} params - Parameters to update in URL
|
|
3081
|
+
* @param {object} options - Options like { replace: true }
|
|
3082
|
+
*/
|
|
3083
|
+
updateUrl(params = {}, options = {}) {
|
|
3084
|
+
const { replace = false } = options;
|
|
3085
|
+
const { pageName } = this.parseCurrentUrl();
|
|
3086
|
+
this.updateBrowserUrl(pageName, params, replace);
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Build URL for given page and query parameters
|
|
3090
|
+
*/
|
|
3091
|
+
buildUrl(page, query = {}) {
|
|
3092
|
+
return this.buildPublicUrl(page, query);
|
|
3093
|
+
}
|
|
3094
|
+
/**
|
|
3095
|
+
* Check if two routes match (ignoring query parameters)
|
|
3096
|
+
* @param {string} route1 - First route in any format ("/admin", "?page=admin", "admin")
|
|
3097
|
+
* @param {string} route2 - Second route in any format
|
|
3098
|
+
* @returns {boolean} - True if routes point to the same page
|
|
3099
|
+
*/
|
|
3100
|
+
doRoutesMatch(route1, route2) {
|
|
3101
|
+
if (!route1 || !route2) return false;
|
|
3102
|
+
const { pageName: pageName1 } = this.parseInput(route1);
|
|
3103
|
+
const { pageName: pageName2 } = this.parseInput(route2);
|
|
3104
|
+
return pageName1 === pageName2;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
class Rest {
|
|
3108
|
+
constructor() {
|
|
3109
|
+
this.config = {
|
|
3110
|
+
baseURL: "",
|
|
3111
|
+
timeout: 3e4,
|
|
3112
|
+
headers: {
|
|
3113
|
+
"Content-Type": "application/json",
|
|
3114
|
+
"Accept": "application/json"
|
|
3115
|
+
},
|
|
3116
|
+
trackDevice: true,
|
|
3117
|
+
// New setting to control DUID tracking
|
|
3118
|
+
duidHeader: "X-Mojo-UID",
|
|
3119
|
+
// Header name for the DUID
|
|
3120
|
+
duidTransport: "header"
|
|
3121
|
+
// How to send the DUID: 'payload' or 'header'
|
|
3122
|
+
};
|
|
3123
|
+
this.interceptors = {
|
|
3124
|
+
request: [],
|
|
3125
|
+
response: []
|
|
3126
|
+
};
|
|
3127
|
+
this.duid = null;
|
|
3128
|
+
if (this.config.trackDevice) {
|
|
3129
|
+
this._initializeDuid();
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* Initialize or generate the Device Unique ID (DUID)
|
|
3134
|
+
* @private
|
|
3135
|
+
*/
|
|
3136
|
+
_initializeDuid() {
|
|
3137
|
+
const storageKey = "mojo_device_uid";
|
|
3138
|
+
try {
|
|
3139
|
+
let storedDuid = localStorage.getItem(storageKey);
|
|
3140
|
+
if (storedDuid) {
|
|
3141
|
+
this.duid = storedDuid;
|
|
3142
|
+
} else {
|
|
3143
|
+
this.duid = this._generateDuid();
|
|
3144
|
+
localStorage.setItem(storageKey, this.duid);
|
|
3145
|
+
}
|
|
3146
|
+
} catch (e) {
|
|
3147
|
+
console.error("Could not access localStorage to get/set DUID.", e);
|
|
3148
|
+
this.duid = this._generateDuid();
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Generate a new DUID (UUID v4)
|
|
3153
|
+
* @private
|
|
3154
|
+
* @returns {string} A new UUID
|
|
3155
|
+
*/
|
|
3156
|
+
_generateDuid() {
|
|
3157
|
+
if (crypto && crypto.randomUUID) {
|
|
3158
|
+
return crypto.randomUUID();
|
|
3159
|
+
}
|
|
3160
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
|
3161
|
+
const r = Math.random() * 16 | 0, v = c === "x" ? r : r & 3 | 8;
|
|
3162
|
+
return v.toString(16);
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Configure the REST client
|
|
3167
|
+
* @param {object} config - Configuration object
|
|
3168
|
+
*/
|
|
3169
|
+
configure(config) {
|
|
3170
|
+
if (config.baseUrl) config.baseURL = config.baseUrl;
|
|
3171
|
+
const oldTrackDevice = this.config.trackDevice;
|
|
3172
|
+
this.config = {
|
|
3173
|
+
...this.config,
|
|
3174
|
+
...config,
|
|
3175
|
+
headers: {
|
|
3176
|
+
...this.config.headers,
|
|
3177
|
+
...config.headers
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
if (this.config.trackDevice && !oldTrackDevice) {
|
|
3181
|
+
this._initializeDuid();
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Add request or response interceptor
|
|
3186
|
+
* @param {string} type - 'request' or 'response'
|
|
3187
|
+
* @param {function} interceptor - Interceptor function
|
|
3188
|
+
*/
|
|
3189
|
+
addInterceptor(type, interceptor) {
|
|
3190
|
+
if (this.interceptors[type]) {
|
|
3191
|
+
this.interceptors[type].push(interceptor);
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* Build complete URL
|
|
3196
|
+
* @param {string} url - Endpoint URL
|
|
3197
|
+
* @returns {string} Complete URL
|
|
3198
|
+
*/
|
|
3199
|
+
buildUrl(url) {
|
|
3200
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
3201
|
+
return url;
|
|
3202
|
+
}
|
|
3203
|
+
const baseURL = this.config.baseURL.endsWith("/") ? this.config.baseURL.slice(0, -1) : this.config.baseURL;
|
|
3204
|
+
const endpoint = url.startsWith("/") ? url : `/${url}`;
|
|
3205
|
+
return `${baseURL}${endpoint}`;
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Categorize error into common reason codes
|
|
3209
|
+
* @param {Error} error - The error object
|
|
3210
|
+
* @param {number} status - HTTP status code (if available)
|
|
3211
|
+
* @returns {object} Object with reason code and user-friendly message
|
|
3212
|
+
*/
|
|
3213
|
+
categorizeError(error, status = 0) {
|
|
3214
|
+
if (error.name === "TypeError" && error.message.includes("fetch")) {
|
|
3215
|
+
return {
|
|
3216
|
+
reason: "not_reachable",
|
|
3217
|
+
message: "Service is not reachable - please check your connection"
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
if (error.name === "AbortError") {
|
|
3221
|
+
return {
|
|
3222
|
+
reason: "cancelled",
|
|
3223
|
+
message: "Request was cancelled"
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
if (error.name === "TimeoutError" || error.message.includes("timeout")) {
|
|
3227
|
+
return {
|
|
3228
|
+
reason: "timed_out",
|
|
3229
|
+
message: "Request timed out - please try again"
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
if (status >= 400) {
|
|
3233
|
+
if (status === 400) {
|
|
3234
|
+
return {
|
|
3235
|
+
reason: "bad_request",
|
|
3236
|
+
message: "Invalid request data"
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
if (status === 401) {
|
|
3240
|
+
return {
|
|
3241
|
+
reason: "unauthorized",
|
|
3242
|
+
message: "Authentication required"
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
if (status === 403) {
|
|
3246
|
+
return {
|
|
3247
|
+
reason: "forbidden",
|
|
3248
|
+
message: "Access denied"
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
if (status === 404) {
|
|
3252
|
+
return {
|
|
3253
|
+
reason: "not_found",
|
|
3254
|
+
message: "Resource not found"
|
|
3255
|
+
};
|
|
3256
|
+
}
|
|
3257
|
+
if (status === 409) {
|
|
3258
|
+
return {
|
|
3259
|
+
reason: "conflict",
|
|
3260
|
+
message: "Resource conflict"
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
if (status === 422) {
|
|
3264
|
+
return {
|
|
3265
|
+
reason: "validation_error",
|
|
3266
|
+
message: "Validation failed"
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
if (status === 429) {
|
|
3270
|
+
return {
|
|
3271
|
+
reason: "rate_limited",
|
|
3272
|
+
message: "Too many requests - please wait"
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
if (status >= 500) {
|
|
3276
|
+
return {
|
|
3277
|
+
reason: "server_error",
|
|
3278
|
+
message: "Server error - please try again later"
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
if (status >= 400) {
|
|
3282
|
+
return {
|
|
3283
|
+
reason: "client_error",
|
|
3284
|
+
message: "Request error"
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
if (error.message.includes("CORS")) {
|
|
3289
|
+
return {
|
|
3290
|
+
reason: "cors_error",
|
|
3291
|
+
message: "Cross-origin request blocked"
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
if (error.message.includes("DNS") || error.message.includes("ENOTFOUND")) {
|
|
3295
|
+
return {
|
|
3296
|
+
reason: "dns_error",
|
|
3297
|
+
message: "Unable to resolve server address"
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
return {
|
|
3301
|
+
reason: "unknown_error",
|
|
3302
|
+
message: `Network error: ${error.message}`
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* Build query string from parameters
|
|
3307
|
+
* @param {object} params - Query parameters
|
|
3308
|
+
* @returns {string} Query string
|
|
3309
|
+
*/
|
|
3310
|
+
buildQueryString(params = {}) {
|
|
3311
|
+
const searchParams = new URLSearchParams();
|
|
3312
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
3313
|
+
if (value !== null && value !== void 0) {
|
|
3314
|
+
if (Array.isArray(value)) {
|
|
3315
|
+
value.forEach((v) => searchParams.append(`${key}[]`, v));
|
|
3316
|
+
} else {
|
|
3317
|
+
searchParams.append(key, value);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
});
|
|
3321
|
+
const queryString = searchParams.toString();
|
|
3322
|
+
return queryString ? `?${queryString}` : "";
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Process request through interceptors
|
|
3326
|
+
* @param {object} request - Request configuration
|
|
3327
|
+
* @returns {object} Processed request configuration
|
|
3328
|
+
*/
|
|
3329
|
+
async processRequestInterceptors(request) {
|
|
3330
|
+
let processedRequest = { ...request };
|
|
3331
|
+
for (const interceptor of this.interceptors.request) {
|
|
3332
|
+
try {
|
|
3333
|
+
processedRequest = await interceptor(processedRequest);
|
|
3334
|
+
} catch (error) {
|
|
3335
|
+
console.error("Request interceptor error:", error);
|
|
3336
|
+
throw error;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
return processedRequest;
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* Process response through interceptors
|
|
3343
|
+
* @param {Response} response - Fetch response object
|
|
3344
|
+
* @param {object} request - Original request configuration
|
|
3345
|
+
* @returns {object} Processed response data
|
|
3346
|
+
*/
|
|
3347
|
+
async processResponseInterceptors(response, request) {
|
|
3348
|
+
let responseData = {
|
|
3349
|
+
success: response.ok,
|
|
3350
|
+
status: response.status,
|
|
3351
|
+
statusText: response.statusText,
|
|
3352
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
3353
|
+
data: null,
|
|
3354
|
+
errors: null,
|
|
3355
|
+
message: null,
|
|
3356
|
+
reason: null
|
|
3357
|
+
};
|
|
3358
|
+
try {
|
|
3359
|
+
const contentType = response.headers.get("content-type");
|
|
3360
|
+
if (contentType && contentType.includes("application/json")) {
|
|
3361
|
+
const jsonData = await response.json();
|
|
3362
|
+
responseData.data = jsonData;
|
|
3363
|
+
if (!response.ok) {
|
|
3364
|
+
const errorInfo = this.categorizeError(new Error("HTTP Error"), response.status);
|
|
3365
|
+
responseData.errors = jsonData.errors || {};
|
|
3366
|
+
responseData.message = jsonData.message || errorInfo.message;
|
|
3367
|
+
responseData.reason = errorInfo.reason;
|
|
3368
|
+
}
|
|
3369
|
+
} else {
|
|
3370
|
+
responseData.data = await response.text();
|
|
3371
|
+
if (!response.ok) {
|
|
3372
|
+
const errorInfo = this.categorizeError(new Error("HTTP Error"), response.status);
|
|
3373
|
+
responseData.message = errorInfo.message;
|
|
3374
|
+
responseData.reason = errorInfo.reason;
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
} catch (error) {
|
|
3378
|
+
responseData.errors = { parse: "Failed to parse response" };
|
|
3379
|
+
responseData.message = "Invalid response format";
|
|
3380
|
+
}
|
|
3381
|
+
for (const interceptor of this.interceptors.response) {
|
|
3382
|
+
try {
|
|
3383
|
+
responseData = await interceptor(responseData, request);
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
console.error("Response interceptor error:", error);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
return responseData;
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Make HTTP request
|
|
3392
|
+
* @param {string} method - HTTP method
|
|
3393
|
+
* @param {string} url - Request URL
|
|
3394
|
+
* @param {object} data - Request body data
|
|
3395
|
+
* @param {object} params - Query parameters
|
|
3396
|
+
* @param {object} options - Additional request options
|
|
3397
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3398
|
+
*/
|
|
3399
|
+
async request(method, url, data = null, params = {}, options = {}) {
|
|
3400
|
+
let request = {
|
|
3401
|
+
method: method.toUpperCase(),
|
|
3402
|
+
url: this.buildUrl(url) + this.buildQueryString(params),
|
|
3403
|
+
headers: {
|
|
3404
|
+
...this.config.headers,
|
|
3405
|
+
...options.headers
|
|
3406
|
+
},
|
|
3407
|
+
data,
|
|
3408
|
+
options: {
|
|
3409
|
+
timeout: this.config.timeout,
|
|
3410
|
+
...options
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
request = await this.processRequestInterceptors(request);
|
|
3414
|
+
if (this.config.trackDevice && this.duid) {
|
|
3415
|
+
if (this.config.duidTransport === "header") {
|
|
3416
|
+
request.headers[this.config.duidHeader] = this.duid;
|
|
3417
|
+
} else {
|
|
3418
|
+
if (request.method === "GET") {
|
|
3419
|
+
const url2 = new URL(request.url);
|
|
3420
|
+
url2.searchParams.append("duid", this.duid);
|
|
3421
|
+
request.url = url2.toString();
|
|
3422
|
+
} else if (request.data && typeof request.data === "object" && !(request.data instanceof FormData)) {
|
|
3423
|
+
request.data.duid = this.duid;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
const fetchOptions = {
|
|
3428
|
+
method: request.method,
|
|
3429
|
+
headers: request.headers
|
|
3430
|
+
};
|
|
3431
|
+
const signals = [];
|
|
3432
|
+
if (request.options.timeout) {
|
|
3433
|
+
signals.push(AbortSignal.timeout(request.options.timeout));
|
|
3434
|
+
}
|
|
3435
|
+
if (request.options.signal) {
|
|
3436
|
+
signals.push(request.options.signal);
|
|
3437
|
+
}
|
|
3438
|
+
if (signals.length > 1) {
|
|
3439
|
+
fetchOptions.signal = AbortSignal.any ? AbortSignal.any(signals) : signals[0];
|
|
3440
|
+
} else if (signals.length === 1) {
|
|
3441
|
+
fetchOptions.signal = signals[0];
|
|
3442
|
+
}
|
|
3443
|
+
if (request.data && ["POST", "PUT", "PATCH"].includes(request.method)) {
|
|
3444
|
+
if (request.data instanceof FormData) {
|
|
3445
|
+
fetchOptions.body = request.data;
|
|
3446
|
+
delete fetchOptions.headers["Content-Type"];
|
|
3447
|
+
} else if (typeof request.data === "object") {
|
|
3448
|
+
fetchOptions.body = JSON.stringify(request.data);
|
|
3449
|
+
} else {
|
|
3450
|
+
fetchOptions.body = request.data;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
try {
|
|
3454
|
+
const response = await fetch(request.url, fetchOptions);
|
|
3455
|
+
const responseData = await this.processResponseInterceptors(response, request);
|
|
3456
|
+
return responseData;
|
|
3457
|
+
} catch (error) {
|
|
3458
|
+
if (error.name === "AbortError") {
|
|
3459
|
+
throw error;
|
|
3460
|
+
}
|
|
3461
|
+
const errorInfo = this.categorizeError(error);
|
|
3462
|
+
const errorResponse = {
|
|
3463
|
+
success: false,
|
|
3464
|
+
status: 0,
|
|
3465
|
+
statusText: "Network Error",
|
|
3466
|
+
headers: {},
|
|
3467
|
+
data: null,
|
|
3468
|
+
errors: { network: error.message },
|
|
3469
|
+
message: errorInfo.message,
|
|
3470
|
+
reason: errorInfo.reason
|
|
3471
|
+
};
|
|
3472
|
+
const mockResponse = {
|
|
3473
|
+
ok: false,
|
|
3474
|
+
status: 0,
|
|
3475
|
+
statusText: "Network Error",
|
|
3476
|
+
headers: new Headers(),
|
|
3477
|
+
json: async () => ({}),
|
|
3478
|
+
text: async () => ""
|
|
3479
|
+
};
|
|
3480
|
+
await this.processResponseInterceptors(mockResponse, request);
|
|
3481
|
+
return errorResponse;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
/**
|
|
3485
|
+
* GET request
|
|
3486
|
+
* @param {string} url - Request URL
|
|
3487
|
+
* @param {object} params - Query parameters
|
|
3488
|
+
* @param {object} options - Request options
|
|
3489
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3490
|
+
*/
|
|
3491
|
+
async GET(url, params = {}, options = {}) {
|
|
3492
|
+
return this.request("GET", url, null, params, options);
|
|
3493
|
+
}
|
|
3494
|
+
/**
|
|
3495
|
+
* POST request
|
|
3496
|
+
* @param {string} url - Request URL
|
|
3497
|
+
* @param {object} data - Request body data
|
|
3498
|
+
* @param {object} params - Query parameters
|
|
3499
|
+
* @param {object} options - Request options
|
|
3500
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3501
|
+
*/
|
|
3502
|
+
async POST(url, data = {}, params = {}, options = {}) {
|
|
3503
|
+
return this.request("POST", url, data, params, options);
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* PUT request
|
|
3507
|
+
* @param {string} url - Request URL
|
|
3508
|
+
* @param {object} data - Request body data
|
|
3509
|
+
* @param {object} params - Query parameters
|
|
3510
|
+
* @param {object} options - Request options
|
|
3511
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3512
|
+
*/
|
|
3513
|
+
async PUT(url, data = {}, params = {}, options = {}) {
|
|
3514
|
+
return this.request("PUT", url, data, params, options);
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* PATCH request
|
|
3518
|
+
* @param {string} url - Request URL
|
|
3519
|
+
* @param {object} data - Request body data
|
|
3520
|
+
* @param {object} params - Query parameters
|
|
3521
|
+
* @param {object} options - Request options
|
|
3522
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3523
|
+
*/
|
|
3524
|
+
async PATCH(url, data = {}, params = {}, options = {}) {
|
|
3525
|
+
return this.request("PATCH", url, data, params, options);
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* DELETE request
|
|
3529
|
+
* @param {string} url - Request URL
|
|
3530
|
+
* @param {object} params - Query parameters
|
|
3531
|
+
* @param {object} options - Request options
|
|
3532
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3533
|
+
*/
|
|
3534
|
+
async DELETE(url, params = {}, options = {}) {
|
|
3535
|
+
return this.request("DELETE", url, null, params, options);
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* Upload file with raw PUT request (compatible with legacy backend)
|
|
3539
|
+
* @param {string} url - Upload URL
|
|
3540
|
+
* @param {File} file - Single file to upload
|
|
3541
|
+
* @param {object} options - Request options
|
|
3542
|
+
* @param {function} options.onProgress - Progress callback function(event)
|
|
3543
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3544
|
+
*/
|
|
3545
|
+
async upload(url, file, options = {}) {
|
|
3546
|
+
return new Promise((resolve, reject) => {
|
|
3547
|
+
if (!(file instanceof File)) {
|
|
3548
|
+
reject(new Error("Only single File objects are supported for legacy backend compatibility"));
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
const xhr = new XMLHttpRequest();
|
|
3552
|
+
if (options.onProgress && typeof options.onProgress === "function") {
|
|
3553
|
+
xhr.upload.onprogress = options.onProgress;
|
|
3554
|
+
}
|
|
3555
|
+
xhr.onload = function() {
|
|
3556
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
3557
|
+
resolve({
|
|
3558
|
+
data: xhr.response,
|
|
3559
|
+
status: xhr.status,
|
|
3560
|
+
statusText: xhr.statusText,
|
|
3561
|
+
xhr
|
|
3562
|
+
});
|
|
3563
|
+
} else {
|
|
3564
|
+
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
3565
|
+
}
|
|
3566
|
+
};
|
|
3567
|
+
xhr.onerror = function() {
|
|
3568
|
+
reject(new Error("Upload failed: Network error"));
|
|
3569
|
+
};
|
|
3570
|
+
xhr.ontimeout = function() {
|
|
3571
|
+
reject(new Error("Upload failed: Timeout"));
|
|
3572
|
+
};
|
|
3573
|
+
xhr.open("PUT", url);
|
|
3574
|
+
xhr.setRequestHeader("Content-Type", file.type);
|
|
3575
|
+
if (options.timeout) {
|
|
3576
|
+
xhr.timeout = options.timeout;
|
|
3577
|
+
}
|
|
3578
|
+
xhr.send(file);
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
/**
|
|
3582
|
+
* Upload multiple files with multipart/form-data (for modern backends)
|
|
3583
|
+
* @param {string} url - Upload URL
|
|
3584
|
+
* @param {File|FileList|FormData} files - File(s) to upload
|
|
3585
|
+
* @param {object} additionalData - Additional form fields
|
|
3586
|
+
* @param {object} options - Request options
|
|
3587
|
+
* @returns {Promise} Promise that resolves with response data
|
|
3588
|
+
*/
|
|
3589
|
+
async uploadMultipart(url, files, additionalData = {}, options = {}) {
|
|
3590
|
+
const formData = new FormData();
|
|
3591
|
+
if (files instanceof FileList) {
|
|
3592
|
+
Array.from(files).forEach((file, index) => {
|
|
3593
|
+
formData.append(`file_${index}`, file);
|
|
3594
|
+
});
|
|
3595
|
+
} else if (files instanceof File) {
|
|
3596
|
+
formData.append("file", files);
|
|
3597
|
+
} else if (files instanceof FormData) {
|
|
3598
|
+
return this.POST(url, files, {}, options);
|
|
3599
|
+
}
|
|
3600
|
+
Object.entries(additionalData).forEach(([key, value]) => {
|
|
3601
|
+
formData.append(key, value);
|
|
3602
|
+
});
|
|
3603
|
+
return this.POST(url, formData, {}, options);
|
|
3604
|
+
}
|
|
3605
|
+
/**
|
|
3606
|
+
* Set authentication token
|
|
3607
|
+
* @param {string} token - JWT or API token
|
|
3608
|
+
* @param {string} type - Token type ('Bearer', 'Token', etc.)
|
|
3609
|
+
*/
|
|
3610
|
+
setAuthToken(token, type = "Bearer") {
|
|
3611
|
+
if (token) {
|
|
3612
|
+
this.config.headers["Authorization"] = `${type} ${token}`;
|
|
3613
|
+
} else {
|
|
3614
|
+
delete this.config.headers["Authorization"];
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Clear authentication
|
|
3619
|
+
*/
|
|
3620
|
+
clearAuth() {
|
|
3621
|
+
delete this.config.headers["Authorization"];
|
|
3622
|
+
}
|
|
3623
|
+
/**
|
|
3624
|
+
* Check if an error is retryable (network issues that might resolve)
|
|
3625
|
+
* @param {object} response - Response object with reason field
|
|
3626
|
+
* @returns {boolean} True if error can be retried
|
|
3627
|
+
*/
|
|
3628
|
+
isRetryableError(response) {
|
|
3629
|
+
const retryableReasons = [
|
|
3630
|
+
"not_reachable",
|
|
3631
|
+
"timed_out",
|
|
3632
|
+
"server_error",
|
|
3633
|
+
"dns_error"
|
|
3634
|
+
];
|
|
3635
|
+
return retryableReasons.includes(response.reason);
|
|
3636
|
+
}
|
|
3637
|
+
/**
|
|
3638
|
+
* Check if error requires authentication
|
|
3639
|
+
* @param {object} response - Response object with reason field
|
|
3640
|
+
* @returns {boolean} True if authentication is required
|
|
3641
|
+
*/
|
|
3642
|
+
requiresAuth(response) {
|
|
3643
|
+
return response.reason === "unauthorized";
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Check if error is network-related
|
|
3647
|
+
* @param {object} response - Response object with reason field
|
|
3648
|
+
* @returns {boolean} True if it's a network error
|
|
3649
|
+
*/
|
|
3650
|
+
isNetworkError(response) {
|
|
3651
|
+
const networkReasons = [
|
|
3652
|
+
"not_reachable",
|
|
3653
|
+
"timed_out",
|
|
3654
|
+
"cancelled",
|
|
3655
|
+
"cors_error",
|
|
3656
|
+
"dns_error"
|
|
3657
|
+
];
|
|
3658
|
+
return networkReasons.includes(response.reason);
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Get user-friendly error message based on reason
|
|
3662
|
+
* @param {object} response - Response object with reason field
|
|
3663
|
+
* @returns {string} User-friendly error message
|
|
3664
|
+
*/
|
|
3665
|
+
getUserMessage(response) {
|
|
3666
|
+
if (response.message) {
|
|
3667
|
+
return response.message;
|
|
3668
|
+
}
|
|
3669
|
+
const messages = {
|
|
3670
|
+
"not_reachable": "Unable to connect to the server. Please check your internet connection.",
|
|
3671
|
+
"timed_out": "The request took too long. Please try again.",
|
|
3672
|
+
"cancelled": "The request was cancelled.",
|
|
3673
|
+
"unauthorized": "Please log in to continue.",
|
|
3674
|
+
"forbidden": "You don't have permission to perform this action.",
|
|
3675
|
+
"not_found": "The requested resource was not found.",
|
|
3676
|
+
"validation_error": "Please check your input and try again.",
|
|
3677
|
+
"rate_limited": "Too many requests. Please wait a moment before trying again.",
|
|
3678
|
+
"server_error": "Server error. Please try again later.",
|
|
3679
|
+
"cors_error": "Access blocked by security policy.",
|
|
3680
|
+
"dns_error": "Unable to reach the server.",
|
|
3681
|
+
"unknown_error": "An unexpected error occurred."
|
|
3682
|
+
};
|
|
3683
|
+
return messages[response.reason] || "An error occurred. Please try again.";
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
const rest = new Rest();
|
|
3687
|
+
class EventBus {
|
|
3688
|
+
constructor() {
|
|
3689
|
+
this.listeners = {};
|
|
3690
|
+
this.onceListeners = {};
|
|
3691
|
+
this.maxListeners = 100;
|
|
3692
|
+
this.debugMode = false;
|
|
3693
|
+
this.eventStats = {};
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* Add event listener
|
|
3697
|
+
* @param {string|Array<string>} event - Event name or array of event names
|
|
3698
|
+
* @param {function} callback - Event callback
|
|
3699
|
+
* @returns {EventBus} This instance for chaining
|
|
3700
|
+
*/
|
|
3701
|
+
on(event, callback) {
|
|
3702
|
+
if (typeof callback !== "function") {
|
|
3703
|
+
throw new Error("Callback must be a function");
|
|
3704
|
+
}
|
|
3705
|
+
if (Array.isArray(event)) {
|
|
3706
|
+
event.forEach((eventName) => this.on(eventName, callback));
|
|
3707
|
+
return this;
|
|
3708
|
+
}
|
|
3709
|
+
if (!this.listeners[event]) {
|
|
3710
|
+
this.listeners[event] = [];
|
|
3711
|
+
}
|
|
3712
|
+
if (this.listeners[event].length >= this.maxListeners) {
|
|
3713
|
+
console.warn(`Max listeners (${this.maxListeners}) exceeded for event: ${event}`);
|
|
3714
|
+
}
|
|
3715
|
+
this.listeners[event].push(callback);
|
|
3716
|
+
return this;
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* Add one-time event listener
|
|
3720
|
+
* @param {string|Array<string>} event - Event name or array of event names
|
|
3721
|
+
* @param {function} callback - Event callback
|
|
3722
|
+
* @returns {EventBus} This instance for chaining
|
|
3723
|
+
*/
|
|
3724
|
+
once(event, callback) {
|
|
3725
|
+
if (typeof callback !== "function") {
|
|
3726
|
+
throw new Error("Callback must be a function");
|
|
3727
|
+
}
|
|
3728
|
+
if (Array.isArray(event)) {
|
|
3729
|
+
event.forEach((eventName) => this.once(eventName, callback));
|
|
3730
|
+
return this;
|
|
3731
|
+
}
|
|
3732
|
+
if (!this.onceListeners[event]) {
|
|
3733
|
+
this.onceListeners[event] = [];
|
|
3734
|
+
}
|
|
3735
|
+
this.onceListeners[event].push(callback);
|
|
3736
|
+
return this;
|
|
3737
|
+
}
|
|
3738
|
+
/**
|
|
3739
|
+
* Remove event listener
|
|
3740
|
+
* @param {string|Array<string>} event - Event name or array of event names
|
|
3741
|
+
* @param {function} callback - Event callback to remove
|
|
3742
|
+
* @returns {EventBus} This instance for chaining
|
|
3743
|
+
*/
|
|
3744
|
+
off(event, callback) {
|
|
3745
|
+
if (Array.isArray(event)) {
|
|
3746
|
+
event.forEach((eventName) => this.off(eventName, callback));
|
|
3747
|
+
return this;
|
|
3748
|
+
}
|
|
3749
|
+
if (!callback) {
|
|
3750
|
+
delete this.listeners[event];
|
|
3751
|
+
delete this.onceListeners[event];
|
|
3752
|
+
return this;
|
|
3753
|
+
}
|
|
3754
|
+
if (this.listeners[event]) {
|
|
3755
|
+
const index = this.listeners[event].indexOf(callback);
|
|
3756
|
+
if (index !== -1) {
|
|
3757
|
+
this.listeners[event].splice(index, 1);
|
|
3758
|
+
if (this.listeners[event].length === 0) {
|
|
3759
|
+
delete this.listeners[event];
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
if (this.onceListeners[event]) {
|
|
3764
|
+
const index = this.onceListeners[event].indexOf(callback);
|
|
3765
|
+
if (index !== -1) {
|
|
3766
|
+
this.onceListeners[event].splice(index, 1);
|
|
3767
|
+
if (this.onceListeners[event].length === 0) {
|
|
3768
|
+
delete this.onceListeners[event];
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
return this;
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* Emit event to all listeners
|
|
3776
|
+
* @param {string} event - Event name
|
|
3777
|
+
* @param {*} data - Event data
|
|
3778
|
+
* @returns {EventBus} This instance for chaining
|
|
3779
|
+
*/
|
|
3780
|
+
emit(event, data) {
|
|
3781
|
+
this.updateEventStats(event);
|
|
3782
|
+
if (this.debugMode) {
|
|
3783
|
+
console.log(`[EventBus] Emitting: ${event}`, data);
|
|
3784
|
+
}
|
|
3785
|
+
const listeners = [];
|
|
3786
|
+
if (this.listeners[event]) {
|
|
3787
|
+
listeners.push(...this.listeners[event]);
|
|
3788
|
+
}
|
|
3789
|
+
if (this.listeners["*"]) {
|
|
3790
|
+
listeners.push(...this.listeners["*"]);
|
|
3791
|
+
}
|
|
3792
|
+
if (this.onceListeners[event]) {
|
|
3793
|
+
listeners.push(...this.onceListeners[event]);
|
|
3794
|
+
delete this.onceListeners[event];
|
|
3795
|
+
}
|
|
3796
|
+
if (this.onceListeners["*"]) {
|
|
3797
|
+
listeners.push(...this.onceListeners["*"]);
|
|
3798
|
+
delete this.onceListeners["*"];
|
|
3799
|
+
}
|
|
3800
|
+
if (this.debugMode && listeners.length > 0) {
|
|
3801
|
+
console.log(`[EventBus] ${listeners.length} listener(s) for '${event}'`);
|
|
3802
|
+
}
|
|
3803
|
+
listeners.forEach((callback) => {
|
|
3804
|
+
try {
|
|
3805
|
+
callback(data, event);
|
|
3806
|
+
} catch (error) {
|
|
3807
|
+
console.error(`Error in event listener for '${event}':`, error);
|
|
3808
|
+
this.emitError(error, event, callback);
|
|
3809
|
+
}
|
|
3810
|
+
});
|
|
3811
|
+
return this;
|
|
3812
|
+
}
|
|
3813
|
+
/**
|
|
3814
|
+
* Emit event asynchronously
|
|
3815
|
+
* @param {string} event - Event name
|
|
3816
|
+
* @param {*} data - Event data
|
|
3817
|
+
* @returns {Promise<EventBus>} Promise that resolves with this instance
|
|
3818
|
+
*/
|
|
3819
|
+
async emitAsync(event, data) {
|
|
3820
|
+
const listeners = [];
|
|
3821
|
+
if (this.listeners[event]) {
|
|
3822
|
+
listeners.push(...this.listeners[event]);
|
|
3823
|
+
}
|
|
3824
|
+
if (this.listeners["*"]) {
|
|
3825
|
+
listeners.push(...this.listeners["*"]);
|
|
3826
|
+
}
|
|
3827
|
+
if (this.onceListeners[event]) {
|
|
3828
|
+
listeners.push(...this.onceListeners[event]);
|
|
3829
|
+
delete this.onceListeners[event];
|
|
3830
|
+
}
|
|
3831
|
+
if (this.onceListeners["*"]) {
|
|
3832
|
+
listeners.push(...this.onceListeners["*"]);
|
|
3833
|
+
delete this.onceListeners["*"];
|
|
3834
|
+
}
|
|
3835
|
+
const promises = listeners.map((callback) => {
|
|
3836
|
+
return new Promise((resolve) => {
|
|
3837
|
+
try {
|
|
3838
|
+
const result = callback(data, event);
|
|
3839
|
+
resolve(result);
|
|
3840
|
+
} catch (error) {
|
|
3841
|
+
console.error(`Error in async event listener for '${event}':`, error);
|
|
3842
|
+
this.emitError(error, event, callback);
|
|
3843
|
+
resolve();
|
|
3844
|
+
}
|
|
3845
|
+
});
|
|
3846
|
+
});
|
|
3847
|
+
await Promise.all(promises);
|
|
3848
|
+
return this;
|
|
3849
|
+
}
|
|
3850
|
+
/**
|
|
3851
|
+
* Remove all listeners for all events
|
|
3852
|
+
* @returns {EventBus} This instance for chaining
|
|
3853
|
+
*/
|
|
3854
|
+
removeAllListeners() {
|
|
3855
|
+
this.listeners = {};
|
|
3856
|
+
this.onceListeners = {};
|
|
3857
|
+
return this;
|
|
3858
|
+
}
|
|
3859
|
+
/**
|
|
3860
|
+
* Get listener count for an event
|
|
3861
|
+
* @param {string} event - Event name
|
|
3862
|
+
* @returns {number} Number of listeners
|
|
3863
|
+
*/
|
|
3864
|
+
listenerCount(event) {
|
|
3865
|
+
const regularCount = this.listeners[event] ? this.listeners[event].length : 0;
|
|
3866
|
+
const onceCount = this.onceListeners[event] ? this.onceListeners[event].length : 0;
|
|
3867
|
+
return regularCount + onceCount;
|
|
3868
|
+
}
|
|
3869
|
+
/**
|
|
3870
|
+
* Get all event names that have listeners
|
|
3871
|
+
* @returns {Array<string>} Array of event names
|
|
3872
|
+
*/
|
|
3873
|
+
eventNames() {
|
|
3874
|
+
const regularEvents = Object.keys(this.listeners);
|
|
3875
|
+
const onceEvents = Object.keys(this.onceListeners);
|
|
3876
|
+
return [.../* @__PURE__ */ new Set([...regularEvents, ...onceEvents])];
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Set maximum number of listeners per event
|
|
3880
|
+
* @param {number} max - Maximum listeners
|
|
3881
|
+
* @returns {EventBus} This instance for chaining
|
|
3882
|
+
*/
|
|
3883
|
+
setMaxListeners(max) {
|
|
3884
|
+
if (typeof max !== "number" || max < 0) {
|
|
3885
|
+
throw new Error("Max listeners must be a non-negative number");
|
|
3886
|
+
}
|
|
3887
|
+
this.maxListeners = max;
|
|
3888
|
+
return this;
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3891
|
+
* Create a namespaced event bus
|
|
3892
|
+
* @param {string} namespace - Namespace prefix
|
|
3893
|
+
* @returns {object} Namespaced event bus methods
|
|
3894
|
+
*/
|
|
3895
|
+
namespace(namespace) {
|
|
3896
|
+
const prefixEvent = (event) => `${namespace}:${event}`;
|
|
3897
|
+
return {
|
|
3898
|
+
on: (event, callback) => this.on(prefixEvent(event), callback),
|
|
3899
|
+
once: (event, callback) => this.once(prefixEvent(event), callback),
|
|
3900
|
+
off: (event, callback) => this.off(prefixEvent(event), callback),
|
|
3901
|
+
emit: (event, data) => this.emit(prefixEvent(event), data),
|
|
3902
|
+
emitAsync: (event, data) => this.emitAsync(prefixEvent(event), data)
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
/**
|
|
3906
|
+
* Add middleware to intercept events
|
|
3907
|
+
* @param {function} middleware - Middleware function
|
|
3908
|
+
* @returns {EventBus} This instance for chaining
|
|
3909
|
+
*/
|
|
3910
|
+
use(middleware) {
|
|
3911
|
+
if (typeof middleware !== "function") {
|
|
3912
|
+
throw new Error("Middleware must be a function");
|
|
3913
|
+
}
|
|
3914
|
+
const originalEmit = this.emit;
|
|
3915
|
+
this.emit = (event, data) => {
|
|
3916
|
+
try {
|
|
3917
|
+
const result = middleware(event, data);
|
|
3918
|
+
if (result === false) {
|
|
3919
|
+
return this;
|
|
3920
|
+
}
|
|
3921
|
+
const finalData = result !== void 0 ? result : data;
|
|
3922
|
+
return originalEmit.call(this, event, finalData);
|
|
3923
|
+
} catch (error) {
|
|
3924
|
+
console.error("Error in event middleware:", error);
|
|
3925
|
+
return originalEmit.call(this, event, data);
|
|
3926
|
+
}
|
|
3927
|
+
};
|
|
3928
|
+
return this;
|
|
3929
|
+
}
|
|
3930
|
+
/**
|
|
3931
|
+
* Emit error event for listener errors
|
|
3932
|
+
* @private
|
|
3933
|
+
* @param {Error} error - The error that occurred
|
|
3934
|
+
* @param {string} originalEvent - The event that caused the error
|
|
3935
|
+
* @param {function} callback - The callback that threw the error
|
|
3936
|
+
*/
|
|
3937
|
+
emitError(error, originalEvent, callback) {
|
|
3938
|
+
if (originalEvent === "error") {
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
setTimeout(() => {
|
|
3942
|
+
this.emit("error", {
|
|
3943
|
+
error,
|
|
3944
|
+
originalEvent,
|
|
3945
|
+
callback: callback.toString()
|
|
3946
|
+
});
|
|
3947
|
+
}, 0);
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Create a promise that resolves when an event is emitted
|
|
3951
|
+
* @param {string} event - Event name
|
|
3952
|
+
* @param {number} timeout - Optional timeout in milliseconds
|
|
3953
|
+
* @returns {Promise} Promise that resolves with event data
|
|
3954
|
+
*/
|
|
3955
|
+
waitFor(event, timeout = null) {
|
|
3956
|
+
return new Promise((resolve, reject) => {
|
|
3957
|
+
let timeoutId = null;
|
|
3958
|
+
const cleanup = () => {
|
|
3959
|
+
if (timeoutId) {
|
|
3960
|
+
clearTimeout(timeoutId);
|
|
3961
|
+
}
|
|
3962
|
+
};
|
|
3963
|
+
const listener = (data) => {
|
|
3964
|
+
cleanup();
|
|
3965
|
+
resolve(data);
|
|
3966
|
+
};
|
|
3967
|
+
this.once(event, listener);
|
|
3968
|
+
if (timeout) {
|
|
3969
|
+
timeoutId = setTimeout(() => {
|
|
3970
|
+
this.off(event, listener);
|
|
3971
|
+
reject(new Error(`Timeout waiting for event: ${event}`));
|
|
3972
|
+
}, timeout);
|
|
3973
|
+
}
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Enable/disable debug mode for detailed event logging
|
|
3978
|
+
* @param {boolean} enable - Whether to enable debug mode
|
|
3979
|
+
* @returns {EventBus} This instance for chaining
|
|
3980
|
+
*/
|
|
3981
|
+
debug(enable = true) {
|
|
3982
|
+
this.debugMode = enable;
|
|
3983
|
+
if (enable) {
|
|
3984
|
+
console.log("[EventBus] Debug mode enabled");
|
|
3985
|
+
} else {
|
|
3986
|
+
console.log("[EventBus] Debug mode disabled");
|
|
3987
|
+
}
|
|
3988
|
+
return this;
|
|
3989
|
+
}
|
|
3990
|
+
/**
|
|
3991
|
+
* Get statistics about the event bus
|
|
3992
|
+
* @returns {object} Statistics object
|
|
3993
|
+
*/
|
|
3994
|
+
getStats() {
|
|
3995
|
+
const eventNames = this.eventNames();
|
|
3996
|
+
const stats = {
|
|
3997
|
+
totalEvents: eventNames.length,
|
|
3998
|
+
totalListeners: 0,
|
|
3999
|
+
events: {},
|
|
4000
|
+
emissions: { ...this.eventStats }
|
|
4001
|
+
};
|
|
4002
|
+
eventNames.forEach((event) => {
|
|
4003
|
+
const count = this.listenerCount(event);
|
|
4004
|
+
stats.events[event] = count;
|
|
4005
|
+
stats.totalListeners += count;
|
|
4006
|
+
});
|
|
4007
|
+
return stats;
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Update event emission statistics
|
|
4011
|
+
* @private
|
|
4012
|
+
* @param {string} event - Event name
|
|
4013
|
+
*/
|
|
4014
|
+
updateEventStats(event) {
|
|
4015
|
+
if (!this.eventStats[event]) {
|
|
4016
|
+
this.eventStats[event] = {
|
|
4017
|
+
count: 0,
|
|
4018
|
+
firstEmission: Date.now(),
|
|
4019
|
+
lastEmission: null
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
this.eventStats[event].count++;
|
|
4023
|
+
this.eventStats[event].lastEmission = Date.now();
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Get detailed statistics for a specific event
|
|
4027
|
+
* @param {string} event - Event name
|
|
4028
|
+
* @returns {Object} Event statistics
|
|
4029
|
+
*/
|
|
4030
|
+
getEventStats(event) {
|
|
4031
|
+
const stats = this.eventStats[event];
|
|
4032
|
+
if (!stats) {
|
|
4033
|
+
return null;
|
|
4034
|
+
}
|
|
4035
|
+
return {
|
|
4036
|
+
...stats,
|
|
4037
|
+
listenerCount: this.listenerCount(event),
|
|
4038
|
+
avgEmissionsPerMinute: this.calculateEmissionRate(stats)
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Calculate emission rate for an event
|
|
4043
|
+
* @private
|
|
4044
|
+
* @param {Object} stats - Event statistics
|
|
4045
|
+
* @returns {number} Emissions per minute
|
|
4046
|
+
*/
|
|
4047
|
+
calculateEmissionRate(stats) {
|
|
4048
|
+
if (!stats.firstEmission || !stats.lastEmission) {
|
|
4049
|
+
return 0;
|
|
4050
|
+
}
|
|
4051
|
+
const durationMs = stats.lastEmission - stats.firstEmission;
|
|
4052
|
+
if (durationMs === 0) {
|
|
4053
|
+
return 0;
|
|
4054
|
+
}
|
|
4055
|
+
const durationMinutes = durationMs / (1e3 * 60);
|
|
4056
|
+
return Math.round(stats.count / durationMinutes * 100) / 100;
|
|
4057
|
+
}
|
|
4058
|
+
/**
|
|
4059
|
+
* Reset event statistics
|
|
4060
|
+
* @returns {EventBus} This instance for chaining
|
|
4061
|
+
*/
|
|
4062
|
+
resetStats() {
|
|
4063
|
+
this.eventStats = {};
|
|
4064
|
+
return this;
|
|
4065
|
+
}
|
|
4066
|
+
/**
|
|
4067
|
+
* Get a summary of the most active events
|
|
4068
|
+
* @param {number} limit - Number of events to return (default: 10)
|
|
4069
|
+
* @returns {Array} Array of event statistics sorted by emission count
|
|
4070
|
+
*/
|
|
4071
|
+
getTopEvents(limit = 10) {
|
|
4072
|
+
return Object.entries(this.eventStats).map(([event, stats]) => ({
|
|
4073
|
+
event,
|
|
4074
|
+
count: stats.count,
|
|
4075
|
+
rate: this.calculateEmissionRate(stats),
|
|
4076
|
+
listeners: this.listenerCount(event)
|
|
4077
|
+
})).sort((a, b) => b.count - a.count).slice(0, limit);
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* Log comprehensive debug information
|
|
4081
|
+
* @returns {EventBus} This instance for chaining
|
|
4082
|
+
*/
|
|
4083
|
+
debugInfo() {
|
|
4084
|
+
console.group("[EventBus] Debug Information");
|
|
4085
|
+
console.log("Debug Mode:", this.debugMode);
|
|
4086
|
+
console.log("Max Listeners:", this.maxListeners);
|
|
4087
|
+
const stats = this.getStats();
|
|
4088
|
+
console.log("Total Events:", stats.totalEvents);
|
|
4089
|
+
console.log("Total Listeners:", stats.totalListeners);
|
|
4090
|
+
if (Object.keys(this.eventStats).length > 0) {
|
|
4091
|
+
console.log("Top Events:", this.getTopEvents(5));
|
|
4092
|
+
}
|
|
4093
|
+
console.groupEnd();
|
|
4094
|
+
return this;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
class WebApp {
|
|
4098
|
+
constructor(config = {}) {
|
|
4099
|
+
this.initPluginRegistry();
|
|
4100
|
+
this.name = config.name || "MOJO App";
|
|
4101
|
+
this.version = config.version || "1.0.0";
|
|
4102
|
+
this.debug = config.debug || false;
|
|
4103
|
+
this.container = config.container || "#app";
|
|
4104
|
+
this.layoutType = config.layout || "portal";
|
|
4105
|
+
this.layoutConfig = config.layoutConfig || {};
|
|
4106
|
+
if (config.sidebar) {
|
|
4107
|
+
this.layoutConfig.sidebarConfig = config.sidebar;
|
|
4108
|
+
}
|
|
4109
|
+
if (config.topbar) {
|
|
4110
|
+
this.layoutConfig.topbarConfig = config.topbar;
|
|
4111
|
+
}
|
|
4112
|
+
this.layout = null;
|
|
4113
|
+
this.layoutConfig.containerId = this.container || this.containerId || "#app";
|
|
4114
|
+
this.pageContainer = config.pageContainer || "#page-container";
|
|
4115
|
+
this.basePath = config.basePath || "";
|
|
4116
|
+
this.routerMode = config.routerMode || config.router?.mode || "param";
|
|
4117
|
+
this.basePath = config.basePath || config.router?.base || "";
|
|
4118
|
+
this.defaultRoute = config.defaultRoute || "home";
|
|
4119
|
+
this.session = config.session || {};
|
|
4120
|
+
this.router = null;
|
|
4121
|
+
this.navigation = config.navigation || {};
|
|
4122
|
+
this.state = {
|
|
4123
|
+
currentPage: null,
|
|
4124
|
+
previousPage: null,
|
|
4125
|
+
loading: false
|
|
4126
|
+
};
|
|
4127
|
+
this.events = new EventBus();
|
|
4128
|
+
this.rest = rest;
|
|
4129
|
+
if (config.api) {
|
|
4130
|
+
this.rest.configure(config.api);
|
|
4131
|
+
}
|
|
4132
|
+
this.router = new Router({
|
|
4133
|
+
mode: this.routerMode === "param" ? "params" : this.routerMode,
|
|
4134
|
+
basePath: this.basePath,
|
|
4135
|
+
defaultRoute: this.defaultRoute,
|
|
4136
|
+
eventEmitter: this.events
|
|
4137
|
+
});
|
|
4138
|
+
this.events.on("route:changed", async (routeInfo) => {
|
|
4139
|
+
const { pageName, params, query } = routeInfo;
|
|
4140
|
+
await this.showPage(pageName, query, params, { fromRouter: true });
|
|
4141
|
+
});
|
|
4142
|
+
if (typeof window !== "undefined") {
|
|
4143
|
+
window.MOJO = window.MOJO || {};
|
|
4144
|
+
window.MOJO.router = this.router;
|
|
4145
|
+
}
|
|
4146
|
+
this.pageCache = /* @__PURE__ */ new Map();
|
|
4147
|
+
this.pageClasses = /* @__PURE__ */ new Map();
|
|
4148
|
+
this.componentClasses = /* @__PURE__ */ new Map();
|
|
4149
|
+
this.modelClasses = /* @__PURE__ */ new Map();
|
|
4150
|
+
this.currentPage = null;
|
|
4151
|
+
this.isStarted = false;
|
|
4152
|
+
if (window.matchUUID) {
|
|
4153
|
+
window[window.matchUUID] = this;
|
|
4154
|
+
} else if (window.MOJO) {
|
|
4155
|
+
window.MOJO.app = this;
|
|
4156
|
+
} else {
|
|
4157
|
+
window.__app__ = this;
|
|
4158
|
+
}
|
|
4159
|
+
console.log(`WebApp initialized: ${this.name} v${this.version}`);
|
|
4160
|
+
}
|
|
4161
|
+
/**
|
|
4162
|
+
* Start the application
|
|
4163
|
+
*/
|
|
4164
|
+
async start() {
|
|
4165
|
+
if (this.isStarted) {
|
|
4166
|
+
console.warn("WebApp already started");
|
|
4167
|
+
return;
|
|
4168
|
+
}
|
|
4169
|
+
try {
|
|
4170
|
+
console.log(`Starting ${this.name}...`);
|
|
4171
|
+
this.setupPageContainer();
|
|
4172
|
+
this.validateDefaultRoute();
|
|
4173
|
+
console.log("Setting up router...");
|
|
4174
|
+
await this.setupRouter();
|
|
4175
|
+
this.isStarted = true;
|
|
4176
|
+
this.events.emit("app:ready", { app: this });
|
|
4177
|
+
console.log(`✅ ${this.name} started successfully`);
|
|
4178
|
+
} catch (error) {
|
|
4179
|
+
console.error(`Failed to start ${this.name}:`, error);
|
|
4180
|
+
this.showError("Failed to start application");
|
|
4181
|
+
throw error;
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Setup router with configuration
|
|
4186
|
+
*/
|
|
4187
|
+
async setupRouter() {
|
|
4188
|
+
if (!this.router) {
|
|
4189
|
+
console.error("Router not initialized");
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
4192
|
+
this.events.on("route:notfound", async (info) => {
|
|
4193
|
+
console.warn(`Route not found: ${info.path}`);
|
|
4194
|
+
this._show404(info.path);
|
|
4195
|
+
});
|
|
4196
|
+
this.router.start();
|
|
4197
|
+
console.log(`Router started in ${this.routerMode} mode`);
|
|
4198
|
+
}
|
|
4199
|
+
/**
|
|
4200
|
+
* Setup layout based on configuration
|
|
4201
|
+
*/
|
|
4202
|
+
setupPageContainer() {
|
|
4203
|
+
const container = typeof this.container === "string" ? document.querySelector(this.container) : this.container;
|
|
4204
|
+
if (container && !container.querySelector("#page-container")) {
|
|
4205
|
+
container.innerHTML = '<div id="page-container"></div>';
|
|
4206
|
+
}
|
|
4207
|
+
this.pageContainer = "#page-container";
|
|
4208
|
+
}
|
|
4209
|
+
/**
|
|
4210
|
+
* Register a page with the app
|
|
4211
|
+
*/
|
|
4212
|
+
registerPage(pageName, PageClass, options = {}) {
|
|
4213
|
+
if (typeof pageName !== "string" || !pageName) {
|
|
4214
|
+
console.error("registerPage: pageName must be a non-empty string");
|
|
4215
|
+
return this;
|
|
4216
|
+
}
|
|
4217
|
+
if (typeof PageClass !== "function") {
|
|
4218
|
+
console.error("registerPage: PageClass must be a constructor function");
|
|
4219
|
+
return this;
|
|
4220
|
+
}
|
|
4221
|
+
if (!options.containerId) options.containerId = this.pageContainer;
|
|
4222
|
+
this.pageClasses.set(pageName, {
|
|
4223
|
+
PageClass,
|
|
4224
|
+
constructorOptions: options
|
|
4225
|
+
});
|
|
4226
|
+
if (this.router) {
|
|
4227
|
+
let route = options.route || `/${pageName}`;
|
|
4228
|
+
if (!route.startsWith("/")) {
|
|
4229
|
+
route = `/${route}`;
|
|
4230
|
+
}
|
|
4231
|
+
options.route = route;
|
|
4232
|
+
this.router.addRoute(route, pageName);
|
|
4233
|
+
console.log(`📝 Registered route: "${route}" -> ${pageName}`);
|
|
4234
|
+
}
|
|
4235
|
+
return this;
|
|
4236
|
+
}
|
|
4237
|
+
/**
|
|
4238
|
+
* Get page instance (cached)
|
|
4239
|
+
*/
|
|
4240
|
+
getPage(pageName) {
|
|
4241
|
+
return this.pageCache.get(pageName);
|
|
4242
|
+
}
|
|
4243
|
+
/**
|
|
4244
|
+
* Get or create page instance (with caching)
|
|
4245
|
+
*/
|
|
4246
|
+
getOrCreatePage(pageName) {
|
|
4247
|
+
if (this.pageCache.has(pageName)) {
|
|
4248
|
+
return this.pageCache.get(pageName);
|
|
4249
|
+
}
|
|
4250
|
+
const pageInfo = this.pageClasses.get(pageName);
|
|
4251
|
+
if (!pageInfo) {
|
|
4252
|
+
console.error(`Page not registered: ${pageName}`);
|
|
4253
|
+
return null;
|
|
4254
|
+
}
|
|
4255
|
+
const { PageClass, constructorOptions } = pageInfo;
|
|
4256
|
+
try {
|
|
4257
|
+
const pageOptions = {
|
|
4258
|
+
pageName,
|
|
4259
|
+
...constructorOptions,
|
|
4260
|
+
app: this
|
|
4261
|
+
};
|
|
4262
|
+
const page = new PageClass(pageOptions);
|
|
4263
|
+
if (constructorOptions.route) {
|
|
4264
|
+
page.route = constructorOptions.route;
|
|
4265
|
+
}
|
|
4266
|
+
this.pageCache.set(pageName, page);
|
|
4267
|
+
console.log(`Created page: ${pageName} with route: ${page.route}`);
|
|
4268
|
+
return page;
|
|
4269
|
+
} catch (error) {
|
|
4270
|
+
console.error(`Failed to create page ${pageName}:`, error);
|
|
4271
|
+
return null;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
/**
|
|
4275
|
+
* Show a page
|
|
4276
|
+
*/
|
|
4277
|
+
/**
|
|
4278
|
+
* SIMPLIFIED UNIFIED PAGE SHOWING METHOD
|
|
4279
|
+
* @param {string|object} page - Page name or page instance
|
|
4280
|
+
* @param {object} query - URL query parameters (URL-safe)
|
|
4281
|
+
* @param {object} params - Any data to pass to page (can include objects)
|
|
4282
|
+
* @param {object} options - Options { fromRouter, replace, force }
|
|
4283
|
+
*/
|
|
4284
|
+
async showPage(page, query = {}, params = {}, options = {}) {
|
|
4285
|
+
const { fromRouter = false, replace = false, force = false } = options;
|
|
4286
|
+
try {
|
|
4287
|
+
let pageInstance, pageName;
|
|
4288
|
+
if (typeof page === "string") {
|
|
4289
|
+
pageName = page;
|
|
4290
|
+
pageInstance = this.getOrCreatePage(page);
|
|
4291
|
+
} else if (page && typeof page === "object") {
|
|
4292
|
+
pageInstance = page;
|
|
4293
|
+
pageName = page.pageName;
|
|
4294
|
+
}
|
|
4295
|
+
const oldPage = this.currentPage;
|
|
4296
|
+
if (!pageInstance) {
|
|
4297
|
+
this._show404(pageName, params, query, fromRouter);
|
|
4298
|
+
return;
|
|
4299
|
+
}
|
|
4300
|
+
if (!pageInstance.canEnter()) {
|
|
4301
|
+
this._showDeniedPage(pageInstance, params, query, fromRouter);
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
4304
|
+
if (oldPage && oldPage !== pageInstance) {
|
|
4305
|
+
await this._exitOldPage(oldPage);
|
|
4306
|
+
}
|
|
4307
|
+
await pageInstance.onParams(params, query);
|
|
4308
|
+
if (oldPage !== pageInstance) {
|
|
4309
|
+
await pageInstance.onEnter();
|
|
4310
|
+
}
|
|
4311
|
+
pageInstance.syncUrl();
|
|
4312
|
+
await pageInstance.render();
|
|
4313
|
+
this.currentPage = pageInstance;
|
|
4314
|
+
this.events.emit("page:show", {
|
|
4315
|
+
page: pageInstance,
|
|
4316
|
+
pageName: pageInstance.pageName,
|
|
4317
|
+
params,
|
|
4318
|
+
query,
|
|
4319
|
+
fromRouter
|
|
4320
|
+
});
|
|
4321
|
+
console.log(`✅ Showing page: ${pageInstance.pageName}`, { query, params });
|
|
4322
|
+
} catch (error) {
|
|
4323
|
+
console.error("Error in showPage:", error);
|
|
4324
|
+
this.showError(`Failed to load page: ${error.message}`);
|
|
4325
|
+
if (page !== "error") {
|
|
4326
|
+
await this.showPage("error", {}, { error, originalPage: page }, { fromRouter });
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
async _show404(pageName, params, query, fromRouter) {
|
|
4331
|
+
const notFoundPage = this.getOrCreatePage("404");
|
|
4332
|
+
if (!notFoundPage) return;
|
|
4333
|
+
if (notFoundPage.setInfo) {
|
|
4334
|
+
notFoundPage.setInfo(pageName);
|
|
4335
|
+
}
|
|
4336
|
+
await this._exitOldPage(this.currentPage);
|
|
4337
|
+
await notFoundPage.render();
|
|
4338
|
+
this.currentPage = notFoundPage;
|
|
4339
|
+
this.events.emit("page:404", {
|
|
4340
|
+
page: null,
|
|
4341
|
+
pageName,
|
|
4342
|
+
params,
|
|
4343
|
+
query,
|
|
4344
|
+
fromRouter
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
async _showDeniedPage(pageInstance, params, query, fromRouter) {
|
|
4348
|
+
const deniedPage = this.getOrCreatePage("denied");
|
|
4349
|
+
if (deniedPage.setDeniedPage) {
|
|
4350
|
+
deniedPage.setDeniedPage(pageInstance);
|
|
4351
|
+
}
|
|
4352
|
+
await this._exitOldPage(this.currentPage);
|
|
4353
|
+
await deniedPage.render();
|
|
4354
|
+
this.currentPage = deniedPage;
|
|
4355
|
+
this.events.emit("page:denied", {
|
|
4356
|
+
page: pageInstance,
|
|
4357
|
+
pageName: pageInstance.pageName,
|
|
4358
|
+
params,
|
|
4359
|
+
query,
|
|
4360
|
+
fromRouter
|
|
4361
|
+
});
|
|
4362
|
+
}
|
|
4363
|
+
async _exitOldPage(oldPage) {
|
|
4364
|
+
if (!oldPage) return;
|
|
4365
|
+
try {
|
|
4366
|
+
await oldPage.onExit();
|
|
4367
|
+
await oldPage.unmount();
|
|
4368
|
+
this.events.emit("page:hide", { page: oldPage });
|
|
4369
|
+
} catch (error) {
|
|
4370
|
+
console.error(`Error exiting page ${oldPage.pageName}:`, error);
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
/**
|
|
4374
|
+
* SIMPLIFIED NAVIGATION - delegates to router
|
|
4375
|
+
* @param {string} route - Route like "/users" or "/users/123"
|
|
4376
|
+
* @param {object} query - Query string params { filter: 'active' }
|
|
4377
|
+
* @param {object} options - Navigation options { replace, force }
|
|
4378
|
+
*/
|
|
4379
|
+
async navigate(route, query = {}, options = {}) {
|
|
4380
|
+
if (!this.router) {
|
|
4381
|
+
console.error("Router not initialized");
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
console.log("🧭 WebApp.navigate:", { route, query, options });
|
|
4385
|
+
let fullPath = route;
|
|
4386
|
+
if (Object.keys(query).length > 0) {
|
|
4387
|
+
const queryString = new URLSearchParams(query).toString();
|
|
4388
|
+
fullPath += (route.includes("?") ? "&" : "?") + queryString;
|
|
4389
|
+
}
|
|
4390
|
+
return await this.router.navigate(fullPath, options);
|
|
4391
|
+
}
|
|
4392
|
+
/**
|
|
4393
|
+
* Navigate to the default route
|
|
4394
|
+
*/
|
|
4395
|
+
async navigateToDefault(options = {}) {
|
|
4396
|
+
return await this.showPage(this.defaultRoute, {}, {}, options);
|
|
4397
|
+
}
|
|
4398
|
+
/**
|
|
4399
|
+
* Navigate back
|
|
4400
|
+
*/
|
|
4401
|
+
back() {
|
|
4402
|
+
if (this.router) {
|
|
4403
|
+
this.router.back();
|
|
4404
|
+
} else {
|
|
4405
|
+
console.warn("Router not initialized");
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
/**
|
|
4409
|
+
* Navigate forward
|
|
4410
|
+
*/
|
|
4411
|
+
forward() {
|
|
4412
|
+
if (this.router) {
|
|
4413
|
+
this.router.forward();
|
|
4414
|
+
} else {
|
|
4415
|
+
console.warn("Router not initialized");
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
/**
|
|
4419
|
+
* Get current page
|
|
4420
|
+
*/
|
|
4421
|
+
getCurrentPage() {
|
|
4422
|
+
return this.currentPage;
|
|
4423
|
+
}
|
|
4424
|
+
/**
|
|
4425
|
+
* Get page container element
|
|
4426
|
+
*/
|
|
4427
|
+
getPageContainer() {
|
|
4428
|
+
if (this.layout && this.layout.getPageContainer) {
|
|
4429
|
+
return this.layout.getPageContainer();
|
|
4430
|
+
}
|
|
4431
|
+
const container = typeof this.pageContainer === "string" ? document.querySelector(this.pageContainer) : this.pageContainer;
|
|
4432
|
+
return container;
|
|
4433
|
+
}
|
|
4434
|
+
/**
|
|
4435
|
+
* Show error notification
|
|
4436
|
+
*/
|
|
4437
|
+
async showError(message) {
|
|
4438
|
+
try {
|
|
4439
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4440
|
+
await Dialog.alert(message, "Error", { size: "md", class: "text-danger" });
|
|
4441
|
+
} catch (e) {
|
|
4442
|
+
this.events.emit("notification", { message, type: "error" });
|
|
4443
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4444
|
+
console.error("[WebApp] showError fallback:", e);
|
|
4445
|
+
}
|
|
4446
|
+
if (typeof window !== "undefined") {
|
|
4447
|
+
alert(`Error: ${message}`);
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
/**
|
|
4452
|
+
* Show success notification
|
|
4453
|
+
*/
|
|
4454
|
+
async showSuccess(message) {
|
|
4455
|
+
try {
|
|
4456
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4457
|
+
await Dialog.alert(message, "Success", { size: "md", class: "text-success" });
|
|
4458
|
+
} catch (e) {
|
|
4459
|
+
this.events.emit("notification", { message, type: "success" });
|
|
4460
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4461
|
+
console.warn("[WebApp] showSuccess fallback:", e);
|
|
4462
|
+
}
|
|
4463
|
+
if (typeof window !== "undefined") {
|
|
4464
|
+
alert(`Success: ${message}`);
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
/**
|
|
4469
|
+
* Show info notification
|
|
4470
|
+
*/
|
|
4471
|
+
async showInfo(message) {
|
|
4472
|
+
try {
|
|
4473
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4474
|
+
await Dialog.alert(message, "Information", { size: "md", class: "text-info" });
|
|
4475
|
+
} catch (e) {
|
|
4476
|
+
this.events.emit("notification", { message, type: "info" });
|
|
4477
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4478
|
+
console.info("[WebApp] showInfo fallback:", e);
|
|
4479
|
+
}
|
|
4480
|
+
if (typeof window !== "undefined") {
|
|
4481
|
+
alert(`Info: ${message}`);
|
|
4482
|
+
}
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
/**
|
|
4486
|
+
* Show warning notification
|
|
4487
|
+
*/
|
|
4488
|
+
async showWarning(message) {
|
|
4489
|
+
try {
|
|
4490
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4491
|
+
await Dialog.alert(message, "Warning", { size: "md", class: "text-warning" });
|
|
4492
|
+
} catch (e) {
|
|
4493
|
+
this.events.emit("notification", { message, type: "warning" });
|
|
4494
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4495
|
+
console.warn("[WebApp] showWarning fallback:", e);
|
|
4496
|
+
}
|
|
4497
|
+
if (typeof window !== "undefined") {
|
|
4498
|
+
alert(`Warning: ${message}`);
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
/**
|
|
4503
|
+
* Show generic notification
|
|
4504
|
+
*/
|
|
4505
|
+
showNotification(message, type = "info") {
|
|
4506
|
+
this.events.emit("notification", { message, type });
|
|
4507
|
+
}
|
|
4508
|
+
/**
|
|
4509
|
+
* Show loading indicator
|
|
4510
|
+
*/
|
|
4511
|
+
async showLoading(opts = {}) {
|
|
4512
|
+
if (typeof opts === "string") {
|
|
4513
|
+
opts = { message: opts };
|
|
4514
|
+
}
|
|
4515
|
+
try {
|
|
4516
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4517
|
+
Dialog.showBusy(opts);
|
|
4518
|
+
} catch (e) {
|
|
4519
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4520
|
+
console.warn("[WebApp] showLoading fallback:", e, opts);
|
|
4521
|
+
}
|
|
4522
|
+
this.events.emit("notification", { message: opts.message || "Loading...", type: "info" });
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
/**
|
|
4526
|
+
* Hide loading indicator
|
|
4527
|
+
*/
|
|
4528
|
+
async hideLoading() {
|
|
4529
|
+
try {
|
|
4530
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4531
|
+
Dialog.hideBusy();
|
|
4532
|
+
} catch (e) {
|
|
4533
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4534
|
+
console.warn("[WebApp] hideLoading fallback:", e);
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
async showModelForm(options = {}) {
|
|
4539
|
+
try {
|
|
4540
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4541
|
+
return await Dialog.showModelForm(options);
|
|
4542
|
+
} catch (e) {
|
|
4543
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4544
|
+
console.error("[WebApp] showModelForm failed:", e);
|
|
4545
|
+
}
|
|
4546
|
+
throw e;
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
async showForm(options = {}) {
|
|
4550
|
+
try {
|
|
4551
|
+
const Dialog = (await import("./Dialog-DSlctbon.js")).default;
|
|
4552
|
+
return await Dialog.showForm(options);
|
|
4553
|
+
} catch (e) {
|
|
4554
|
+
if (typeof window !== "undefined" && window?.console) {
|
|
4555
|
+
console.error("[WebApp] showForm failed:", e);
|
|
4556
|
+
}
|
|
4557
|
+
throw e;
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
/**
|
|
4561
|
+
* Setup global error handling
|
|
4562
|
+
*/
|
|
4563
|
+
setupErrorHandling() {
|
|
4564
|
+
window.addEventListener("error", (event) => {
|
|
4565
|
+
console.error("Global error:", event.error);
|
|
4566
|
+
if (this.debug) {
|
|
4567
|
+
this.showError(`Error: ${event.error?.message || "Unknown error"}`);
|
|
4568
|
+
}
|
|
4569
|
+
});
|
|
4570
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
4571
|
+
console.error("Unhandled promise rejection:", event.reason);
|
|
4572
|
+
if (this.debug) {
|
|
4573
|
+
this.showError(`Promise rejected: ${event.reason?.message || "Unknown error"}`);
|
|
4574
|
+
}
|
|
4575
|
+
});
|
|
4576
|
+
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Escape HTML to prevent XSS
|
|
4579
|
+
*/
|
|
4580
|
+
escapeHtml(unsafe) {
|
|
4581
|
+
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4582
|
+
}
|
|
4583
|
+
/**
|
|
4584
|
+
* Get application state
|
|
4585
|
+
*/
|
|
4586
|
+
getState(key) {
|
|
4587
|
+
return key ? this.state[key] : this.state;
|
|
4588
|
+
}
|
|
4589
|
+
/**
|
|
4590
|
+
* Set application state
|
|
4591
|
+
*/
|
|
4592
|
+
setState(updates) {
|
|
4593
|
+
const oldState = { ...this.state };
|
|
4594
|
+
Object.assign(this.state, updates);
|
|
4595
|
+
this.events.emit("state:changed", {
|
|
4596
|
+
oldState,
|
|
4597
|
+
newState: this.state,
|
|
4598
|
+
updates
|
|
4599
|
+
});
|
|
4600
|
+
}
|
|
4601
|
+
/**
|
|
4602
|
+
* Register component class
|
|
4603
|
+
*/
|
|
4604
|
+
registerComponent(name, ComponentClass) {
|
|
4605
|
+
this.componentClasses.set(name, ComponentClass);
|
|
4606
|
+
}
|
|
4607
|
+
/**
|
|
4608
|
+
* Get component class
|
|
4609
|
+
*/
|
|
4610
|
+
getComponent(name) {
|
|
4611
|
+
return this.componentClasses.get(name);
|
|
4612
|
+
}
|
|
4613
|
+
/**
|
|
4614
|
+
* Register model class
|
|
4615
|
+
*/
|
|
4616
|
+
registerModel(name, ModelClass) {
|
|
4617
|
+
this.modelClasses.set(name, ModelClass);
|
|
4618
|
+
}
|
|
4619
|
+
/**
|
|
4620
|
+
* Get model class
|
|
4621
|
+
*/
|
|
4622
|
+
getModel(name) {
|
|
4623
|
+
return this.modelClasses.get(name);
|
|
4624
|
+
}
|
|
4625
|
+
/**
|
|
4626
|
+
* Setup REST configuration
|
|
4627
|
+
*/
|
|
4628
|
+
setupRest() {
|
|
4629
|
+
this.rest = rest;
|
|
4630
|
+
rest.configure(this.api);
|
|
4631
|
+
}
|
|
4632
|
+
/**
|
|
4633
|
+
* Destroy the application
|
|
4634
|
+
*/
|
|
4635
|
+
async destroy() {
|
|
4636
|
+
console.log("Destroying WebApp...");
|
|
4637
|
+
if (this.router) {
|
|
4638
|
+
this.router.stop();
|
|
4639
|
+
}
|
|
4640
|
+
const pages = Array.from(this.pageCache.values());
|
|
4641
|
+
await Promise.allSettled(
|
|
4642
|
+
pages.map(async (page) => {
|
|
4643
|
+
try {
|
|
4644
|
+
if (page.destroy) {
|
|
4645
|
+
await page.destroy();
|
|
4646
|
+
}
|
|
4647
|
+
} catch (error) {
|
|
4648
|
+
console.error("Error destroying page:", error);
|
|
4649
|
+
}
|
|
4650
|
+
})
|
|
4651
|
+
);
|
|
4652
|
+
if (this.layout && this.layout.destroy) {
|
|
4653
|
+
try {
|
|
4654
|
+
await this.layout.destroy();
|
|
4655
|
+
} catch (error) {
|
|
4656
|
+
console.error("Error destroying layout:", error);
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
this.pageCache.clear();
|
|
4660
|
+
this.pageClasses.clear();
|
|
4661
|
+
this.componentClasses.clear();
|
|
4662
|
+
this.modelClasses.clear();
|
|
4663
|
+
if (typeof window !== "undefined" && window.MOJO) {
|
|
4664
|
+
delete window.MOJO.router;
|
|
4665
|
+
}
|
|
4666
|
+
this.isStarted = false;
|
|
4667
|
+
console.log(`✨ ${this.name} destroyed`);
|
|
4668
|
+
}
|
|
4669
|
+
/**
|
|
4670
|
+
* Build page path with params and query
|
|
4671
|
+
*/
|
|
4672
|
+
buildPagePath(page, params, query) {
|
|
4673
|
+
let path = page.route || `/${page.pageName.toLowerCase()}`;
|
|
4674
|
+
Object.keys(params).forEach((key) => {
|
|
4675
|
+
if (typeof params[key] === "string" || typeof params[key] === "number") {
|
|
4676
|
+
path = path.replace(`:${key}`, params[key]);
|
|
4677
|
+
}
|
|
4678
|
+
});
|
|
4679
|
+
if (query && Object.keys(query).length > 0) {
|
|
4680
|
+
const queryString = new URLSearchParams(query).toString();
|
|
4681
|
+
path += (path.includes("?") ? "&" : "?") + queryString;
|
|
4682
|
+
}
|
|
4683
|
+
return path;
|
|
4684
|
+
}
|
|
4685
|
+
/**
|
|
4686
|
+
* Static factory method
|
|
4687
|
+
*/
|
|
4688
|
+
/**
|
|
4689
|
+
* Validate that default route has a registered page
|
|
4690
|
+
*/
|
|
4691
|
+
validateDefaultRoute() {
|
|
4692
|
+
if (!this.pageClasses.has(this.defaultRoute)) {
|
|
4693
|
+
console.warn(`⚠️ Default route '${this.defaultRoute}' is not registered!`);
|
|
4694
|
+
console.warn(` Please register a page: app.registerPage('${this.defaultRoute}', YourPageClass);`);
|
|
4695
|
+
console.warn(` Or change default route: new WebApp({ defaultRoute: 'your-page' });`);
|
|
4696
|
+
} else {
|
|
4697
|
+
console.log(`✅ Default route '${this.defaultRoute}' is registered`);
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
/**
|
|
4701
|
+
* Find any registered page to use as emergency fallback
|
|
4702
|
+
*/
|
|
4703
|
+
findFallbackPage() {
|
|
4704
|
+
const systemPages = ["404", "error", "denied"];
|
|
4705
|
+
for (const [pageName] of this.pageClasses.entries()) {
|
|
4706
|
+
if (!systemPages.includes(pageName)) {
|
|
4707
|
+
return pageName;
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
return null;
|
|
4711
|
+
}
|
|
4712
|
+
static create(config = {}) {
|
|
4713
|
+
return new WebApp(config);
|
|
4714
|
+
}
|
|
4715
|
+
/**
|
|
4716
|
+
* Initialize global plugin registry for extensions
|
|
4717
|
+
* Allows extensions to register components that core can use optionally
|
|
4718
|
+
*/
|
|
4719
|
+
initPluginRegistry() {
|
|
4720
|
+
if (typeof window !== "undefined") {
|
|
4721
|
+
if (!window.MOJO) {
|
|
4722
|
+
window.MOJO = {};
|
|
4723
|
+
}
|
|
4724
|
+
if (!window.MOJO.plugins) {
|
|
4725
|
+
window.MOJO.plugins = {};
|
|
4726
|
+
}
|
|
4727
|
+
window.MOJO.app = this;
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
/**
|
|
4731
|
+
* Register a plugin component
|
|
4732
|
+
* @param {string} name - Plugin component name (e.g., 'ImageCropView')
|
|
4733
|
+
* @param {*} component - The component/class to register
|
|
4734
|
+
*/
|
|
4735
|
+
static registerPlugin(name, component) {
|
|
4736
|
+
if (typeof window !== "undefined") {
|
|
4737
|
+
if (!window.MOJO) {
|
|
4738
|
+
window.MOJO = {};
|
|
4739
|
+
}
|
|
4740
|
+
if (!window.MOJO.plugins) {
|
|
4741
|
+
window.MOJO.plugins = {};
|
|
4742
|
+
}
|
|
4743
|
+
window.MOJO.plugins[name] = component;
|
|
4744
|
+
console.debug(`MOJO Plugin registered: ${name}`);
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
export {
|
|
4749
|
+
BUILD_TIME as B,
|
|
4750
|
+
DataWrapper as D,
|
|
4751
|
+
EventDelegate as E,
|
|
4752
|
+
Mustache as M,
|
|
4753
|
+
Router as R,
|
|
4754
|
+
View as V,
|
|
4755
|
+
WebApp as W,
|
|
4756
|
+
VERSION_INFO as a,
|
|
4757
|
+
VERSION as b,
|
|
4758
|
+
VERSION_MAJOR as c,
|
|
4759
|
+
dataFormatter as d,
|
|
4760
|
+
VERSION_MINOR as e,
|
|
4761
|
+
VERSION_REVISION as f,
|
|
4762
|
+
EventBus as g,
|
|
4763
|
+
MOJOUtils as h,
|
|
4764
|
+
EventEmitter as i,
|
|
4765
|
+
rest as r
|
|
4766
|
+
};
|
|
4767
|
+
//# sourceMappingURL=WebApp-B6mgbNn2.js.map
|