UNPKG

wacom

Version:

Module which has common services, pipes, directives and interfaces which can be used on all projects.

1,288 lines (1,279 loc) 183 kB
import * as i1$2 from '@angular/common'; import { isPlatformBrowser, DOCUMENT, CommonModule } from '@angular/common'; import * as i0 from '@angular/core'; import { inject, PLATFORM_ID, DestroyRef, signal, computed, Injectable, ChangeDetectorRef, InjectionToken, Inject, Optional, makeEnvironmentProviders, provideEnvironmentInitializer, input, ElementRef, afterNextRender, effect, Directive, Pipe, output, isSignal, ApplicationRef, EnvironmentInjector, createComponent, NgModule } from '@angular/core'; import { take, firstValueFrom, Subject, skip, takeUntil, share, filter, map, Observable, merge, combineLatest, timeout, ReplaySubject, EMPTY, defer, from, switchMap } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; import * as i1 from '@angular/common/http'; import { HttpHeaders, HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { first, catchError, filter as filter$1 } from 'rxjs/operators'; import * as i1$1 from '@angular/router'; import { NavigationEnd } from '@angular/router'; import * as i2 from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; // Add capitalize method to String prototype if it doesn't already exist if (!String.prototype.capitalize) { String.prototype.capitalize = function () { if (this.length > 0) { return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase(); } return ''; }; } // Core utilities and helpers for the Wacom app class CoreService { constructor() { this._platformId = inject(PLATFORM_ID); this._isBrowser = isPlatformBrowser(this._platformId); this._destroyRef = inject(DestroyRef); this.deviceID = ''; // After While this._afterWhile = {}; // Device management this.device = ''; // Viewport management (responsive breakpoint) this.viewport = signal('desktop', ...(ngDevMode ? [{ debugName: "viewport" }] : [])); this.isViewportMobile = computed(() => this.viewport() === 'mobile', ...(ngDevMode ? [{ debugName: "isViewportMobile" }] : [])); this.isViewportTablet = computed(() => this.viewport() === 'tablet', ...(ngDevMode ? [{ debugName: "isViewportTablet" }] : [])); this.isViewportDesktop = computed(() => this.viewport() === 'desktop', ...(ngDevMode ? [{ debugName: "isViewportDesktop" }] : [])); // Version management this.version = '1.0.0'; this.appVersion = ''; this.dateVersion = ''; // Locking management this._locked = {}; this._unlockResolvers = {}; if (this._isBrowser) { const stored = localStorage.getItem('deviceID'); this.deviceID = stored || (typeof crypto?.randomUUID === 'function' ? crypto.randomUUID() : this.UUID()); localStorage.setItem('deviceID', this.deviceID); this.detectDevice(); this.detectViewport(); } else { this.deviceID = this.UUID(); } } /** * Generates a UUID (Universally Unique Identifier) version 4. * * This implementation uses `Math.random()` to generate random values, * making it suitable for general-purpose identifiers, but **not** for * cryptographic or security-sensitive use cases. * * The format follows the UUID v4 standard: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` * where: * - `x` is a random hexadecimal digit (0–f) * - `4` indicates UUID version 4 * - `y` is one of 8, 9, A, or B * * Example: `f47ac10b-58cc-4372-a567-0e02b2c3d479` * * @returns A string containing a UUID v4. */ UUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** * Converts an object to an array. Optionally holds keys instead of values. * * @param {any} obj - The object to be converted. * @param {boolean} [holder=false] - If true, the keys will be held in the array; otherwise, the values will be held. * @returns {any[]} The resulting array. */ ota(obj, holder = false) { if (Array.isArray(obj)) return obj; if (typeof obj !== 'object' || obj === null) return []; const arr = []; for (const each in obj) { if (obj.hasOwnProperty(each) && (obj[each] || typeof obj[each] === 'number' || typeof obj[each] === 'boolean')) { if (holder) { arr.push(each); } else { arr.push(obj[each]); } } } return arr; } /** * Removes elements from `fromArray` that are present in `removeArray` based on a comparison field. * * @param {any[]} removeArray - The array of elements to remove. * @param {any[]} fromArray - The array from which to remove elements. * @param {string} [compareField='_id'] - The field to use for comparison. * @returns {any[]} The modified `fromArray` with elements removed. */ splice(removeArray, fromArray, compareField = '_id') { if (!Array.isArray(removeArray) || !Array.isArray(fromArray)) { return fromArray; } const removeSet = new Set(removeArray.map(item => item[compareField])); return fromArray.filter(item => !removeSet.has(item[compareField])); } /** * Unites multiple _id values into a single unique _id. * The resulting _id is unique regardless of the order of the input _id values. * * @param {...string[]} args - The _id values to be united. * @returns {string} The unique combined _id. */ ids2id(...args) { args.sort((a, b) => { if (Number(a.toString().substring(0, 8)) > Number(b.toString().substring(0, 8))) { return 1; } return -1; }); return args.join(); } /** * Delays the execution of a callback function for a specified amount of time. * If called again within that time, the timer resets. * * @param {string | object | (() => void)} doc - A unique identifier for the timer, an object to host the timer, or the callback function. * @param {() => void} [cb] - The callback function to execute after the delay. * @param {number} [time=1000] - The delay time in milliseconds. */ afterWhile(doc, cb, time = 1000) { if (typeof doc === 'function') { cb = doc; doc = 'common'; } if (typeof cb === 'function' && typeof time === 'number') { if (typeof doc === 'string') { clearTimeout(this._afterWhile[doc]); this._afterWhile[doc] = setTimeout(cb, time); } else if (typeof doc === 'object') { clearTimeout(doc.__afterWhile); doc.__afterWhile = setTimeout(cb, time); } else { console.warn('badly configured after while'); } } } /** * Recursively copies properties from one object to another. * Handles nested objects, arrays, and Date instances appropriately. * * @param from - The source object from which properties are copied. * @param to - The target object to which properties are copied. */ copy(from, to) { for (const each in from) { if (typeof from[each] !== 'object' || from[each] instanceof Date || Array.isArray(from[each]) || from[each] === null) { to[each] = from[each]; } else { if (typeof to[each] !== 'object' || to[each] instanceof Date || Array.isArray(to[each]) || to[each] === null) { to[each] = {}; } this.copy(from[each], to[each]); } } } /** * Detects the device type based on the user agent. */ detectDevice() { if (!this._isBrowser) return; const userAgent = navigator.userAgent || navigator.vendor || window.opera; if (/windows phone/i.test(userAgent)) { this.device = 'Windows Phone'; } else if (/android/i.test(userAgent)) { this.device = 'Android'; } else if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) { this.device = 'iOS'; } else { this.device = 'Web'; } } /** * Checks if the device is a mobile device. * @returns {boolean} - Returns true if the device is a mobile device. */ isMobile() { return (this.device === 'Windows Phone' || this.device === 'Android' || this.device === 'iOS'); } /** * Checks if the device is a tablet. * @returns {boolean} - Returns true if the device is a tablet. */ isTablet() { if (!this._isBrowser) return false; return this.device === 'iOS' && /iPad/.test(navigator.userAgent); } /** * Checks if the device is a web browser. * @returns {boolean} - Returns true if the device is a web browser. */ isWeb() { return this.device === 'Web'; } /** * Checks if the device is an Android device. * @returns {boolean} - Returns true if the device is an Android device. */ isAndroid() { return this.device === 'Android'; } /** * Checks if the device is an iOS device. * @returns {boolean} - Returns true if the device is an iOS device. */ isIos() { return this.device === 'iOS'; } detectViewport() { if (!this._isBrowser) return; const mqMobile = window.matchMedia('(max-width: 767.98px)'); const mqTablet = window.matchMedia('(min-width: 768px) and (max-width: 1023.98px)'); const mqDesktop = window.matchMedia('(min-width: 1024px)'); const update = () => { if (mqMobile.matches) return this.viewport.set('mobile'); if (mqTablet.matches) return this.viewport.set('tablet'); return this.viewport.set('desktop'); }; update(); mqMobile.addEventListener('change', update); mqTablet.addEventListener('change', update); mqDesktop.addEventListener('change', update); this._destroyRef.onDestroy(() => { mqMobile.removeEventListener('change', update); mqTablet.removeEventListener('change', update); mqDesktop.removeEventListener('change', update); }); } /** * Sets the combined version string based on appVersion and dateVersion. */ setVersion() { this.version = this.appVersion || ''; this.version += this.version && this.dateVersion ? ' ' : ''; this.version += this.dateVersion || ''; } /** * Sets the app version and updates the combined version string. * * @param {string} appVersion - The application version to set. */ setAppVersion(appVersion) { this.appVersion = appVersion; this.setVersion(); } /** * Sets the date version and updates the combined version string. * * @param {string} dateVersion - The date version to set. */ setDateVersion(dateVersion) { this.dateVersion = dateVersion; this.setVersion(); } /** * Locks a resource to prevent concurrent access. * @param which - The resource to lock, identified by a string. */ lock(which) { this._locked[which] = true; if (!this._unlockResolvers[which]) { this._unlockResolvers[which] = []; } } /** * Unlocks a resource, allowing access. * @param which - The resource to unlock, identified by a string. */ unlock(which) { this._locked[which] = false; if (this._unlockResolvers[which]) { this._unlockResolvers[which].forEach(resolve => resolve()); this._unlockResolvers[which] = []; } } /** * Returns a Promise that resolves when the specified resource is unlocked. * @param which - The resource to watch for unlocking, identified by a string. * @returns A Promise that resolves when the resource is unlocked. */ onUnlock(which) { if (!this._locked[which]) { return Promise.resolve(); } return new Promise(resolve => { if (!this._unlockResolvers[which]) { this._unlockResolvers[which] = []; } this._unlockResolvers[which].push(resolve); }); } /** * Checks if a resource is locked. * @param which - The resource to check, identified by a string. * @returns True if the resource is locked, false otherwise. */ locked(which) { return !!this._locked[which]; } // Angular Signals // /** * Converts a plain object into a signal-wrapped object. * Optionally wraps specific fields of the object as individual signals, * and merges them into the returned signal for fine-grained reactivity. * * @template Document - The type of the object being wrapped. * @param {Document} document - The plain object to wrap into a signal. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map where each key is a field name and the value is a function * to extract the initial value for that field. These fields will be wrapped * as separate signals and embedded in the returned object. * * @returns {WritableSignal<Document>} A signal-wrapped object, possibly containing * nested field signals for more granular control. * * @example * const user = { _id: '1', name: 'Alice', score: 42 }; * const sig = toSignal(user, { score: (u) => u.score }); * console.log(sig().name); // 'Alice' * console.log(sig().score()); // 42 — field is now a signal */ toSignal(document, signalFields = {}) { if (Object.keys(signalFields).length) { const fields = {}; for (const key in signalFields) { fields[key] = signal(signalFields[key](document)); } return signal({ ...document, ...fields }); } else { return signal(document); } } /** * Converts an array of objects into an array of Angular signals. * Optionally wraps specific fields of each object as individual signals. * * @template Document - The type of each object in the array. * @param {Document[]} arr - Array of plain objects to convert into signals. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map where keys are field names and values are functions that extract the initial value * from the object. These fields will be turned into separate signals. * * @returns {WritableSignal<Document>[]} An array where each item is a signal-wrapped object, * optionally with individual fields also wrapped in signals. * * @example * toSignalsArray(users, { * name: (u) => u.name, * score: (u) => u.score, * }); */ toSignalsArray(arr, signalFields = {}) { return arr.map(obj => this.toSignal(obj, signalFields)); } /** * Adds a new object to the signals array. * Optionally wraps specific fields of the object as individual signals before wrapping the whole object. * * @template Document - The type of the object being added. * @param {WritableSignal<Document>[]} signals - The signals array to append to. * @param {Document} item - The object to wrap and push as a signal. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map of fields to be wrapped as signals within the object. * * @returns {void} */ pushSignal(signals, item, signalFields = {}) { signals.push(this.toSignal(item, signalFields)); } /** * Removes the first signal from the array whose object's field matches the provided value. * @template Document * @param {WritableSignal<Document>[]} signals - The signals array to modify. * @param {unknown} value - The value to match. * @param {string} [field='_id'] - The object field to match against. * @returns {void} */ removeSignalByField(signals, value, field = '_id') { const idx = signals.findIndex(sig => sig()[field] === value); if (idx > -1) signals.splice(idx, 1); } /** * Returns a generic trackBy function for *ngFor, tracking by the specified object field. * @template Document * @param {string} field - The object field to use for tracking (e.g., '_id'). * @returns {(index: number, sig: Signal<Document>) => unknown} TrackBy function for Angular. */ trackBySignalField(field) { return (_, sig) => sig()[field]; } /** * Finds the first signal in the array whose object's field matches the provided value. * @template Document * @param {Signal<Document>[]} signals - Array of signals to search. * @param {unknown} value - The value to match. * @param {string} [field='_id'] - The object field to match against. * @returns {Signal<Document> | undefined} The found signal or undefined if not found. */ findSignalByField(signals, value, field = '_id') { return signals.find(sig => sig()[field] === value); } /** * Updates the first writable signal in the array whose object's field matches the provided value. * @template Document * @param {WritableSignal<Document>[]} signals - Array of writable signals to search. * @param {unknown} value - The value to match. * @param {(val: Document) => Document} updater - Function to produce the updated object. * @param {string} field - The object field to match against. * @returns {void} */ updateSignalByField(signals, value, updater, field) { const sig = this.findSignalByField(signals, value, field); if (sig) sig.update(updater); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CoreService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CoreService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CoreService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); /** * Abstract reusable base class for CRUD list views. * It encapsulates pagination, modals, and document handling logic. * * @template Service - A service implementing CrudServiceInterface for a specific document type * @template Document - The data model extending CrudDocument */ class CrudComponent { /** * Constructor * * @param formConfig - Object describing form title and its component structure * @param formService - Any service that conforms to FormServiceInterface (usually casted) * @param crudService - CRUD service implementing get/create/update/delete */ constructor(formConfig, formService, crudService, module = '') { /** The array of documents currently loaded and shown */ this.documents = signal([], ...(ngDevMode ? [{ debugName: "documents" }] : [])); /** Current pagination page */ this.page = 1; /** CoreService handles timing and copying helpers */ this.__core = inject(CoreService); /** ChangeDetectorRef handles on push strategy */ this.__cdr = inject(ChangeDetectorRef); this.localDocumentsFilter = () => true; /** Fields considered when performing bulk updates. */ this.updatableFields = ['_id', 'name', 'description', 'data']; /** Data source mode used for document retrieval. */ this.configType = 'server'; /** Number of documents fetched per page when paginating. */ this.perPage = 20; /** Name of the collection or module used for contextual actions. */ this._module = ''; const form = formConfig; this.__formService = formService; this.form = form; this.crudService = crudService; this._module = module; } /** * Allow set query customization */ setDocumentsQuery(query) { return query; } /** * Loads documents for a given page. */ setDocuments(page = this.page, query = '') { query = this.setDocumentsQuery(query); return new Promise(resolve => { if (this.configType === 'server') { this.page = page; this.__core.afterWhile(this, () => { this.crudService .get({ page, query }, this.getOptions()) .subscribe((docs) => { this.documents.update(() => (docs || []).map(doc => this.crudService.getSignal(doc))); resolve(); this.__cdr.markForCheck(); }); }, 250); } else { this.documents.update(() => this.crudService .getDocs() .filter(this.localDocumentsFilter) .map(doc => this.crudService.getSignal(doc))); this.crudService.loaded.pipe(take(1)).subscribe(() => { resolve(); this.__cdr.markForCheck(); }); } }); } /** * Clears temporary metadata before document creation. */ preCreate(doc) { delete doc.__creating; } /** * Funciton which controls whether the create functionality is available. */ allowCreate() { return true; } /** * Funciton which controls whether the update and delete functionality is available. */ allowMutate() { return true; } /** * Funciton which controls whether the unique url functionality is available. */ allowUrl() { return true; } /** Determines whether manual sorting controls are available. */ allowSort() { return false; } /** * Funciton which prepare get crud options. */ getOptions() { return {}; } /** * Handles bulk creation and updating of documents. * In creation mode, adds new documents. * In update mode, syncs changes and deletes removed entries. */ bulkManagement(isCreateFlow = true) { return () => { this.__formService .modalDocs(isCreateFlow ? [] : this.documents().map((obj) => Object.fromEntries(this.updatableFields.map(key => [key, obj()[key]])))) .then(async (docs) => { if (isCreateFlow) { for (const doc of docs) { this.preCreate(doc); await firstValueFrom(this.crudService.create(doc)); } } else { for (const document of this.documents()) { if (!docs.find(d => d._id === document()._id)) { await firstValueFrom(this.crudService.delete(document())); } } for (const doc of docs) { const local = this.documents().find(document => document()._id === doc._id); if (local) { local.update(document => { this.__core.copy(doc, document); return document; }); await firstValueFrom(this.crudService.update(local())); } else { this.preCreate(doc); await firstValueFrom(this.crudService.create(doc)); } } } this.setDocuments(); }); }; } /** Opens a modal to create a new document. */ create() { this.__formService.modal(this.form, { label: 'Create', click: async (created, close) => { close(); this.preCreate(created); await firstValueFrom(this.crudService.create(created)); this.setDocuments(); }, }, { data: {} }, () => { }, { resetOnSubmit: true, }); } /** Displays a modal to edit an existing document. */ update(doc) { this.__formService.modal(this.form, { label: 'Update', click: (updated, close) => { close(); this.__core.copy(updated, doc); this.crudService.update(doc); this.__cdr.markForCheck(); }, }, doc); } /** Requests confirmation before deleting the provided document. */ async delete(doc) { this.crudService.delete(doc).subscribe(() => { this.setDocuments(); }); } /** Opens a modal to edit the document's unique URL. */ mutateUrl(doc) { this.__formService.modalUnique(this._module, 'url', doc); } /** Moves the given document one position up and updates ordering. */ moveUp(doc) { const index = this.documents().findIndex(document => document()._id === doc._id); if (index) { this.documents.update(documents => { documents.splice(index, 1); documents.splice(index - 1, 0, this.crudService.getSignal(doc)); return documents; }); } for (let i = 0; i < this.documents().length; i++) { if (this.documents()[i]().order !== i) { this.documents()[i]().order = i; this.crudService.update(this.documents()[i]()); } } this.__cdr.markForCheck(); } /** * Configuration object used by the UI for rendering table and handling actions. */ getConfig() { const config = { create: this.allowCreate() ? () => { this.create(); } : null, update: this.allowMutate() ? (doc) => { this.update(doc); } : null, delete: this.allowMutate() ? (doc) => { this.delete(doc); } : null, buttons: [], headerButtons: [], allDocs: true, }; if (this.allowUrl()) { config.buttons.push({ icon: 'cloud_download', click: (doc) => { this.mutateUrl(doc); }, }); } if (this.allowSort()) { config.buttons.push({ icon: 'arrow_upward', click: (doc) => { this.moveUp(doc); }, }); } if (this.allowCreate()) { config.headerButtons.push({ icon: 'playlist_add', click: this.bulkManagement(), class: 'playlist', }); } if (this.allowMutate()) { config.headerButtons.push({ icon: 'edit_note', click: this.bulkManagement(false), class: 'edit', }); } return this.configType === 'server' ? { ...config, paginate: this.setDocuments.bind(this), perPage: this.perPage, setPerPage: this.crudService.setPerPage?.bind(this.crudService), allDocs: false, } : config; } } class EmitterService { constructor() { this._signals = new Map(); this._closers = new Map(); this._streams = new Map(); this._done = new Map(); } _getSignal(id) { let s = this._signals.get(id); if (!s) { // emit even if same payload repeats s = signal(undefined, { equal: () => false }); this._signals.set(id, s); } return s; } _getCloser(id) { let c = this._closers.get(id); if (!c) { c = new Subject(); this._closers.set(id, c); } return c; } _getStream(id) { let obs$ = this._streams.get(id); if (!obs$) { const sig = this._getSignal(id); const closed$ = this._getCloser(id); obs$ = toObservable(sig).pipe( // Subject-like: don't replay the current value on subscribe skip(1), takeUntil(closed$), share()); this._streams.set(id, obs$); } return obs$; } /** Emit an event */ emit(id, data) { this._getSignal(id).set(data); } /** Listen for events (hot, completes when off(id) is called) */ on(id) { return this._getStream(id); } /** Complete and remove a channel */ off(id) { const closer = this._closers.get(id); if (closer) { closer.next(); closer.complete(); this._closers.delete(id); } this._signals.delete(id); this._streams.delete(id); } offAll() { for (const id of Array.from(this._closers.keys())) this.off(id); } has(id) { return this._signals.has(id); } _getDoneSignal(id) { let s = this._done.get(id); if (!s) { s = signal(undefined); this._done.set(id, s); } return s; } /** Mark task as completed with a payload (default: true) */ complete(task, value = true) { this._getDoneSignal(task).set(value); } /** Clear completion so it can be awaited again */ clearCompleted(task) { const s = this._done.get(task) ?? this._getDoneSignal(task); s.set(undefined); } /** Read current completion payload (undefined => not completed) */ completed(task) { return this._getDoneSignal(task)(); } isCompleted(task) { return this._getDoneSignal(task)() !== undefined; } onComplete(tasks, opts) { const list = (Array.isArray(tasks) ? tasks : [tasks]).filter(Boolean); const streams = list.map(id => toObservable(this._getDoneSignal(id)).pipe(filter((v) => v !== undefined), map(v => v))); let source$; if (list.length <= 1) { // single-task await source$ = streams[0]?.pipe(take(1)) ?? new Observable(); } else if (opts?.mode === 'any') { source$ = merge(...streams).pipe(take(1)); } else { source$ = combineLatest(streams).pipe(take(1)); } if (opts?.timeoutMs && Number.isFinite(opts.timeoutMs)) { source$ = source$.pipe(timeout({ first: opts.timeoutMs })); } if (opts?.abort) { const abort$ = new Observable(sub => { const handler = () => { sub.next(); sub.complete(); }; opts.abort.addEventListener('abort', handler); return () => opts.abort.removeEventListener('abort', handler); }); source$ = source$.pipe(takeUntil(abort$)); } return source$; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: EmitterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: EmitterService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: EmitterService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); const CONFIG_TOKEN = new InjectionToken('config'); const DEFAULT_CONFIG = { store: { prefix: 'waStore', }, meta: { useTitleSuffix: false, defaults: { links: {} }, }, socket: false, http: { url: '', headers: {}, }, }; const DEFAULT_HTTP_CONFIG = { headers: {}, url: '', }; class HttpService { constructor(config, _http) { this._http = _http; this._isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); // An array of error handling callbacks this.errors = []; // Base URL for HTTP requests this.url = ''; // Flag to lock the service to prevent multiple requests this.locked = false; // Array to store setTimeout IDs for managing request locks this.awaitLocked = []; // Object to store HTTP headers this._headers = {}; // Instance of HttpHeaders with current headers this._http_headers = new HttpHeaders(this._headers); // Initialize HTTP configuration and headers from injected config this._config = { ...DEFAULT_HTTP_CONFIG, ...(config.http || {}), }; if (typeof this._config.url === 'string') { this.setUrl(this._config.url); } if (this._isBrowser) { this.url = localStorage.getItem('wacom-http.url') || this.url; const raw = localStorage.getItem('wacom-http.headers'); this._headers = raw ? JSON.parse(raw) : this._headers; this._http_headers = new HttpHeaders(this._headers); } if (typeof this._config.headers === 'object') { for (const header in this._config.headers) { this._headers[header] = this._config.headers[header]; } this._http_headers = new HttpHeaders(this._headers); } } // Set a new base URL and save it in the store setUrl(url) { this.url = url; if (this._isBrowser) { localStorage.setItem('wacom-http.url', url); } } // Remove the base URL and revert to the default or stored one removeUrl() { this.url = this._config.url || ''; if (this._isBrowser) { localStorage.removeItem('wacom-http.url'); } } // Set a new HTTP header and update the stored headers set(key, value) { this._headers[key] = value; if (this._isBrowser) { localStorage.setItem('wacom-http.headers', JSON.stringify(this._headers)); } this._http_headers = new HttpHeaders(this._headers); } // Get the value of a specific HTTP header header(key) { return this._headers[key]; } // Remove a specific HTTP header and update the stored headers remove(key) { delete this._headers[key]; if (this._isBrowser) { localStorage.setItem('wacom-http.headers', JSON.stringify(this._headers)); } this._http_headers = new HttpHeaders(this._headers); } // Internal method to make HTTP requests based on the method type _httpMethod(method, _url, doc, headers) { if (method === 'post') { return this._http.post(_url, doc, headers); } else if (method === 'put') { return this._http.put(_url, doc, headers); } else if (method === 'patch') { return this._http.patch(_url, doc, headers); } else if (method === 'delete') { return this._http.delete(_url, headers); } else { return this._http.get(_url, headers); } } /** * Internal method to handle HTTP requests for various methods (POST, PUT, PATCH, DELETE, GET). * * Features: * - **Request Locking**: Manages request locking to prevent simultaneous requests. * - **Acceptance Check**: Validates the server response against a user-defined `acceptance` function. * If the check fails, the response is rejected with an error. * - **Replace Logic**: Allows modification of specific parts of the response object, determined by a user-defined `replace` function. * Can handle both objects and arrays within the response. * - **Field Filtering**: Supports extracting specific fields from response objects or arrays. * - **Legacy Support**: Compatible with callback-based usage alongside Observables. * - **ReplaySubject**: Ensures that the response can be shared across multiple subscribers. * * @param url - The endpoint to send the HTTP request to (relative to the base URL). * @param doc - The request payload for methods like POST, PUT, and PATCH. * @param callback - A legacy callback function to handle the response. * @param opts - Additional options: * - `err`: Error handling callback. * - `acceptance`: Function to validate the server response. Should return `true` for valid responses. * - `replace`: Function to modify specific parts of the response data. * - `fields`: Array of fields to extract from the response object(s). * - `data`: Path in the response where the data resides for `replace` and `fields` operations. * - `skipLock`: If `true`, bypasses request locking. * - `url`: Overrides the base URL for this request. * @param method - The HTTP method (e.g., 'post', 'put', 'patch', 'delete', 'get'). * @returns An Observable that emits the processed HTTP response or an error. */ _post(url, doc, callback = (resp) => { }, opts = {}, method = 'post') { if (typeof opts === 'function') { opts = { err: opts }; } if (!opts.err) { opts.err = (err) => { }; } // Handle request locking to avoid multiple simultaneous requests if (this.locked && !opts.skipLock) { return new Observable(observer => { const wait = setTimeout(() => { this._post(url, doc, callback, opts, method).subscribe(observer); }, 100); this.awaitLocked.push(wait); }); } const _url = (opts.url || this.url) + url; this.prepare_handle(_url, doc); // Using ReplaySubject to allow multiple subscriptions without re-triggering the HTTP request const responseSubject = new ReplaySubject(1); this._httpMethod(method, _url, doc, { headers: this._http_headers }) .pipe(first(), catchError((error) => { this.handleError(opts.err, () => { this._post(url, doc, callback, opts, method).subscribe(responseSubject); })(error); responseSubject.error(error); return EMPTY; })) .subscribe({ next: (resp) => { if (opts.acceptance && typeof opts.acceptance === 'function') { if (!opts.acceptance(resp)) { const error = new HttpErrorResponse({ error: 'Acceptance failed', status: 400, }); this.handleError(opts.err, () => { })(error); responseSubject.error(error); return; } } if (opts.replace && typeof opts.replace === 'function') { if (Array.isArray(this._getObjectToReplace(resp, opts.data))) { this._getObjectToReplace(resp, opts.data).map((item) => opts.replace(item)); } else if (this._getObjectToReplace(resp, opts.data)) { opts.replace(this._getObjectToReplace(resp, opts.data)); } } if (Array.isArray(opts.fields)) { if (Array.isArray(this._getObjectToReplace(resp, opts.data))) { this._getObjectToReplace(resp, opts.data).map((item) => { return this._newDoc(item, opts.fields); }); } else if (this._getObjectToReplace(resp, opts.data)) { const newDoc = this._newDoc(this._getObjectToReplace(resp, opts.data), opts.fields); if (opts.data) { this._setObjectToReplace(resp, opts.data, newDoc); } else { resp = newDoc; } } } this.response_handle(_url, resp, () => callback(resp)); responseSubject.next(resp); responseSubject.complete(); }, error: err => responseSubject.error(err), complete: () => responseSubject.complete(), }); return responseSubject.asObservable(); } /** * Public method to perform a POST request. * - Supports legacy callback usage. * - Returns an Observable for reactive programming. */ post(url, doc, callback = (resp) => { }, opts = {}) { return this._post(url, doc, callback, opts); } /** * Public method to perform a PUT request. * - Supports legacy callback usage. * - Returns an Observable for reactive programming. */ put(url, doc, callback = (resp) => { }, opts = {}) { return this._post(url, doc, callback, opts, 'put'); } /** * Public method to perform a PATCH request. * - Supports legacy callback usage. * - Returns an Observable for reactive programming. */ patch(url, doc, callback = (resp) => { }, opts = {}) { return this._post(url, doc, callback, opts, 'patch'); } /** * Public method to perform a DELETE request. * - Supports legacy callback usage. * - Returns an Observable for reactive programming. */ delete(url, callback = (resp) => { }, opts = {}) { return this._post(url, null, callback, opts, 'delete'); } /** * Public method to perform a GET request. * - Supports legacy callback usage. * - Returns an Observable for reactive programming. */ get(url, callback = (resp) => { }, opts = {}) { return this._post(url, null, callback, opts, 'get'); } // Clear all pending request locks clearLocked() { for (const awaitLocked of this.awaitLocked) { clearTimeout(awaitLocked); } this.awaitLocked = []; } // Lock the service to prevent multiple simultaneous requests lock() { this.locked = true; } // Unlock the service to allow new requests unlock() { this.locked = false; } /** * Handles HTTP errors. * - Calls provided error callback and retries the request if needed. */ handleError(callback, retry) { return (error) => { return new Promise(resolve => { this.err_handle(error, callback, retry); resolve(); }); }; } /** * Internal method to trigger error handling callbacks. */ err_handle(err, next, retry) { if (typeof next === 'function') { next(err); } for (const callback of this.errors) { if (typeof callback === 'function') { callback(err, retry); } } } // Placeholder method for handling request preparation (can be customized) prepare_handle(url, body) { } // Placeholder method for handling the response (can be customized) response_handle(url, body, next) { if (typeof next === 'function') { next(); } } /** * Retrieves a nested object or property from the response based on a dot-separated path. * * @param resp - The response object to retrieve data from. * @param base - A dot-separated string indicating the path to the desired property within the response. * - Example: `'data.items'` will navigate through `resp.data.items`. * - If empty, the entire response is returned. * @returns The object or property located at the specified path within the response. */ _getObjectToReplace(resp, base = '') { if (base.includes('.')) { const newBase = base.split(''); const currentBase = newBase.pop() || ''; return this._getObjectToReplace(resp[currentBase] || {}, newBase.join('.')); } else if (base) { return resp[base]; } else { return resp; } } /** * Sets or replaces a nested object or property in the response based on a dot-separated path. * * @param resp - The response object to modify. * @param base - A dot-separated string indicating the path to the property to replace. * - Example: `'data.items'` will navigate through `resp.data.items`. * @param doc - The new data or object to set at the specified path. * @returns `void`. */ _setObjectToReplace(resp, base = '', doc) { while (base.includes('.')) { const newBase = base.split(''); const currentBase = newBase.pop() || ''; resp = resp[currentBase] || {}; base = newBase.join('.'); } resp[base] = doc; } /** * Creates a new object containing only specified fields from the input item. * * @param item - The input object to extract fields from. * @param fields - An array of field names to include in the new object. * - Example: `['id', 'name']` will create a new object with only the `id` and `name` properties from `item`. * @returns A new object containing only the specified fields. */ _newDoc(item, fields) { const newDoc = {}; for (const field of fields) { newDoc[field] = item[field]; } return newDoc; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: HttpService, deps: [{ token: CONFIG_TOKEN, optional: true }, { token: i1.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: HttpService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: HttpService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [CONFIG_TOKEN] }, { type: Optional }] }, { type: i1.HttpClient }] }); const DEFAULT_NETWORK_CONFIG = { endpoints: [ 'https://api.webart.work/status', // Opaque but useful reachability fallbacks: 'https://www.google.com/generate_204', 'https://www.gstatic.com/generate_204', 'https://www.cloudflare.com/cdn-cgi/trace', ], intervalMs: 30_000, timeoutMs: 2_500, goodLatencyMs: 300, maxConsecutiveFails: 3, }; const NETWORK_CONFIG = new InjectionToken('NETWORK_CONFIG', { factory: () => DEFAULT_NETWORK_CONFIG, }); // network.service.ts — Angular 20+ (zoneless) signal-based connectivity checker class NetworkService { /** * Creates the network monitor, binds browser/Capacitor events, * performs an immediate check, and starts periodic polling. */ constructor(config) { this._isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); /** Internal mutable signals. */ this._status = signal(this._isBrowser && navigator.onLine ? 'poor' : 'none', ...(ngDevMode ? [{ debugName: "_status" }] : [])); this._latencyMs = signal(null, ...(ngDevMode ? [{ debugName: "_latencyMs" }] : [])); this._isOnline = signal(this._isBrowser ? navigator.onLine : false, ...(ngDevMode ? [{ debugName: "_isOnline" }] : [])); /** Public read-only signals. */ this.status = this._status.asReadonly(); this.latencyMs = this._latencyMs.asReadonly(); this.isOnline = this._isOnline.asReadonly(); /** Failure counter to decide "none". */ this._fails = 0; this._emitterService = inject(EmitterService); this._config = { ...DEFAULT_NETWORK_CONFIG, ...(config.network || {}), }; if (!this._isBrowser) return; this._bindEvents(); this.recheckNow(); // fire once on start window.setInterval(() => this.recheckNow(), this._config.intervalMs); } /** * Manually trigger a connectivity check. * - Measures latency against the first reachable e