@idea-ionic/common
Version:
IDEA Ionic common components
1 lines • 512 kB
Source Map (JSON)
{"version":3,"file":"idea-ionic-common.mjs","sources":["../../../modules/common/src/actionSheet/actionSheet.component.ts","../../../modules/common/src/actionSheet/actionSheetController.service.ts","../../../modules/common/environment.ts","../../../modules/common/src/translations/translations.service.ts","../../../modules/common/src/translations/translate.pipe.ts","../../../modules/common/src/api.service.ts","../../../modules/common/src/storage.service.ts","../../../modules/common/src/appStatus/appStatus.service.ts","../../../modules/common/src/appStatus/appStatus.page.ts","../../../modules/common/src/appStatus/appStatus.routes.ts","../../../modules/common/src/loading.service.ts","../../../modules/common/src/message.service.ts","../../../modules/common/src/attachments/attachments.service.ts","../../../modules/common/src/attachments/attachments.component.ts","../../../modules/common/src/labeler/labeler.component.ts","../../../modules/common/src/translations/label.pipe.ts","../../../modules/common/src/attachments/manageAttachmentsSection.component.ts","../../../modules/common/src/attachments/attachmentSections.component.ts","../../../modules/common/src/boldPrefix.pipe.ts","../../../modules/common/src/select/suggestions.component.ts","../../../modules/common/src/select/suggestions.component.html","../../../modules/common/src/userAvatar/userAvatar.component.ts","../../../modules/common/src/checker/checks.component.ts","../../../modules/common/src/checker/checker.component.ts","../../../modules/common/src/checker/chipChecker.component.ts","../../../modules/common/src/checker/inlineChecker.component.ts","../../../modules/common/src/select/select.component.ts","../../../modules/common/src/select/select.component.html","../../../modules/common/src/customFields/customBlock.component.ts","../../../modules/common/src/customFields/customBlock.component.html","../../../modules/common/src/icons/icons.component.ts","../../../modules/common/src/customFields/customFieldMeta.component.ts","../../../modules/common/src/customFields/customFieldMeta.component.html","../../../modules/common/src/customFields/customSectionMeta.component.ts","../../../modules/common/src/customFields/customSectionMeta.component.html","../../../modules/common/src/customFields/customBlockMeta.component.ts","../../../modules/common/src/customFields/customBlockMeta.component.html","../../../modules/common/src/customFields/customSection.component.ts","../../../modules/common/src/customFields/customSection.component.html","../../../modules/common/src/highlightedVariables.pipe.ts","../../../modules/common/src/list/listElements.component.ts","../../../modules/common/src/list/list.component.ts","../../../modules/common/src/email/emailDataConfiguration.component.ts","../../../modules/common/src/email/emailData.component.ts","../../../modules/common/src/email/sendEmail.component.ts","../../../modules/common/src/labeler/label.component.ts","../../../modules/common/src/pdfTemplate/pdfTemplate.component.ts","../../../modules/common/src/pdfTemplate/pdfTemplate.component.html","../../../modules/common/src/showHintButton/showHintButton.component.ts","../../../modules/common/src/signature/signature.component.ts","../../../modules/common/src/tooltip/tooltip.component.ts","../../../modules/common/src/tooltip/tooltip.directive.ts","../../../modules/common/src/translations/dateLocale.pipe.ts","../../../modules/common/src/translations/languagePicker.component.ts","../../../modules/common/src/translations/translations.module.ts","../../../modules/common/src/linkify.pipe.ts","../../../modules/common/src/webSocketApi.service.ts","../../../modules/common/idea-ionic-common.ts"],"sourcesContent":["import { Component, Input, OnInit, inject } from '@angular/core';\nimport { ActionSheetButton } from '@ionic/core';\nimport {\n IonContent,\n IonGrid,\n IonCol,\n IonRow,\n IonLabel,\n IonButton,\n IonIcon,\n PopoverController\n} from '@ionic/angular/standalone';\n\n/**\n * It's an alternative for desktop devices to the traditional ActionSheet.\n * It shares (almost) the same inputs so they are interchangeable.\n */\n@Component({\n selector: 'idea-action-sheet',\n standalone: true,\n imports: [IonIcon, IonButton, IonLabel, IonRow, IonCol, IonGrid, IonContent],\n template: `\n <ion-content [class]=\"cssClass\">\n <ion-grid class=\"ion-padding\">\n @if (header) {\n <ion-row class=\"headerRow\">\n <ion-col class=\"ion-text-center\">\n <ion-label class=\"ion-text-wrap\">\n {{ header }}\n @if (subHeader) {\n <p>{{ subHeader }}</p>\n }\n </ion-label>\n </ion-col>\n </ion-row>\n }\n <ion-row class=\"ion-justify-content-center buttonsRow\">\n @for (button of buttons; track button) {\n <ion-col [size]=\"withIcons ? 6 : 12\">\n <ion-button\n fill=\"clear\"\n expand=\"full\"\n color=\"medium\"\n [class.withIcon]=\"withIcons\"\n [class.destructive]=\"button.role === 'destructive'\"\n [class.cancel]=\"button.role === 'cancel'\"\n (click)=\"buttonClicked(button)\"\n >\n <div>\n @if (withIcons) {\n <ion-icon [icon]=\"button.icon || 'flash'\" />\n }\n @if (withIcons) {\n <br />\n }\n <ion-label class=\"ion-text-wrap\">{{ button.text }}</ion-label>\n </div>\n </ion-button>\n </ion-col>\n }\n </ion-row>\n </ion-grid>\n </ion-content>\n `,\n styles: [\n `\n ion-row.headerRow {\n margin-top: 8px;\n margin-bottom: 16px;\n font-size: 1.2em;\n font-weight: 500;\n }\n ion-row.buttonsRow {\n margin-bottom: 8px;\n ion-button {\n text-transform: none;\n --ion-color-base: var(--ion-text-color-step-350) !important;\n &.destructive {\n --ion-color-base: var(--ion-color-danger) !important;\n }\n &.cancel {\n --ion-color-base: var(--ion-text-color-step-650) !important;\n }\n &.withIcon {\n height: 100%;\n div {\n display: flex;\n flex-flow: column nowrap;\n align-items: center;\n ion-icon {\n font-size: 1.8em;\n }\n br {\n content: '';\n margin-bottom: 10px;\n }\n }\n }\n }\n }\n .action-sheet-cancel ion-icon {\n opacity: 0.8;\n }\n `\n ]\n})\nexport class IDEAActionSheetComponent implements OnInit {\n private _popover = inject(PopoverController);\n\n /**\n * An array of buttons for the actions panel.\n */\n @Input() buttons: ActionSheetButton[] = [];\n /**\n * Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces.\n */\n @Input() cssClass: string;\n /**\n * Title for the actions panel.\n */\n @Input() header: string;\n /**\n * Subtitle for the actions panel.\n */\n @Input() subHeader: string;\n\n withIcons: boolean;\n\n ngOnInit(): void {\n // based on the input, changes the way the UI behaves\n this.withIcons = this.buttons.some(b => b.icon);\n }\n\n buttonClicked(button: ActionSheetButton): void {\n if (button.handler) button.handler();\n this._popover.dismiss();\n }\n}\n","import { Injectable, inject } from '@angular/core';\nimport { ActionSheetButton } from '@ionic/core';\nimport { ActionSheetController, Platform, PopoverController } from '@ionic/angular/standalone';\n\nimport { IDEAActionSheetComponent } from './actionSheet.component';\n\n/**\n * It's an alternative to the traditional ActionSheetController.\n * It shares (almost) the same inputs, so they are interchangeable.\n */\n@Injectable({ providedIn: 'root' })\nexport class IDEAActionSheetController {\n private _platform = inject(Platform);\n private _actions = inject(ActionSheetController);\n private _popover = inject(PopoverController);\n\n /**\n * Based on the platform, open the traditional or the customised ActionSheet.\n */\n create(options: IDEAActionSheetOptions, forceCustom?: boolean): Promise<HTMLIonActionSheetElement> {\n if ((this._platform.is('mobile') || this._platform.width() < 576) && !forceCustom)\n return this._actions.create(options);\n else\n return (this._popover as any).create({\n backdropDismiss: options.backdropDismiss,\n component: IDEAActionSheetComponent,\n componentProps: options,\n cssClass: 'actionSheetPopover'\n }) as Promise<HTMLIonActionSheetElement>;\n }\n}\n\n/**\n * The options for the ActionSheet.\n */\nexport interface IDEAActionSheetOptions {\n /**\n * If true, the action sheet will be dismissed when the backdrop is clicked.\n */\n backdropDismiss?: boolean;\n /**\n * An array of buttons for the action sheet.\n */\n buttons: ActionSheetButton[];\n /**\n * Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces.\n */\n cssClass?: string;\n /**\n * Title for the action sheet.\n */\n header?: string;\n /**\n * Subtitle for the action sheet.\n */\n subHeader?: string;\n}\n","import { InjectionToken } from '@angular/core';\n\n/**\n * The token to inject the app configurations in the module.\n */\nexport const IDEAEnvironment = new InjectionToken<IDEAEnvironmentConfiguration | any>('IDEA environment configuration');\n\n/**\n * The environment variables used by IDEA Ionic Extra's modules.\n */\nexport interface IDEAEnvironmentConfiguration {\n idea: {\n project: string;\n ionicExtraModules: string[];\n app?: {\n version: string;\n bundle: string;\n appleStoreURL?: string;\n googleStoreURL?: string;\n maxFileUploadSizeMB?: number;\n };\n api?: {\n url: string;\n stage?: string;\n };\n ideaApi?: {\n url: string;\n stage?: string;\n };\n socket?: {\n url: string;\n stage?: string;\n };\n auth?: {\n title?: string;\n website?: string;\n hasIntroPage?: boolean;\n registrationIsPossible: boolean;\n singleSimultaneousSession: boolean;\n forceLoginWithMFA: boolean;\n // note: the passwordPolicy should be set matching the configuration of the Cognito User Pool\n passwordPolicy?: {\n minLength: number;\n requireLowercase: boolean;\n requireDigits: boolean;\n requireSymbols: boolean;\n requireUppercase: boolean;\n advancedPasswordCheck?: boolean;\n };\n };\n };\n aws?: {\n cognito?: {\n userPoolId: string;\n userPoolClientId: string;\n domain?: string;\n externalProviders?: { type: 'okta'; name: string; emailDomains: string[] }[];\n };\n };\n google?: {\n apiClientId: string;\n apiScope: string;\n mapsApiKey: string;\n };\n microsoft?: {\n apiClientId: string;\n apiScope: string;\n };\n auth0?: {\n domain: string;\n clientId: string;\n callbackUri: string;\n storeRefreshToken: boolean;\n };\n}\n","import { Injectable, EventEmitter, inject } from '@angular/core';\nimport { DatePipe } from '@angular/common';\nimport { getStringEnumKeyByValue, Label, Languages, LanguagesISO639, mdToHtml, TIMEZONE_OFFSETS } from 'idea-toolbox';\n\nimport { IDEAEnvironment } from '../../environment';\n\n/**\n * Translations service.\n */\n@Injectable({ providedIn: 'root' })\nexport class IDEATranslationsService {\n protected _env = inject(IDEAEnvironment);\n\n /**\n * Base folder containing the translations.\n */\n protected basePath = 'assets/i18n/';\n /**\n * The modules for which to load the translations.\n */\n protected modulesPath: string[];\n\n /**\n * Template matcher to interpolate complex strings (e.g. `{{user}}`).\n */\n private templateMatcher = /{{\\s?([^{}\\s]*)\\s?}}/g;\n /**\n * The available languages.\n */\n private langs: string[];\n /**\n * The current language.\n */\n private currentLang: string;\n /**\n * The fallback language.\n */\n private defaultLang: string;\n /**\n * The translations.\n */\n private translations: any = {};\n /**\n * Some default interpolation parameters to add to istant translations.\n */\n private defaultInterpolations: Record<string, string> = {};\n /**\n * To subscribe to language changes.\n */\n onLangChange = new EventEmitter<string>();\n\n constructor() {\n this.modulesPath = [''].concat(this._env.idea.ionicExtraModules || []);\n }\n\n /**\n * Initialize the service.\n */\n async init(languages: string[] = ['en'], defaultLang = 'en'): Promise<void> {\n this.setLangs(languages);\n this.setDefaultLang(defaultLang);\n let lang = this.getBrowserLang();\n if (!languages.includes(lang)) lang = this.getDefaultLang();\n await this.use(lang, true);\n }\n\n /**\n * Set the available languages.\n */\n setLangs(langs: string[]): void {\n this.langs = langs.slice();\n }\n /**\n * Returns an array of currently available languages.\n */\n getLangs(): string[] {\n return this.langs;\n }\n\n /**\n * Get the fallback language.\n */\n getDefaultLang(): string {\n return this.defaultLang;\n }\n /**\n * Sets the default language to use as a fallback.\n */\n setDefaultLang(lang: string): void {\n if (this.langs.includes(lang)) this.defaultLang = lang;\n else this.defaultLang = this.langs[0];\n }\n\n /**\n * Get the languages in IdeaX format.\n */\n languages(): Languages {\n return new Languages({ available: this.langs, default: this.defaultLang });\n }\n\n /**\n * Returns the language code name from the browser, e.g. \"it\"\n */\n getBrowserLang(): string {\n if (typeof window === 'undefined' || typeof window.navigator === 'undefined') return undefined;\n let browserLang: any = window.navigator.languages ? window.navigator.languages[0] : null;\n browserLang =\n browserLang ||\n window.navigator.language ||\n (window.navigator as any).browserLanguage ||\n (window.navigator as any).userLanguage;\n if (typeof browserLang === 'undefined') return undefined;\n if (browserLang.indexOf('-') !== -1) browserLang = browserLang.split('-')[0];\n if (browserLang.indexOf('_') !== -1) browserLang = browserLang.split('_')[0];\n return browserLang;\n }\n\n /**\n * The lang currently used.\n */\n getCurrentLang(): string {\n return this.currentLang;\n }\n /**\n * Set a language to use.\n */\n use(lang: string, force?: boolean): Promise<void> {\n return new Promise(resolve => {\n const changed = lang !== this.currentLang;\n if (!changed && !force) return;\n // check whether the language is among the available ones; otherwise, fallback to default\n if (!this.langs.includes(lang)) lang = this.defaultLang;\n // load translations\n this.loadTranlations(lang).then((): void => {\n // set the lang\n this.currentLang = lang;\n // emit the change\n if (changed) this.onLangChange.emit(lang);\n // resolve\n resolve();\n });\n });\n }\n\n /**\n * Set some parameters to automatically provide to translation actions.\n */\n setDefaultInterpolations(defaultParams: Record<string, string>): void {\n this.defaultInterpolations = defaultParams || {};\n }\n\n /**\n * Get a translated term by key in the current language, optionally interpolating variables (e.g. `{{user}}`).\n * If the term doesn't exist in the current language, it is searched in the default language.\n */\n instant(key: string, interpolateParams?: any): string {\n return this.instantInLanguage(this.currentLang, key, interpolateParams);\n }\n /**\n * Get a translated term by key in the selected language, optionally interpolating variables (e.g. `{{user}}`).\n * If the term doesn't exist in the current language, it is searched in the default language.\n */\n instantInLanguage(language: string, key: string, interpolateParams?: any): string {\n const params = { ...this.defaultInterpolations, ...(interpolateParams || {}) };\n if (!this.isDefined(key) || !key.length) return;\n let res = this.interpolate(this.getValue(this.translations[language], key), params);\n if (res === undefined && this.defaultLang !== null && this.defaultLang !== language)\n res = this.interpolate(this.getValue(this.translations[this.defaultLang], key), params);\n return res;\n }\n /**\n * Shortcut to instant.\n */\n _(key: string, interpolateParams?: any): string {\n return this.instant(key, interpolateParams);\n }\n /**\n * Translate (instant) and transform an expected markdown string into HTML.\n */\n _md(key: string, interpolateParams?: any): string {\n return mdToHtml(this._(key, interpolateParams));\n }\n\n /**\n * Return a Label containing all the available translations of a key.\n */\n getLabelByKey(key: string, interpolateParams?: any): Label {\n const label = new Label(null, this.languages());\n this.langs.forEach(lang => (label[lang] = this.instantInLanguage(lang, key, interpolateParams)));\n return label;\n }\n /**\n * Return the translation in the current language of a label.\n */\n translateLabel(label: Label): string {\n return label.translate(this.getCurrentLang(), this.languages());\n }\n /**\n * Shortcut to translateLabel.\n */\n _label(label: Label): string {\n return this.translateLabel(label);\n }\n\n /**\n * Load the translations from the files.\n */\n private loadTranlations(lang: string): Promise<void> {\n return new Promise(resolve => {\n this.translations = {};\n this.translations[this.defaultLang] = {};\n let promises = this.modulesPath.map(m =>\n this.loadTranslationFileHelper(this.basePath.concat(m), this.defaultLang)\n );\n if (lang !== this.defaultLang) {\n this.translations[lang] = {};\n promises = promises.concat(\n this.modulesPath.map(m => this.loadTranslationFileHelper(this.basePath.concat(m), lang))\n );\n }\n Promise.all(promises).then((): void => resolve());\n });\n }\n /**\n * Load a file into the translations.\n */\n private async loadTranslationFileHelper(path: string, lang: string): Promise<void> {\n const res = await fetch(`${path.slice(-1) === '/' ? path : path.concat('/')}${lang}.json`, {\n method: 'GET',\n cache: 'no-cache' // to avoid issues upon releases\n });\n if (res.status !== 200) return;\n\n const obj = await res.json();\n for (const key in obj) if (obj[key]) this.translations[lang][key] = obj[key];\n }\n\n /**\n * Interpolates a string to replace parameters.\n * \"This is a {{ key }}\" ==> \"This is a value\", with params = { key: \"value\" }\n */\n private interpolate(expr: string, params?: any): string {\n if (!params || !expr) return expr;\n return expr.replace(this.templateMatcher, (substring: string, b: string): any => {\n const r = this.getValue(params, b);\n return this.isDefined(r) ? r : substring;\n });\n }\n /**\n * Gets a value from an object by composed key.\n * getValue({ key1: { keyA: 'valueI' }}, 'key1.keyA') ==> 'valueI'\n */\n private getValue(target: any, key: string): any {\n const keys = typeof key === 'string' ? key.split('.') : [key];\n key = '';\n do {\n key += keys.shift();\n if (this.isDefined(target) && this.isDefined(target[key]) && (typeof target[key] === 'object' || !keys.length)) {\n target = target[key];\n key = '';\n } else if (!keys.length) target = undefined;\n else key += '.';\n } while (keys.length);\n return target;\n }\n\n /**\n * Helper to quicly check if the value is defined.\n */\n private isDefined(value: any): boolean {\n return value !== undefined && value !== null;\n }\n\n /**\n * Format a date in the current locale (optionally forcing a timeZone).\n */\n formatDate(value: any, pattern: string = 'mediumDate', timeZone?: string): string {\n const timeZoneOffset = timeZone ? TIMEZONE_OFFSETS[timeZone] : undefined;\n const datePipe = new DatePipe(this.getCurrentLang(), timeZoneOffset);\n return datePipe.transform(value, pattern);\n }\n\n /**\n * Get a readable string to represent the current language (standard ISO639).\n */\n getLanguageNameByKey(lang?: string): string {\n return getStringEnumKeyByValue(LanguagesISO639, lang || this.getCurrentLang());\n }\n}\n","import { ChangeDetectorRef, Injectable, OnDestroy, Pipe, PipeTransform, inject } from '@angular/core';\nimport { Subscription } from 'rxjs'; // @todo replace rxjs with something else?\n\nimport { IDEATranslationsService } from '../translations/translations.service';\n\n@Injectable()\n// NOT pure because we need to update the translations when the language change\n@Pipe({ name: 'translate', pure: false, standalone: true })\nexport class IDEATranslatePipe implements PipeTransform, OnDestroy {\n private _translate = inject(IDEATranslationsService);\n private _ref = inject(ChangeDetectorRef);\n\n /**\n * The value to display.\n */\n private value = '';\n /**\n * The last key requested.\n */\n private lastKey: string;\n /**\n * The last params requested.\n */\n private lastParams: any[];\n /**\n * Subscription to the (current) language changes.\n */\n private onLangChange: Subscription;\n\n updateValue(key: string, interpolateParams?: any): void {\n const res = this._translate.instant(key, interpolateParams);\n this.value = res !== undefined ? res : key;\n this.lastKey = key;\n this._ref.markForCheck();\n }\n\n transform(query: string, ...args: any[]): any {\n if (!query || !query.length) return query;\n // if we ask another time for the same key, return the last value\n if (equals(query, this.lastKey) && equals(args, this.lastParams)) return this.value;\n let interpolateParams: any;\n if (args[0] !== undefined && args[0] !== null && args.length) {\n if (typeof args[0] === 'string' && args[0].length) {\n // we accept objects written in the template such as {n:1}, {'n':1}, {n:'v'}\n // which is why we might need to change it to real JSON objects such as {\"n\":1} or {\"n\":\"v\"}\n const validArgs: string = args[0]\n .replace(/(\\')?([a-zA-Z0-9_]+)(\\')?(\\s)?:/g, '\"$2\":')\n .replace(/:(\\s)?(\\')(.*?)(\\')/g, ':\"$3\"');\n try {\n interpolateParams = JSON.parse(validArgs);\n } catch (e) {\n throw new SyntaxError(`Wrong parameter in TranslatePipe. Expected a valid Object, received: ${args[0]}`);\n }\n } else if (typeof args[0] === 'object' && !Array.isArray(args[0])) interpolateParams = args[0];\n }\n // store the query, in case it changes\n this.lastKey = query;\n // store the params, in case they change\n this.lastParams = args;\n // set the value\n this.updateValue(query, interpolateParams);\n // if there is a subscription to onLangChange, clean it\n this._dispose();\n // subscribe to onLangChange event, in case the language changes\n if (!this.onLangChange) {\n this.onLangChange = this._translate.onLangChange.subscribe((): void => {\n if (this.lastKey) {\n this.lastKey = null; // we want to make sure it doesn't return the same value until it's been updated\n this.updateValue(query, interpolateParams);\n }\n }) as any as Subscription;\n }\n return this.value;\n }\n\n private _dispose(): void {\n if (this.onLangChange !== undefined) {\n this.onLangChange.unsubscribe();\n this.onLangChange = undefined;\n }\n }\n\n ngOnDestroy(): void {\n this._dispose();\n }\n}\n\n/**\n * Determines if two objects or two values are equivalent.\n *\n * Two objects or values are considered equivalent if at least one of the following is true:\n *\n * * Both objects or values pass `===` comparison.\n * * Both objects or values are of the same type and all of their properties are equal by\n * comparing them with `equals`.\n *\n * @param o1 Object or value to compare.\n * @param o2 Object or value to compare.\n * @returns true if arguments are equal.\n */\nexport function equals(o1: any, o2: any): boolean {\n if (o1 === o2) return true;\n if (o1 === null || o2 === null) return false;\n if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN\n const t1 = typeof o1,\n t2 = typeof o2;\n let length: number, key: any, keySet: any;\n if (t1 === t2 && t1 === 'object') {\n if (Array.isArray(o1)) {\n if (!Array.isArray(o2)) return false;\n if ((length = o1.length) === o2.length) {\n for (key = 0; key < length; key++) {\n if (!equals(o1[key], o2[key])) return false;\n }\n return true;\n }\n } else {\n if (Array.isArray(o2)) {\n return false;\n }\n keySet = Object.create(null);\n for (key in o1) {\n if (o1[key]) {\n if (!equals(o1[key], o2[key])) {\n return false;\n }\n keySet[key] = true;\n }\n }\n for (key in o2) {\n if (o2[key])\n if (!(key in keySet) && typeof o2[key] !== 'undefined') {\n return false;\n }\n }\n return true;\n }\n }\n return false;\n}\n","import { Injectable, inject } from '@angular/core';\nimport { Platform } from '@ionic/angular/standalone';\n\nimport { IDEAEnvironment } from '../environment';\n\n/**\n * To communicate with an AWS API Gateway istance.\n * Lighter, alternative version of _IDEAAWSAPIService_.\n */\n@Injectable({ providedIn: 'root' })\nexport class IDEAApiService {\n protected _env = inject(IDEAEnvironment);\n private _platform = inject(Platform);\n\n /**\n * The base URL to which to make requests.\n */\n baseURL: string;\n /**\n * A reference to the current's app version.\n */\n appVersion: string;\n /**\n * A reference to the current's app package (bundle).\n * It can be `undefined` in case the app doesn't have a (mobile) app bundle.\n */\n appBundle: string;\n\n /**\n * Passed as `Authorization` header.\n */\n authToken: string | (() => Promise<string>);\n /**\n * Passed as `X-API-Key` header.\n */\n apiKey: string;\n\n /**\n * Some custom headers to set so that they are used in any API request.\n */\n defaultHeaders: Record<string, string | number> = {};\n\n constructor() {\n this.baseURL = 'https://'.concat([this._env.idea.api?.url, this._env.idea.api?.stage].filter(x => x).join('/'));\n this.appVersion = this._env.idea.app?.version || '?';\n this.appBundle = this._env.idea.app?.bundle;\n }\n\n /**\n * Execute an online API request.\n * @param path resource path (e.g. `['users', userId]`)\n * @param method HTTP method\n * @param options the request options\n */\n protected async request(\n path: string[] | string,\n method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',\n options: ApiRequestOptions = {}\n ): Promise<any> {\n const url = this.baseURL.concat('/', Array.isArray(path) ? path.join('/') : path);\n\n const builtInHeaders: any = {};\n if (this.authToken) {\n if (typeof this.authToken === 'function') builtInHeaders.Authorization = await this.authToken();\n else builtInHeaders.Authorization = this.authToken;\n }\n if (this.apiKey) builtInHeaders['X-API-Key'] = this.apiKey;\n\n const headers: any = { ...builtInHeaders, ...this.defaultHeaders, ...options.headers };\n\n const searchParams = new URLSearchParams();\n searchParams.append('_v', this.appVersion);\n searchParams.append('_p', this._platform.platforms().join(' '));\n if (this.appBundle) searchParams.append('_b', this.appBundle);\n if (options.params) {\n for (const paramName in options.params) {\n const param = options.params[paramName];\n if (Array.isArray(param)) for (const arrayElement of param) searchParams.append(paramName, arrayElement);\n else searchParams.append(paramName, param as string);\n }\n }\n\n let body: any = null;\n if (options.body) body = JSON.stringify(options.body);\n\n const res = await fetch(url.concat('?', searchParams.toString()), { method, headers, body });\n if (res.status === 200) return await res.json();\n\n let errMessage: string;\n try {\n errMessage = (await res.json()).message;\n } catch (err) {\n errMessage = 'Operation failed';\n }\n throw new Error(errMessage);\n }\n\n /**\n * GET request.\n */\n async getResource(path: string[] | string, options?: ApiRequestOptions): Promise<any> {\n return await this.request(path, 'GET', options);\n }\n\n /**\n * POST request.\n */\n async postResource(path: string[] | string, options?: ApiRequestOptions): Promise<any> {\n return await this.request(path, 'POST', options);\n }\n\n /**\n * PUT request.\n */\n async putResource(path: string[] | string, options?: ApiRequestOptions): Promise<any> {\n return await this.request(path, 'PUT', options);\n }\n\n /**\n * PATCH request.\n */\n async patchResource(path: string[] | string, options?: ApiRequestOptions): Promise<any> {\n return await this.request(path, 'PATCH', options);\n }\n\n /**\n * DELETE request.\n */\n async deleteResource(path: string[] | string, options?: ApiRequestOptions): Promise<any> {\n return await this.request(path, 'DELETE', options);\n }\n}\n\n/**\n * The options of an API request.\n */\ninterface ApiRequestOptions {\n /**\n * The additional headers of the request.\n * The headers \"Authorization\" and \"X-API-Key\" are included by default, if set.\n * The headers set in `defaultHeaders` are always added to any API request.\n */\n headers?: { [key: string]: string | number | boolean };\n /**\n * The query parameters of the request.\n */\n params?: { [key: string]: string | number | boolean };\n /**\n * The body of the request.\n */\n body?: any;\n}\n","import { Injectable, inject } from '@angular/core';\n\nimport { Storage } from '@ionic/storage-angular';\n\n@Injectable({ providedIn: 'root' })\nexport class IDEAStorageService {\n private _storage = inject(Storage);\n\n private theStorage: Storage | null = null;\n\n constructor() {\n this.init();\n }\n\n async init(): Promise<void> {\n const storage = await this._storage.create();\n this.theStorage = storage;\n }\n async ready(): Promise<void> {\n return new Promise(resolve => this.readyHelper(resolve));\n }\n private readyHelper(resolve: any): void {\n if (this.theStorage) resolve();\n else setTimeout((): void => this.readyHelper(resolve), 100);\n }\n\n async set(key: string, value: any): Promise<void> {\n return await this.theStorage?.set(key, value);\n }\n async get(key: string): Promise<any> {\n return await this.theStorage?.get(key);\n }\n async remove(key: string): Promise<void> {\n return await this.theStorage?.remove(key);\n }\n async clear(): Promise<void> {\n return await this.theStorage?.clear();\n }\n async keys(): Promise<string[]> {\n return await this.theStorage?.keys();\n }\n async length(): Promise<number> {\n return await this.theStorage?.length();\n }\n}\n","import { Injectable, inject } from '@angular/core';\nimport { NavController, ToastController } from '@ionic/angular/standalone';\nimport { AppStatus, markdown } from 'idea-toolbox';\n\nimport { IDEAEnvironment } from '../../environment';\nimport { IDEATranslationsService } from '../translations/translations.service';\nimport { IDEAApiService } from '../api.service';\nimport { IDEAStorageService } from '../storage.service';\n\n/**\n * Check whether the app has some status message or update to handle.\n */\n@Injectable({ providedIn: 'root' })\nexport class IDEAAppStatusService {\n protected _env = inject(IDEAEnvironment);\n private _nav = inject(NavController);\n private _toast = inject(ToastController);\n private _translate = inject(IDEATranslationsService);\n private _api = inject(IDEAApiService);\n private _storage = inject(IDEAStorageService);\n\n appStatus: AppStatus;\n\n storageKey: string;\n statusFileURL: string;\n\n constructor() {\n this.storageKey == (this._env.idea.project || 'app').concat('_LAST_MESSAGE');\n this.statusFileURL = `${window.location.hostname === 'localhost' ? '' : window.location.origin}/assets/status.json`;\n }\n\n /**\n * Check the app's status and take according actions.\n */\n async check(options: { viaApi?: boolean; toastColor?: string; toastPosition?: string } = {}): Promise<AppStatus> {\n const appStatus = await this.getStatus(options.viaApi);\n if (appStatus.inMaintenance || appStatus.mustUpdate) await this._nav.navigateRoot(['app-status']);\n else await this.presentToast(appStatus, { color: options.toastColor, position: options.toastPosition });\n return appStatus;\n }\n private async getStatus(viaApi = false): Promise<AppStatus> {\n if (this.appStatus) return this.appStatus;\n this.appStatus = await (viaApi ? this.getStatusFromApi() : this.getStatusFromAsset());\n return this.appStatus;\n }\n private async getStatusFromApi(): Promise<AppStatus> {\n return new AppStatus(await this._api.getResource(['status']));\n }\n private async getStatusFromAsset(): Promise<AppStatus> {\n const res = await fetch(this.statusFileURL, { method: 'GET', cache: 'no-cache' });\n if (res.status !== 200) throw new Error('Status not found');\n const statusFromS3: InternalAppVersionStatusViaAsset = await res.json();\n return new AppStatus({\n version: this._env.idea.app.version,\n inMaintenance: statusFromS3.maintenance,\n mustUpdate: statusFromS3.minVersion ? statusFromS3.minVersion > this._env.idea.app.version : false,\n content: statusFromS3.messages[this._env.idea.app.version],\n latestVersion: statusFromS3.latestVersion\n });\n }\n private async presentToast(appStatus: AppStatus, options: { color?: string; position?: string } = {}): Promise<void> {\n let message = appStatus.content || '';\n if (!message && this._env.idea.app.version < appStatus.latestVersion)\n message = this._translate._('IDEA_COMMON.APP_STATUS.NEW_VERSION', { newVersion: appStatus.latestVersion });\n\n if (!message) return;\n\n const messageAlreadyRead = await this._storage.get(this.storageKey);\n if (messageAlreadyRead === message) return; // user already saw this message\n\n const dismissMessage = (): Promise<void> => this._storage.set(this.storageKey, message);\n const buttons: any = [\n { text: this._translate._('IDEA_COMMON.APP_STATUS.GOT_IT'), role: 'cancel', handler: dismissMessage }\n ];\n const color = options.color || 'dark';\n const position: any = options.position || 'bottom';\n\n const toast = await this._toast.create({ message, buttons, position, color });\n await toast.present();\n }\n}\n\ninterface InternalAppVersionStatusViaAsset {\n /**\n * Whether the app is in maintenance mode.\n */\n maintenance: boolean;\n /**\n * The latest version of the app.\n */\n latestVersion: string;\n /**\n * The minimum required version to access the app.\n */\n minVersion: string;\n /**\n * The optional messages for each of the app's versions.\n */\n messages: { [version: string]: markdown };\n}\n","import { Component, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { FormsModule } from '@angular/forms';\nimport {\n IonButton,\n IonCard,\n IonCardContent,\n IonCardHeader,\n IonCardTitle,\n IonCol,\n IonContent,\n IonGrid,\n IonIcon,\n IonImg,\n IonRow,\n Platform\n} from '@ionic/angular/standalone';\nimport { Browser } from '@capacitor/browser';\nimport { mdToHtml, AppStatus } from 'idea-toolbox';\n\nimport { IDEAEnvironment } from '../../environment';\nimport { IDEATranslationsService } from '../translations/translations.service';\nimport { IDEATranslatePipe } from '../translations/translate.pipe';\nimport { IDEAAppStatusService } from './appStatus.service';\n\n/**\n * Handle blocking status messaging for the app.\n */\n@Component({\n selector: 'idea-app-status',\n standalone: true,\n imports: [\n CommonModule,\n FormsModule,\n IonContent,\n IonCard,\n IonCardHeader,\n IonCardContent,\n IonCardTitle,\n IonImg,\n IonGrid,\n IonRow,\n IonCol,\n IonButton,\n IonIcon,\n IDEATranslatePipe\n ],\n template: `\n @if (status) {\n <ion-content class=\"ion-padding\">\n <div class=\"maxWidthContainer\">\n <ion-card color=\"dark\">\n <ion-card-header>\n <ion-card-title class=\"ion-text-center\">\n <h3>{{ getTitle() }}</h3>\n </ion-card-title>\n </ion-card-header>\n <ion-card-content class=\"ion-align-items-center\">\n <ion-img class=\"logo\" [src]=\"appIconURI\" (ionError)=\"$event.target.style.display = 'none'\" />\n @if (htmlContent) {\n <p class=\"htmlContent\" [innerHTML]=\"htmlContent\"></p>\n }\n @if (status.mustUpdate) {\n <ion-grid>\n <ion-row>\n <ion-col class=\"ion-text-center\">\n @if (isIOS() && appleStoreURL) {\n <ion-button (click)=\"opeAppleStoreLink()\">\n <ion-icon slot=\"start\" name=\"logo-apple-appstore\" />\n {{ 'IDEA_COMMON.APP_STATUS.UPDATE' | translate }}\n </ion-button>\n }\n @if (isAndroid() && googleStoreURL) {\n <ion-button (click)=\"openGoogleStoreLink()\">\n <ion-icon slot=\"start\" name=\"logo-google-playstore\" />\n {{ 'IDEA_COMMON.APP_STATUS.UPDATE' | translate }}\n </ion-button>\n }\n </ion-col>\n </ion-row>\n </ion-grid>\n }\n </ion-card-content>\n </ion-card>\n </div>\n </ion-content>\n }\n `,\n styles: [\n `\n ion-img.logo {\n width: 100px;\n margin: 0 auto;\n margin-bottom: 24px;\n }\n p.htmlContent {\n margin: 0 0 24px 0;\n padding: 20px;\n }\n `\n ]\n})\nexport class IDEAAppStatusPage {\n protected _env = inject(IDEAEnvironment);\n private _platform = inject(Platform);\n private _translate = inject(IDEATranslationsService);\n private _appStatus = inject(IDEAAppStatusService);\n\n status: AppStatus;\n htmlContent: string;\n\n appleStoreURL: string;\n googleStoreURL: string;\n\n appIconURI = '/assets/icons/icon.svg';\n\n constructor() {\n this.appleStoreURL = (this._env.idea.app as any)?.appleStoreURL;\n this.googleStoreURL = (this._env.idea.app as any)?.googleStoreURL;\n }\n\n async ionViewWillEnter(): Promise<void> {\n this.status = await this._appStatus.check();\n if (this.status.content) this.htmlContent = mdToHtml(this.status.content);\n }\n\n getTitle(): string {\n if (this.status.inMaintenance) return this._translate._('IDEA_COMMON.APP_STATUS.MAINTENANCE');\n if (this.status.mustUpdate) return this._translate._('IDEA_COMMON.APP_STATUS.MUST_UPDATE');\n return this._translate._('IDEA_COMMON.APP_STATUS.EVERYTHING_IS_OK');\n }\n\n isAndroid(): boolean {\n return this._platform.is('android');\n }\n isIOS(): boolean {\n return this._platform.is('ios');\n }\n\n async openGoogleStoreLink(): Promise<void> {\n await Browser.open({ url: this.googleStoreURL });\n }\n async opeAppleStoreLink(): Promise<void> {\n await Browser.open({ url: this.appleStoreURL });\n }\n}\n","import { Routes } from '@angular/router';\n\nimport { IDEAAppStatusPage } from './appStatus.page';\n\nexport const ideaAppStatusRoutes: Routes = [{ path: '', component: IDEAAppStatusPage }];\n","import { Injectable, inject } from '@angular/core';\nimport { LoadingController } from '@ionic/angular/standalone';\n\nimport { IDEATranslationsService } from './translations/translations.service';\n\n@Injectable({ providedIn: 'root' })\nexport class IDEALoadingService {\n private _loading = inject(LoadingController);\n private _translate = inject(IDEATranslationsService);\n\n private loadingElement: HTMLIonLoadingElement;\n\n /**\n * Show a loading animation.\n * @param content loading message\n */\n async show(content?: string): Promise<void> {\n const message = content || this._translate._('IDEA_COMMON.LOADING.PLEASE_WAIT');\n this.loadingElement = await this._loading.create({ message });\n return await this.loadingElement.present();\n }\n /**\n * Change the content of the loading animation, while it's already on.\n * @param content new loading message\n */\n setContent(content: string): void {\n if (this.loadingElement) this.loadingElement.textContent = content;\n }\n /**\n * Hide the loading animation.\n */\n async hide(): Promise<boolean> {\n if (this.loadingElement) return await this.loadingElement.dismiss();\n else return false;\n }\n}\n","import { Injectable, inject } from '@angular/core';\nimport { ToastController } from '@ionic/angular/standalone';\n\nimport { IDEATranslationsService } from './translations/translations.service';\n\n@Injectable({ providedIn: 'root' })\nexport class IDEAMessageService {\n private _toast = inject(ToastController);\n private _translate = inject(IDEATranslationsService);\n\n /**\n * Show a generic message toast.\n * @param message message to show\n * @param color Ionic colors defined in the theme\n */\n private async show(message: string, color: string, dontTranslate: boolean): Promise<void> {\n message = dontTranslate ? message : this._translate._(message);\n\n const duration = 3000;\n const position = 'bottom';\n const buttons = [{ text: 'X', role: 'cancel' }];\n\n const toast = await this._toast.create({ message, duration, position, color, buttons });\n return await toast.present();\n }\n\n /**\n * Show an info message toast.\n * @param message message to show\n */\n async info(message: string, dontTranslate?: boolean): Promise<void> {\n return await this.show(message, 'dark', dontTranslate);\n }\n /**\n * Show a success message toast.\n * @param message message to show\n */\n async success(message: string, dontTranslate?: boolean): Promise<void> {\n return await this.show(message, 'success', dontTranslate);\n }\n /**\n * Show an error message toast.\n * @param message message to show\n */\n async error(message: string, dontTranslate?: boolean): Promise<void> {\n return await this.show(message, 'danger', dontTranslate);\n }\n /**\n * Show an warning message toast.\n * @param message message to show\n */\n async warning(message: string, dontTranslate?: boolean): Promise<void> {\n return await this.show(message, 'warning', dontTranslate);\n }\n}\n","import { inject, Injectable } from '@angular/core';\nimport { Attachment } from 'idea-toolbox';\n\nimport { IDEAEnvironment } from '../../environment';\nimport { IDEAApiService } from '../api.service';\n\n@Injectable({ providedIn: 'root' })\nexport class IDEAAttachmentsService {\n protected _env = inject(IDEAEnvironment);\n protected _api = inject(IDEAApiService);\n\n /**\n * Upload a new attachment related to an entity and return the `attachmentId`.\n */\n async uploadAndGetId(\n file: File,\n entityPath: string | string[],\n options: { customAction?: string } = {}\n ): Promise<string> {\n const { maxFileUploadSizeMB } = this._env.idea.app;\n if (maxFileUploadSizeMB && bytesToMegaBytes(file.size) > maxFileUploadSizeMB) throw new Error('File is too big');\n const body = { action: options.customAction || 'GET_ATTACHMENT_UPLOAD_URL' };\n const { url, id } = await this._api.patchResource(entityPath, { body });\n await fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });\n return id;\n }\n\n /**\n * Get the URL to download an attachment related to an entity.\n */\n async getDownloadURL(\n attachment: Attachment | string,\n entityPath: string | string[],\n options: { customAction?: string } = {}\n ): Promise<string> {\n const body = {\n action: options.customAction || 'GET_ATTACHMENT_DOWNLOAD_URL',\n attachmentId: typeof attachment === 'string' ? attachment : attachment.attachmentId\n };\n const { url } = await this._api.patchResource(entityPath, { body });\n return url;\n }\n}\n\n/**\n * Approximate conversion of bytes in MB.\n */\nexport const bytesToMegaBytes = (bytes: number): number => bytes / 1024 ** 2;\n","import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CommonModule } from '@angular/common';\nimport {\n IonButton,\n IonIcon,\n IonInput,\n IonItem,\n IonItemDivider,\n IonLabel,\n IonNote,\n IonReorder,\n IonReorderGroup,\n IonSpinner\n} from '@ionic/angular/standalone';\nimport { Attachment } from 'idea-toolbox';\n\nimport { IDEAEnvironment } from '../../environment';\nimport { IDEATranslatePipe } from '../translations/translate.pipe';\nimport { IDEALoadingService } from '../loading.service';\nimport { IDEAMessageService } from '../message.service';\nimport { IDEATranslationsService } from '../translations/translations.service';\nimport { IDEAAttachmentsService } from './attachments.service';\n\n@Component({\n standalone: true,\n imports: [\n CommonModule,\n FormsModule,\n IDEATranslatePipe,\n IonItem,\n IonInput,\n IonLabel,\n IonNote,\n IonSpinner,\n IonButton,\n IonIcon,\n IonItemDivider,\n IonReorderGroup,\n IonReorder\n ],\n selector: 'idea-attachments',\n template: `\n <ion-reorder-group [disabled]=\"disabled\" (ionItemReorder)=\"reorderAttachments($event)\">\n @for (att of attachments; track att.attachmentId) {\n <ion-item class=\"attachmentItem\" [color]=\"color\" [lines]=\"!disabled ? 'inset' : 'none'\">\n @if (!disabled) {\n <ion-reorder slot=\"start\" />\n <ion-input [(ngModel)]=\"att.name\" />\n <ion-note slot=\"end\">.{{ att.format }}</ion-note>\n @if (!att.attachmentId) {\n <ion-spinner\n size=\"small\"\n color=\"medium\"\n slot=\"end\"\n [title]=\"'IDEA_COMMON.ATTACHMENTS.UPLOADING' | translate\"\n />\n }\n <ion-button\n slot=\"end\"\n color=\"danger\"\n fill=\"clear\"\n [title]=\"'IDEA_COMMON.ATTACHMENTS.REMOVE_ATTACHMENT' | translate\"\n (click)=\"removeAttachment(att)\"\n >\n <ion-icon icon=\"remove\" slot=\"icon-only\" />\n </ion-button>\n } @else {\n <ion-label class=\"ion-text-wrap\">{{ att.name }}.{{ att.format }}</ion-label>\n }\n @if (att.attachmentId) {\n <ion-button\n slot=\"end\"\n color=\"medium\"\n fill=\"clear\"\n [title]=\"'IDEA_COMMON.ATTACHMENTS.DOWNLOAD_ATTACHMENT' | translate\"\n (click)=\"downloadAttachment(att)\"\n >\n <ion-icon icon=\"cloud-download-outline\" slot=\"icon-only\" />\n </ion-button>\n }\n </ion-item>\n } @empty {\n @if (disabled) {\n <ion-item lines=\"none\" [color]=\"color\">\n <ion-label>\n <i>{{ 'IDEA_COMMON.ATTACHMENTS.NO_ATTACHMENTS' | translate }}</i>\n </ion-label>\n </ion-item>\n }\n }\n </ion-reorder-group>\n @if (!disabled) {\n <ion-item button [color]=\"color\" (click)=\"browseFiles()\">\n <input\n #filePicker\n type=\"file\"\n style=\"display: none\"\n [multiple]=\"multiple\"\n [accept]=\"acceptedFormats.join(',')\"\n (change)=\"addAttachmentsFromPicker($event.target)\"\n />\n <ion-label>\n <i>{{ 'IDEA_COMMON.ATTACHMENTS.TAP_TO_ADD_ATTACHMENT' | translate }}</i>\n </ion-label>\n </ion-item>\n @for (err of uploadErrors; track $index) {\n <ion-item class=\"attachmentItem error\" [color]=\"color\">\n <ion-label color=\"danger\" class=\"ion-text-wrap\">\n {{ err.file }}\n <p>{{ err.error || ('IDEA_COMMON.ATTACHMENTS.ERROR_UPLOADING_ATTACHMENT' | translate) }}</p>\n </ion-label>\n <ion-button\n slot=\"end\"\n color=\"danger\"\n fill=\"clear\"\n [title]=\"'IDEA_COMMON.ATTACHMENTS.HIDE_ERROR' | translate\"\n (click)=\"removeErrorFromList(err)\"\n >\n <ion-icon name=\"close\" slot=\"icon-only\" />\n </ion-button>\n </ion-item>\n }\n <ion-item-divider [color]=\"color\">\n <ion-label>\n {{\n 'IDEA_COMMON.ATTACHMENTS.ALLOWED_FORMATS_AND_SIZE'\n | translate: { formats: acceptedFormats.join(', '), size: maxSize }\n }}\n </ion-label>\n </ion-item-divider>\n }\n `,\n styles: [\n `\n ion-item.attachmentItem {\n --padding-start: 16px;\n --padding-end: 0;\n }\n ion-item.at