1import { router } from "@/router";
2import { useAuthStore } from "@/stores/auth";
3import { call, dayjsLocal, useFileUpload } from "frappe-ui";
4import { h, ref } from "vue";
5import zod from "zod";
6import LucideBrushCleaning from "~icons/lucide/brush-cleaning";
7import { Icon } from "frappe-ui/experimental";
8import { getMeta } from "./stores/meta";
9import { __ } from "./translation";
10import { copyToClipboard, isEmoji } from "@opendesk/shared/utils";
11
12// TODO: drop after #3846/#3847 merge; import from shared directly.
13export {
14 ConfirmDelete,
15 CUSTOMER_PORTAL_ROOT,
16 getErrorMessage,
17 handleInviteUserSuccess,
18 isContentEmpty,
19 parseApiOptions,
20 prettyDate,
21 timeAgo,
22} from "@opendesk/shared/utils";
23
24export function extractEmail(input: string) {
25 const match = input.match(/<([^>]+)>$/); // grabs the part inside <>
26 return match ? match[1] : input;
27}
28
29export function validateEmailWithZod(email: string) {
30 const extractedEmail = extractEmail(email);
31 const success = zod.string().email().safeParse(extractedEmail).success;
32 return success;
33}
34
35/** Dayjs date format derived from the site's System Settings (boot data). */
36export function getDateFormat(): string {
37 return ((window as any).date_format || "dd-mm-yyyy").toUpperCase();
38}
39
40/** Time format from the site's System Settings (boot data). */
41export function getTimeFormat(): string {
42 return (window as any).time_format || "HH:mm:ss";
43}
44
45export function dateFormat(date, format?: string) {
46 const _format = format || `${getDateFormat()} ${getTimeFormat()}`;
47 if (!date) return "";
48 const tzDate = dayjsLocal(date);
49 return tzDate.format(_format);
50}
51
52export function formatTime(
53 seconds: number,
54 config: {
55 day?: boolean;
56 hour?: boolean;
57 minute?: boolean;
58 second?: boolean;
59 maxUnits?: number;
60 } = {
61 day: true,
62 hour: true,
63 minute: true,
64 second: true,
65 }
66) {
67 const days = Math.floor(seconds / (3600 * 24));
68 const hours = Math.floor((seconds % (3600 * 24)) / 3600);
69 const minutes = Math.floor((seconds % 3600) / 60);
70 const remainingSeconds = Math.floor(seconds % 60);
71
72 const parts: string[] = [];
73
74 if (config.day && days > 0) {
75 parts.push(`${days}d`);
76 }
77
78 if (config.hour && (hours > 0 || days > 0)) {
79 parts.push(`${hours}h`);
80 }
81
82 if (config.minute && (minutes > 0 || hours > 0 || days > 0)) {
83 parts.push(`${minutes}m`);
84 }
85
86 if (config.second) {
87 parts.push(
88 `${
89 remainingSeconds >= 10
90 ? remainingSeconds
91 : remainingSeconds > 1
92 ? "0" + remainingSeconds
93 : "0"
94 }s`
95 );
96 }
97
98 const limited = config.maxUnits ? parts.slice(0, config.maxUnits) : parts;
99 return limited.join(" ").trim();
100}
101
102/**
103 * Shareable link to one row of the ticket feed. `?highlight` is what
104 * TicketTimeline scrolls to; the hash only selects the Activity tab.
105 */
106export function copyActivityLink(
107 prefix: "comment" | "communication",
108 name: string
109) {
110 const { href } = router.resolve({
111 name: "TicketAgent",
112 params: { ticketId: router.currentRoute.value.params.ticketId },
113 hash: "#activity",
114 query: { highlight: `${prefix}-${name}` },
115 });
116 copyToClipboard(window.location.origin + href, __("Link copied."));
117}
118
119export const ClearFormattingUtility = {
120 label: "Clear formatting",
121 icon: LucideBrushCleaning,
122 action: (editor) => {
123 editor.chain().focus().unsetAllMarks().clearNodes().cleanStyles().run();
124 },
125 isActive: () => false,
126};
127
128export const textEditorMenuButtons = [
129 "Paragraph",
130 ["Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6"],
131 "Separator",
132 "Bold",
133 "Italic",
134 "FontColor",
135 "Separator",
136 ["Align Left", "Align Center", "Align Right"],
137 "Bullet List",
138 "Numbered List",
139 "Separator",
140 "Image",
141 "Video",
142 "Link",
143 "Blockquote",
144 "Code",
145 "Horizontal Rule",
146 [
147 "InsertTable",
148 "AddColumnBefore",
149 "AddColumnAfter",
150 "DeleteColumn",
151 "AddRowBefore",
152 "AddRowAfter",
153 "DeleteRow",
154 "MergeCells",
155 "SplitCell",
156 "ToggleHeaderColumn",
157 "ToggleHeaderRow",
158 "ToggleHeaderCell",
159 "DeleteTable",
160 ],
161 "Separator",
162 ClearFormattingUtility,
163];
164
165export function normalize(value: any) {
166 if (value === null || value === undefined) {
167 return "";
168 }
169 return value;
170}
171
172export function isTouchScreenDevice() {
173 return "ontouchstart" in document.documentElement;
174}
175
176/**
177 * Resolves a stored icon value into a renderable component.
178 * Supports Lucide icon names from the frappe-ui IconPicker, emojis stored by
179 * older views, and pre-resolved icon components, falling back to the ticket icon.
180 */
181export function getIcon(icon) {
182 if (!icon) {
183 return h(Icon, { name: "ticket" });
184 }
185 if (isEmoji(icon)) {
186 return h(
187 "div",
188 { class: "flex items-center justify-center leading-none" },
189 icon
190 );
191 }
192 if (typeof icon === "string" && icon.startsWith("lucide-")) {
193 return h("span", { class: icon });
194 }
195 if (typeof icon === "string") {
196 return h(Icon, { name: icon });
197 }
198 return icon;
199}
200function hasArabicContent(content: string) {
201 const arabicRegex = /[\u0600-\u06FF]/;
202 return arabicRegex.test(content);
203}
204
205export function getFontFamily(content: string) {
206 const langMap = {
207 default: "!font-[Inter]",
208 arabic: "!font-[system-ui]",
209 };
210 let lang = "";
211 if (hasArabicContent(content)) {
212 lang = "arabic";
213 }
214 return langMap[lang] || "";
215}
216
217/**
218 * Parses HTML string and returns the text content with preserved line breaks
219 * @param html - HTML string to parse
220 * @returns Plain text content with preserved line breaks
221 */
222export function htmlToText(html: string): string {
223 if (!html) return "";
224
225 const parser = new DOMParser();
226 const doc = parser.parseFromString(html, "text/html");
227
228 const lineBreaks = doc.querySelectorAll("br, p, div, li");
229 lineBreaks.forEach((el) => {
230 el.after("\n");
231 });
232
233 let text = doc.body.textContent || "";
234
235 text = text.replace(/\s+/g, " ");
236
237 text = text.replace(/\n\s*\n/g, "\n");
238
239 return text.trim();
240}
241
242/**
243 * Format a date according to the user's system settings
244 * @param {Date|string} date - Date object or ISO date string
245 * @returns {string} Formatted date string in the user's locale and preferences
246 */
247export function getFormattedDate(date) {
248 if (!date) return "";
249 const dateObj = dayjsLocal(date);
250 if (!dateObj.isValid()) return "";
251
252 return dateObj.format(getDateFormat());
253}
254
255export function getGridTemplateColumnsForTable(columns) {
256 let columnsWidth = columns
257 .map((col) => {
258 let width = col.width || 1;
259 if (typeof width === "number") {
260 return width + "fr";
261 }
262 return width;
263 })
264 .join(" ");
265 return columnsWidth + " 22px";
266}
267
268/**
269 * `options` carries the editor's `{ signal, onProgress }`, which is what drives
270 * the upload's progress ring and its cancel button.
271 */
272export function uploadFunction(
273 file: File,
274 doctype: string | null = null,
275 docname: string | null = null,
276 isPrivate: boolean = true,
277 options: { signal?: AbortSignal; onProgress?: (p: unknown) => void } = {}
278) {
279 let fileUpload = useFileUpload();
280 return fileUpload.upload(file, {
281 private: isPrivate,
282 doctype: doctype,
283 docname: docname,
284 ...options,
285 });
286}
287
288export const convertToConditions = ({
289 conditions,
290 fieldPrefix,
291}: {
292 conditions: any[];
293 fieldPrefix?: string;
294}): string => {
295 if (!conditions || conditions.length === 0) {
296 return "";
297 }
298
299 const processCondition = (condition: any): string => {
300 if (typeof condition === "string") {
301 return condition.toLowerCase();
302 }
303
304 if (Array.isArray(condition)) {
305 // Nested condition group
306 if (Array.isArray(condition[0])) {
307 const nestedStr = convertToConditions({
308 conditions: condition,
309 fieldPrefix,
310 });
311 return `(${nestedStr})`;
312 }
313
314 // Simple condition: [fieldname, operator, value]
315 const [field, operator, value] = condition;
316 const fieldAccess = fieldPrefix ? `${fieldPrefix}.${field}` : field;
317
318 const operatorMap: Record<string, string> = {
319 equals: "==",
320 "=": "==",
321 "==": "==",
322 "!=": "!=",
323 "not equals": "!=",
324 "<": "<",
325 "<=": "<=",
326 ">": ">",
327 ">=": ">=",
328 in: "in",
329 "not in": "not in",
330 like: "like",
331 "not like": "not like",
332 is: "is",
333 "is not": "is not",
334 between: "between",
335 };
336
337 let op = operatorMap[operator.toLowerCase()] || operator;
338
339 if (
340 (op === "==" || op === "!=") &&
341 (String(value).toLowerCase() === "yes" ||
342 String(value).toLowerCase() === "no")
343 ) {
344 let checkVal = String(value).toLowerCase() === "yes";
345 if (op === "!=") {
346 checkVal = !checkVal;
347 }
348 return checkVal ? fieldAccess : `not ${fieldAccess}`;
349 }
350
351 if (op === "is" && String(value).toLowerCase() === "set") {
352 return fieldAccess;
353 }
354 if (
355 (op === "is" && String(value).toLowerCase() === "not set") ||
356 (op === "is not" && String(value).toLowerCase() === "set")
357 ) {
358 return `not ${fieldAccess}`;
359 }
360
361 if (op === "like") {
362 return `(${fieldAccess} and "${value}" in ${fieldAccess})`;
363 }
364 if (op === "not like") {
365 return `(${fieldAccess} and "${value}" not in ${fieldAccess})`;
366 }
367
368 if (
369 op === "between" &&
370 typeof value === "string" &&
371 value.includes(",")
372 ) {
373 const [start, end] = value.split(",").map((v: string) => v.trim());
374 return `(${fieldAccess} >= "${start}" and ${fieldAccess} <= "${end}")`;
375 }
376
377 let valueStr = "";
378 if (op === "in" || op === "not in") {
379 let items: string[];
380 if (Array.isArray(value)) {
381 items = value.map((v) => `"${String(v).trim()}"`);
382 } else if (typeof value === "string") {
383 items = value.split(",").map((v) => `"${v.trim()}"`);
384 } else {
385 items = [`"${String(value).trim()}"`];
386 }
387 valueStr = `[${items.join(", ")}]`;
388 return `(${fieldAccess} and ${fieldAccess} ${op} ${valueStr})`;
389 }
390
391 if (typeof value === "string") {
392 valueStr = `"${value.replace(/"/g, '\\"')}"`;
393 } else if (typeof value === "number" || typeof value === "boolean") {
394 valueStr = String(value);
395 } else if (value === null || value === undefined) {
396 return op === "==" || op === "is" ? `not ${fieldAccess}` : fieldAccess;
397 } else {
398 valueStr = `"${String(value).replace(/"/g, '\\"')}"`;
399 }
400
401 return `${fieldAccess} ${op} ${valueStr}`;
402 }
403
404 return "";
405 };
406
407 const parts = conditions.map(processCondition);
408 return parts.join(" ");
409};
410
411export function validateConditions(conditions: any[]): boolean {
412 if (!Array.isArray(conditions)) return false;
413
414 // Handle simple condition [field, operator, value]
415 if (
416 conditions.length === 3 &&
417 typeof conditions[0] === "string" &&
418 typeof conditions[1] === "string"
419 ) {
420 return conditions[0] !== "" && conditions[1] !== "" && conditions[2] !== "";
421 }
422
423 // Iterate through conditions and logical operators
424 for (let i = 0; i < conditions.length; i++) {
425 const item = conditions[i];
426
427 // Skip logical operators (they will be validated by their position)
428 if (item === "and" || item === "or") {
429 // Ensure logical operators are not at start/end and not consecutive
430 if (
431 i === 0 ||
432 i === conditions.length - 1 ||
433 conditions[i - 1] === "and" ||
434 conditions[i - 1] === "or"
435 ) {
436 return false;
437 }
438 continue;
439 }
440
441 // Handle nested conditions (arrays)
442 if (Array.isArray(item)) {
443 if (!validateConditions(item)) {
444 return false;
445 }
446 } else if (item !== undefined && item !== null) {
447 return false;
448 }
449 }
450
451 return conditions.length > 0;
452}
453
454export async function removeAttachmentFromServer(attachment: string) {
455 await call("frappe.client.delete", {
456 doctype: "File",
457 name: attachment,
458 });
459}
460
461function getParentChildField(name: string) {
462 let [_, parent, child] = name.split("-");
463 return [parent, child];
464}
465
466export function getFieldDependencyLabel(name: string) {
467 const { getField } = getMeta("OD Ticket");
468 let [parent, child] = getParentChildField(name);
469 parent = getField(parent)?.label || parent;
470 child = getField(child)?.label || child;
471 return `${parent} → ${child}`;
472}
473
474export function getRandom(len = 4) {
475 let text = "";
476 const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
477
478 Array.from({ length: len }).forEach(() => {
479 text += possible.charAt(Math.floor(Math.random() * possible.length));
480 });
481
482 return text;
483}
484
485export function openContact(name: string) {
486 const url = window.location.origin + "/opendesk/contacts/" + name;
487 window.open(url, "_blank");
488}
489
490// Shared reactive mirror of <html data-theme> for JS-driven theme-aware components
491export const dataTheme = ref<string>(
492 (typeof document !== "undefined" &&
493 document.documentElement.getAttribute("data-theme")) ||
494 "light"
495);
496
497if (typeof window !== "undefined") {
498 new MutationObserver(() => {
499 const next = document.documentElement.getAttribute("data-theme") || "light";
500 if (next !== dataTheme.value) dataTheme.value = next;
501 }).observe(document.documentElement, {
502 attributes: true,
503 attributeFilter: ["data-theme"],
504 });
505}
506
507export function buildPercentageChange(
508 value: number | null,
509 negativeIsBetter: boolean = true
510) {
511 // No change (or no comparison): stay neutral — never green/red, no up/down arrow.
512 if (value === null || value === undefined || value === 0) {
513 return { icon: "", value: "0", color: "text-ink-gray-5" };
514 }
515 const isPositive = value > 0;
516 const isGood = negativeIsBetter ? !isPositive : isPositive;
517 // Cap the magnitude at 100% so large swings (e.g. +3186%) stay readable.
518 const capped = Math.min(Math.abs(value), 100);
519 return {
520 icon: isPositive ? "lucide-arrow-up-right" : "lucide-arrow-down-left",
521 value: isPositive ? `+${capped}` : `-${capped}`,
522 color: isGood ? "text-ink-green-6" : "text-ink-red-6",
523 };
524}
525
526export function hasPermission() {
527 const authStore = useAuthStore();
528 return authStore.isAdmin || authStore.isManager;
529}
530
531/**
532 * Splits a legacy `placement` string ("bottom-start") into frappe-ui v1's
533 * `side` + `align` pair. A bare side means centred alignment.
534 */
535export function splitPlacement(placement?: string) {
536 const [side = "bottom", align = "center"] = (placement || "").split("-");
537 return { side, align } as { side: any; align: any };
538}
539