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
JavaScript
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