- Activated a new linter check to avoid using N_() without _() later which results in untranslated texts in the UI - Related to bsc#1269514
824 lines
26 KiB
TypeScript
824 lines
26 KiB
TypeScript
/*
|
||
* Copyright (c) [2022-2026] SUSE LLC
|
||
*
|
||
* All Rights Reserved.
|
||
*
|
||
* This program is free software; you can redistribute it and/or modify it
|
||
* under the terms of the GNU General Public License as published by the Free
|
||
* Software Foundation; either version 2 of the License, or (at your option)
|
||
* any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||
* more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License along
|
||
* with this program; if not, contact SUSE LLC.
|
||
*
|
||
* To contact SUSE LLC about this file by physical or electronic mail, you may
|
||
* find current contact information at www.suse.com.
|
||
*/
|
||
|
||
import { isArray, isPlainObject, mapEntries } from "radashi";
|
||
import { generatePath } from "react-router";
|
||
import { ISortBy, sort } from "fast-sort";
|
||
import { _ } from "~/i18n";
|
||
|
||
import type { MarkedString, TranslatedString } from "~/i18n";
|
||
|
||
/**
|
||
* Generates a new array without null and undefined values.
|
||
*/
|
||
const compact = <T>(collection: Array<T>) => {
|
||
return collection.filter((e) => e !== null && e !== undefined);
|
||
};
|
||
|
||
/**
|
||
* Parses a "numeric dot string" as a hexadecimal number.
|
||
*
|
||
* Accepts only strings containing hexadecimal numbers (`0–9a-fA-F`) and dots (`.`),
|
||
* for example: `"0.0.0160"` `"0.0.019d"` or `"123"`. Dots are removed before parsing.
|
||
*
|
||
* If the cleaned string contains any non-digit characters (such as letters),
|
||
* or is not a valid integer string, the function returns `0`.
|
||
*
|
||
* @example
|
||
*
|
||
* ```ts
|
||
* hex("0.0.0.160"); // Returns 352
|
||
* hex("0.0.0.19d"); // Returns 352
|
||
* hex("1.2.3"); // Returns 291
|
||
* hex(".."); // Returns 0 (empty string before removing dots)
|
||
* ```
|
||
*
|
||
* @param value - A string representing a dot-separated numeric value
|
||
* @returns The number parsed as hexadecimal (base-16) integer, or `0` if invalid
|
||
*/
|
||
const hex = (value: string): number => {
|
||
const sanitizedValued = value.replaceAll(".", "");
|
||
return /^[0-9a-fA-F]+$/.test(sanitizedValued) ? parseInt(sanitizedValued, 16) : 0;
|
||
};
|
||
|
||
/**
|
||
* Wrapper around window.location.reload
|
||
*
|
||
* It's needed mainly to ease testing because we can't override window in jest with jsdom anymore
|
||
*
|
||
* See below links
|
||
* - https://github.com/jsdom/jsdom/blob/master/Changelog.md#2100
|
||
* - https://github.com/jsdom/jsdom/issues/3492
|
||
*/
|
||
const locationReload = () => {
|
||
window.location.reload();
|
||
};
|
||
|
||
/**
|
||
* Wrapper around window.location.search setter
|
||
*
|
||
* It's needed mainly to ease testing as we can't override window in jest with jsdom anymore
|
||
*
|
||
* See below links
|
||
* - https://github.com/jsdom/jsdom/blob/master/Changelog.md#2100
|
||
* - https://github.com/jsdom/jsdom/issues/3492
|
||
*
|
||
* @param query
|
||
*/
|
||
const setLocationSearch = (query: string) => {
|
||
window.location.search = query;
|
||
};
|
||
|
||
/**
|
||
* Whether Agama server is running locally or not.
|
||
*
|
||
* This function should be used only in special cases, the Agama behavior should
|
||
* be the same regardless of the user connection.
|
||
*
|
||
* The local connection can be forced by setting the `LOCAL_CONNECTION`
|
||
* environment variable to `1`. This can be useful for debugging or for
|
||
* development.
|
||
*
|
||
* @returns `true` if the connection is local, `false` otherwise
|
||
*/
|
||
const localConnection = (location: Location | URL = window.location) => {
|
||
// forced local behavior
|
||
if (process.env.LOCAL_CONNECTION === "1") return true;
|
||
|
||
const hostname = location.hostname;
|
||
|
||
// using the loopback device? (hostname or IP address)
|
||
return hostname === "localhost" || hostname.startsWith("127.");
|
||
};
|
||
|
||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||
|
||
/**
|
||
* Cached `Intl.DateTimeFormat` for a time zone and options.
|
||
*
|
||
* Constructing a formatter is the costly part; formatting with it is cheap. Reuse
|
||
* instances across calls so building a long time-zone list (or rebuilding it when
|
||
* a selector opens) does not reconstruct hundreds of formatters.
|
||
*
|
||
* The locale is fixed to "en-US" so the output stays locale-neutral (24h time,
|
||
* "UTC+1"), which is why the cache key is just the time zone and options. If the
|
||
* locale is ever made dynamic, it must be added to the cache key too, otherwise a
|
||
* language change would reuse a stale-locale formatter.
|
||
*
|
||
* @param timezone - E.g., "Atlantic/Canary".
|
||
* @param options - Formatting options (e.g. `{ timeStyle: "short" }`).
|
||
*/
|
||
const dateTimeFormat = (
|
||
timezone: string,
|
||
options: Intl.DateTimeFormatOptions,
|
||
): Intl.DateTimeFormat => {
|
||
const key = `${timezone}|${JSON.stringify(options)}`;
|
||
let formatter = dateTimeFormatters.get(key);
|
||
if (!formatter) {
|
||
formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, ...options });
|
||
dateTimeFormatters.set(key, formatter);
|
||
}
|
||
return formatter;
|
||
};
|
||
|
||
/** Clears the cached formatters. Intended for tests that mock `Intl.DateTimeFormat`. */
|
||
const clearDateTimeFormatCache = (): void => {
|
||
dateTimeFormatters.clear();
|
||
};
|
||
|
||
/**
|
||
* Time for the given timezone.
|
||
*
|
||
* @param timezone - E.g., "Atlantic/Canary".
|
||
* @param date - Date to take the time from.
|
||
*
|
||
* @returns Time in 24 hours format (e.g., "23:56"). Undefined for an unknown timezone.
|
||
*/
|
||
const timezoneTime = (timezone: string, date: Date = new Date()): string | undefined => {
|
||
try {
|
||
return dateTimeFormat(timezone, { timeStyle: "short", hour12: false }).format(date);
|
||
} catch (e) {
|
||
if (e instanceof RangeError) return undefined;
|
||
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Masks all but the last `visible` characters of a string.
|
||
*
|
||
* Replaces each character in the input string with `maskChar` ("*" by default),
|
||
* except for the last `visible` (4 by default) characters, which are left
|
||
* unchanged. If `visible` is greater than or equal to the string length, the
|
||
* input is returned as-is.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* mask("123456789"); // "*****6789"
|
||
* mask("secret", 2); // "****et"
|
||
* mask("secret", 6); // "secret"
|
||
* mask("secret", 3, "#"); // "###ret"
|
||
* ```
|
||
*
|
||
* @param value - The input string to mask
|
||
* @param visible - Number of trailing characters to leave unmasked (default: 4)
|
||
* @param maskChar - The character to use for masking (default: "*")
|
||
* @returns The masked string with only the last `visible` characters shown
|
||
*/
|
||
const mask = (value: string, visible: number = 4, maskChar: string = "*"): string => {
|
||
const length = value.length;
|
||
const safeVisible = Number.isFinite(visible) && visible > 0 ? visible : 0;
|
||
const maskedLength = Math.max(0, length - safeVisible);
|
||
const visiblePart = safeVisible === 0 ? "" : value.slice(-safeVisible);
|
||
return maskChar.repeat(maskedLength) + visiblePart;
|
||
};
|
||
|
||
// list of sensitive object properties replaced by the maskSecrets() function
|
||
const defaultSensitiveKeys = [
|
||
// storage
|
||
"encryptionPassword",
|
||
// users
|
||
"password",
|
||
// registration
|
||
"registrationCode",
|
||
// storage (iSCSI setting)
|
||
"reverse_password",
|
||
];
|
||
|
||
/**
|
||
* Recursively filters an object by replacing sensitive values with text
|
||
* "[FILTERED]". Useful for logging possibly sensitive data.
|
||
*
|
||
* It returns a new object, the original input is unchanged.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* const data = { user: "John", password: "123" };
|
||
* // logs '{ "user": "John", "password": "[FILTERED]" }'
|
||
* console.log(maskSecrets(data));
|
||
* ```
|
||
*
|
||
* @param obj - The object or array to sanitize
|
||
* @param options - Options object { sensitiveKeys, stringify }
|
||
* @returns Sanitized copy of the input
|
||
*/
|
||
const maskSecrets = (
|
||
obj: unknown,
|
||
{
|
||
sensitiveKeys = defaultSensitiveKeys,
|
||
stringify = true,
|
||
}: { sensitiveKeys?: string[]; stringify?: boolean } = {},
|
||
): unknown => {
|
||
const mask = (currentObj: unknown): unknown => {
|
||
if (isArray(currentObj)) {
|
||
return currentObj.map(mask);
|
||
}
|
||
|
||
return isPlainObject(currentObj)
|
||
? mapEntries(currentObj, (k, v) => [k, sensitiveKeys.includes(k) ? "[FILTERED]" : mask(v)])
|
||
: currentObj;
|
||
};
|
||
|
||
const result = mask(obj);
|
||
|
||
if (stringify) {
|
||
return JSON.stringify(result, null, 2);
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
/**
|
||
* A wrapper around React Router's `generatePath` that ensures all path parameters
|
||
* are URI-encoded using `encodeURIComponent`. This prevents broken URLs caused by
|
||
* special characters such as spaces, `#`, `$`, and others.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* // Returns "/network/Wired%20%231"
|
||
* generateEncodedPath("/network/:id", { id: "Wired #1" });
|
||
* ```
|
||
*/
|
||
const generateEncodedPath = (...args: Parameters<typeof generatePath>) => {
|
||
const [path, params] = args;
|
||
return generatePath(
|
||
path,
|
||
mapEntries(params, (key, value) => [key, encodeURIComponent(value)]),
|
||
);
|
||
};
|
||
|
||
/**
|
||
* A lightweight wrapper around `fast-sort`.
|
||
*
|
||
* Rather than using `fast-sort`'s method-chaining syntax, this function accepts
|
||
* the sort direction (`"asc"` or `"desc"`) as a direct argument, resulting in a
|
||
* cleaner and more declarative API for Agama components, where sorting is often
|
||
* built dynamically.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* sortCollection(devices, 'asc', size');
|
||
* sortCollection(devices, 'desc', d => d.sid + d.size);
|
||
* sortCollection(devices, 'asc', [d => d.size, d => d.name]);
|
||
* ```
|
||
* @param collection - The array of items to be sorted.
|
||
* @param direction - The direction of the sort. Use "asc" for ascending or
|
||
* "desc" for descending.
|
||
* @param key - The key (as a string) to sort by, or a custom function
|
||
* compatible with `fast-sort`'s ISortBy.
|
||
*
|
||
* @returns A new array sorted based on the given key and direction.
|
||
*
|
||
*/
|
||
const sortCollection = <T>(collection: T[], direction: "asc" | "desc", key: string | ISortBy<T>) =>
|
||
sort(collection)[direction](key as ISortBy<T>);
|
||
|
||
/** Options for mergeSources */
|
||
export type MergeSourcesOptions<T, K extends keyof T> = {
|
||
/** Object mapping source names to their arrays */
|
||
collections: Record<string, T[]>;
|
||
/** Order of precedence of where to take the object from if it is in more than
|
||
* one collection */
|
||
precedence: string[];
|
||
/** The property name(s) to use as the unique identifier */
|
||
primaryKey?: K | K[];
|
||
};
|
||
|
||
/** Item augmented with sources array */
|
||
type ItemWithSources<T> = Omit<T, "sources"> & {
|
||
/** Array of source names where this item was found */
|
||
sources: string[];
|
||
};
|
||
|
||
/**
|
||
* Merges multiple collections of objects, tracking which sources each item
|
||
* appears in.
|
||
*
|
||
* When the same item (identified by the specified primaryKey(s)) appears in multiple
|
||
* collections, it will appear only once in the output with a `sources` array
|
||
* listing all collections where it was found. The object data is taken from
|
||
* the first source in the precedence order where it appears.
|
||
*
|
||
* @template T - The type of objects in the collections
|
||
* @template K - The key type used for identifying unique items
|
||
*
|
||
* @param options - Configuration object
|
||
* @param options.collections - Object mapping source names to arrays of items
|
||
* @param options.precedence - Array of source names in priority order (first = highest)
|
||
* @param options.primaryKey - Property name(s) to use as unique identifier (default: "id").
|
||
* Can be a single key or array of keys for composite primary keys.
|
||
*
|
||
* @returns Array of merged items, each with a `sources` property
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* // Single primary key example - iSCSI targets identified by name only
|
||
* const result = mergeSources({
|
||
* collections: {
|
||
* system: [
|
||
* {
|
||
* name: "iqn.2023-01.com.example:12ac588",
|
||
* address: "192.168.100.102",
|
||
* port: 3262,
|
||
* interface: "default",
|
||
* ibtf: false,
|
||
* startup: "onboot",
|
||
* connected: true,
|
||
* locked: false
|
||
* }
|
||
* ],
|
||
* config: [
|
||
* {
|
||
* name: "iqn.2023-01.com.example:12ac588",
|
||
* address: "192.168.100.102",
|
||
* port: 3262,
|
||
* interface: "default",
|
||
* startup: "onboot"
|
||
* },
|
||
* {
|
||
* name: "iqn.2023-01.com.example:12ac788",
|
||
* address: "192.168.100.106",
|
||
* port: 3264,
|
||
* interface: "default",
|
||
* startup: "onboot"
|
||
* }
|
||
* ]
|
||
* },
|
||
* precedence: ['system', 'config'],
|
||
* primaryKey: 'name'
|
||
* });
|
||
* // Returns:
|
||
* // [
|
||
* // {
|
||
* // name: "iqn.2023-01.com.example:12ac588",
|
||
* // address: "192.168.100.102",
|
||
* // port: 3262,
|
||
* // interface: "default",
|
||
* // ibtf: false,
|
||
* // startup: "onboot",
|
||
* // connected: true,
|
||
* // locked: false,
|
||
* // sources: ['system', 'config']
|
||
* // },
|
||
* // {
|
||
* // name: "iqn.2023-01.com.example:12ac788",
|
||
* // address: "192.168.100.106",
|
||
* // port: 3264,
|
||
* // interface: "default",
|
||
* // startup: "onboot",
|
||
* // sources: ['config']
|
||
* // }
|
||
* // ]
|
||
*
|
||
* // Multiple primary key example - iSCSI targets identified by name + address + port
|
||
* // This is necessary because the same target name can have multiple portals
|
||
* const result2 = mergeSources({
|
||
* collections: {
|
||
* system: [
|
||
* {
|
||
* name: "iqn.2023-01.com.example:storage",
|
||
* address: "192.168.100.102",
|
||
* port: 3260,
|
||
* interface: "default",
|
||
* ibtf: false,
|
||
* startup: "onboot",
|
||
* connected: true,
|
||
* locked: false
|
||
* },
|
||
* {
|
||
* name: "iqn.2023-01.com.example:storage",
|
||
* address: "192.168.100.102",
|
||
* port: 3261,
|
||
* interface: "default",
|
||
* ibtf: false,
|
||
* startup: "manual",
|
||
* connected: false,
|
||
* locked: false
|
||
* }
|
||
* ],
|
||
* config: [
|
||
* {
|
||
* name: "iqn.2023-01.com.example:storage",
|
||
* address: "192.168.100.102",
|
||
* port: 3260,
|
||
* interface: "default",
|
||
* startup: "onboot"
|
||
* },
|
||
* {
|
||
* name: "iqn.2023-01.com.example:storage",
|
||
* address: "192.168.100.102",
|
||
* port: 3261,
|
||
* interface: "default",
|
||
* startup: "manual"
|
||
* },
|
||
* {
|
||
* name: "iqn.2023-01.com.example:storage",
|
||
* address: "192.168.100.103",
|
||
* port: 3260,
|
||
* interface: "default",
|
||
* startup: "onboot"
|
||
* }
|
||
* ]
|
||
* },
|
||
* precedence: ['system', 'config'],
|
||
* primaryKey: ['name', 'address', 'port']
|
||
* });
|
||
* // Returns:
|
||
* // [
|
||
* // {
|
||
* // name: "iqn.2023-01.com.example:storage",
|
||
* // address: "192.168.100.102",
|
||
* // port: 3260,
|
||
* // interface: "default",
|
||
* // ibtf: false,
|
||
* // startup: "onboot",
|
||
* // connected: true,
|
||
* // locked: false,
|
||
* // sources: ['system', 'config']
|
||
* // },
|
||
* // {
|
||
* // name: "iqn.2023-01.com.example:storage",
|
||
* // address: "192.168.100.102",
|
||
* // port: 3261,
|
||
* // interface: "default",
|
||
* // ibtf: false,
|
||
* // startup: "manual",
|
||
* // connected: false,
|
||
* // locked: false,
|
||
* // sources: ['system', 'config']
|
||
* // },
|
||
* // {
|
||
* // name: "iqn.2023-01.com.example:storage",
|
||
* // address: "192.168.100.103",
|
||
* // port: 3260,
|
||
* // interface: "default",
|
||
* // startup: "onboot",
|
||
* // sources: ['config']
|
||
* // }
|
||
* // ]
|
||
* ```
|
||
*/
|
||
function mergeSources<T, K extends keyof T>({
|
||
collections,
|
||
precedence,
|
||
primaryKey = "id" as K,
|
||
}: MergeSourcesOptions<T, K>): ItemWithSources<T>[] {
|
||
const map = new Map<string, ItemWithSources<T>>();
|
||
|
||
for (const name of precedence) {
|
||
const items = collections[name];
|
||
for (const obj of items) {
|
||
const key = Array.isArray(primaryKey)
|
||
? primaryKey.map((k) => String(obj[k])).join("|")
|
||
: String(obj[primaryKey]);
|
||
|
||
if (map.has(key)) {
|
||
map.get(key)!.sources.push(name);
|
||
} else {
|
||
map.set(key, { ...obj, sources: [name] });
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
/**
|
||
* Options for extending a base collection with matching merge items.
|
||
*/
|
||
interface ExtendCollectionOptions<T, U> {
|
||
/**
|
||
* Collection providing merge data to enrich base items.
|
||
*
|
||
* Items in this collection will be matched against the base collection
|
||
* according to the `matching` fields.
|
||
*/
|
||
with?: U[];
|
||
|
||
/**
|
||
* Field name or array of field names used to match items between the base
|
||
* collection and the merge collection.
|
||
* - Single field: matches on that field.
|
||
* - Multiple fields: all fields are combined to determine a match.
|
||
*/
|
||
matching?: keyof T | (keyof T)[];
|
||
|
||
/**
|
||
* Determines which collection's properties take priority when merging:
|
||
* - `"baseWins"`: base item properties overwrite merge properties.
|
||
* - `"extensionWins"`: merge properties overwrite base item properties.
|
||
* @default "baseWins"
|
||
*/
|
||
precedence?: "baseWins" | "extensionWins";
|
||
|
||
/**
|
||
* Controls how array properties are merged:
|
||
* - `true`: Merge all array properties by concatenating values from both sources
|
||
* - `string[]`: Merge only the specified array property names
|
||
* - `false` or `undefined`: Use precedence rules (default behavior)
|
||
*
|
||
* When merging arrays, values from the extension are added to the base array,
|
||
* with duplicates removed using strict equality.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* // Merge all arrays
|
||
* extendCollection(
|
||
* [{ id: "a", addresses: [], nameservers: [] }],
|
||
* {
|
||
* with: [{ id: "a", addresses: ["192.168.1.1"], nameservers: ["8.8.8.8"] }],
|
||
* mergeArrays: true
|
||
* }
|
||
* )
|
||
* // => addresses: ["192.168.1.1"], nameservers: ["8.8.8.8"]
|
||
*
|
||
* // Merge specific arrays only
|
||
* extendCollection(
|
||
* [{ id: "a", addresses: [], tags: ["tag1"] }],
|
||
* {
|
||
* with: [{ id: "a", addresses: ["192.168.1.1"], tags: ["tag2"] }],
|
||
* mergeArrays: ["addresses"]
|
||
* }
|
||
* )
|
||
* // => addresses: ["192.168.1.1"], tags: ["tag1"] (precedence wins for tags)
|
||
* ```
|
||
*/
|
||
mergeArrays?: boolean | string[];
|
||
}
|
||
|
||
/**
|
||
* Result of extending a collection.
|
||
*/
|
||
interface ExtendCollectionResult<T, U> {
|
||
/**
|
||
* Items from the base collection, extended with matching extension data.
|
||
*
|
||
* Items without matches are returned unchanged.
|
||
*/
|
||
extended: (T & U)[];
|
||
/**
|
||
* Items from the extension collection that did not match any base item.
|
||
*/
|
||
unmatched: U[];
|
||
/**
|
||
* All items: base collection (extended where matched) + unmatched extension
|
||
* items appended at the end, preserving base collection order.
|
||
*/
|
||
all: (T & U)[];
|
||
}
|
||
|
||
/**
|
||
* Extends a base collection of items by merging in matching items from an
|
||
* extension collection.
|
||
*
|
||
* For each item in the base collection:
|
||
* 1. Find a matching item in the extension collection based on the specified
|
||
* field(s).
|
||
* 2. Merge the matched item's properties into the base item according to
|
||
* `precedence`.
|
||
*
|
||
* @returns Object containing:
|
||
* - `extended`: base collection items, merged where a match was found.
|
||
* - `unmatched`: extension items that had no matching base item.
|
||
* - `all`: `extended` items followed by `unmatched` items, preserving base
|
||
* collection order with unmatched extension items appended at the end.
|
||
*
|
||
* @example
|
||
* // Single field matching with default precedence (base wins)
|
||
* const configDevices = [
|
||
* { channel: "0.0.0160", diag: false, format: true }
|
||
* ];
|
||
* const systemDevices = [
|
||
* { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true },
|
||
* { channel: "0.0.0999", deviceName: "extra", type: "fba" }
|
||
* ];
|
||
* const { extended, unmatched, all } = extendCollection(configDevices, {
|
||
* with: systemDevices,
|
||
* matching: 'channel'
|
||
* });
|
||
* // extended: [
|
||
* // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true }
|
||
* // ]
|
||
* // unmatched: [
|
||
* // { channel: "0.0.0999", deviceName: "extra", type: "fba" }
|
||
* // ]
|
||
* // all: [
|
||
* // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true },
|
||
* // { channel: "0.0.0999", deviceName: "extra", type: "fba" }
|
||
* // ]
|
||
*
|
||
* @example
|
||
* // Multiple field matching with extensionWins precedence
|
||
* const configDevices = [
|
||
* { channel: "0.0.0160", state: "offline", diag: false }
|
||
* ];
|
||
* const systemDevices = [
|
||
* { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true },
|
||
* { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" }
|
||
* ];
|
||
* const { extended, unmatched, all } = extendCollection(configDevices, {
|
||
* with: systemDevices,
|
||
* matching: ['channel', 'state'],
|
||
* precedence: "extensionWins"
|
||
* });
|
||
* // extended: [
|
||
* // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }
|
||
* // ]
|
||
* // unmatched: [
|
||
* // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" }
|
||
* // ]
|
||
* // all: [
|
||
* // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true },
|
||
* // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" }
|
||
* // ]
|
||
*/
|
||
function extendCollection<T extends object, U extends object>(
|
||
collection: T[] = [],
|
||
options: ExtendCollectionOptions<T, U>,
|
||
): ExtendCollectionResult<T, U> {
|
||
const { with: extension = [], matching = "id", precedence = "baseWins", mergeArrays } = options;
|
||
|
||
// Normalize matching field(s) to array
|
||
const keys = Array.isArray(matching) ? matching : [matching];
|
||
// Create a composite key from item values
|
||
// For single field: returns the value directly (e.g., "0.0.0160")
|
||
// For multiple fields: joins values with pipe separator (e.g., "0.0.0160|offline")
|
||
const getKey = (item: T | U): string =>
|
||
keys.map((k) => String(item[k as keyof (T | U)])).join("|");
|
||
|
||
// Build a lookup map from extension items keyed by their matching fields
|
||
const extensionLookup = new Map(extension.map((item) => [getKey(item), item]));
|
||
|
||
const extended: (T & U)[] = [];
|
||
const unmatched: U[] = [];
|
||
const all: (T & U)[] = [];
|
||
|
||
// Helper to merge two items with array merging support
|
||
const mergeItems = (base: T, ext: U): T & U => {
|
||
const merged = precedence === "baseWins" ? { ...ext, ...base } : { ...base, ...ext };
|
||
|
||
if (!mergeArrays) return merged as T & U;
|
||
|
||
// Determine which properties should have their arrays merged
|
||
const shouldMergeArray = (key: string): boolean => {
|
||
if (mergeArrays === true) return true;
|
||
if (Array.isArray(mergeArrays)) return mergeArrays.includes(key);
|
||
return false;
|
||
};
|
||
|
||
// Merge array properties
|
||
for (const key of Object.keys(merged)) {
|
||
if (!shouldMergeArray(key)) continue;
|
||
|
||
const baseVal = base[key as keyof T];
|
||
const extVal = ext[key as keyof U];
|
||
|
||
if (isArray(baseVal) && isArray(extVal)) {
|
||
// Concatenate arrays and remove duplicates
|
||
const combined = [...baseVal, ...extVal];
|
||
merged[key as keyof typeof merged] = Array.from(new Set(combined)) as unknown as (T &
|
||
U)[keyof (T & U)];
|
||
}
|
||
}
|
||
|
||
return merged as T & U;
|
||
};
|
||
|
||
// Extend each item in the base collection
|
||
for (const item of collection) {
|
||
const key = getKey(item);
|
||
const match = extensionLookup.get(key);
|
||
|
||
const resolved = !match
|
||
? (item as T & U)
|
||
: (extensionLookup.delete(key), mergeItems(item, match));
|
||
|
||
extended.push(resolved);
|
||
all.push(resolved);
|
||
}
|
||
|
||
// Remaining items in lookup are unmatched — append to all to preserve
|
||
// base collection order with unmatched extension items at the end
|
||
for (const item of extensionLookup.values()) {
|
||
unmatched.push(item);
|
||
all.push(item as unknown as T & U);
|
||
}
|
||
|
||
return { all, extended, unmatched };
|
||
}
|
||
|
||
/** Options for translateEntries utility */
|
||
type TranslateEntriesOptions = {
|
||
/** Optional predicate to exclude entries by key. */
|
||
filter?: (key: string) => boolean;
|
||
};
|
||
|
||
/**
|
||
* Translates the values of a string record using `_()`, optionally filtering
|
||
* entries by key.
|
||
*
|
||
* @example
|
||
* // Translate all entries
|
||
* const FORMAT_OPTIONS = { yes: N_("Yes"), no: N_("No") };
|
||
* translateEntries(FORMAT_OPTIONS)
|
||
*
|
||
* @example
|
||
* // Translate only entries matching a condition
|
||
* const STATUS_OPTIONS = { active: N_("Active"), offline: N_("Offline") };
|
||
* translateEntries(STATUS_OPTIONS, { filter: (status) => devices.some((d) => d.status === status) })
|
||
*
|
||
* @param entries - A record whose values are translation keys.
|
||
* @param options - Optional configuration.
|
||
* @param options.filter - Optional predicate to exclude entries by key.
|
||
* @returns A new record with the same keys and translated values.
|
||
*
|
||
*/
|
||
const translateEntries = (
|
||
entries: Record<string, MarkedString>,
|
||
{ filter }: TranslateEntriesOptions = {},
|
||
): Record<string, TranslatedString> =>
|
||
Object.fromEntries(
|
||
Object.entries(entries)
|
||
.filter(([key]) => filter?.(key) ?? true)
|
||
.map(([key, value]) => [key, _(value)]),
|
||
);
|
||
|
||
/**
|
||
* Returns the current date and time as an ISO 8601 string with colons and
|
||
* dots replaced by hyphens, e.g. `2024-01-15T10-30-00-000Z`.
|
||
*
|
||
* @returns A string in the form `YYYY-MM-DDTHH-MM-SS-mmmZ`.
|
||
*
|
||
* @example
|
||
* const filename = `agama-logs-${isoTimestamp()}.tar.gz`;
|
||
*/
|
||
const isoTimestamp = (): string => new Date().toISOString().replace(/[:.]/g, "-");
|
||
|
||
/**
|
||
* Fetches a resource and triggers a browser file download.
|
||
*
|
||
* The download is performed via `fetch` so the caller can `await` completion,
|
||
* enabling UI feedback (e.g. a loading indicator) that can be dismissed once
|
||
* the browser Save dialog appears.
|
||
*
|
||
* @param url - URL of the resource to download.
|
||
* @param filename - Suggested filename for the downloaded file.
|
||
*
|
||
* @throws {Error} If the server responds with a non-OK HTTP status.
|
||
*
|
||
* @example
|
||
* await download("/api/private/download_logs", "agama-logs.tar.gz");
|
||
*/
|
||
const download = async (url: string, filename: string): Promise<void> => {
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
||
|
||
const blob = await response.blob();
|
||
const anchor = document.createElement("a");
|
||
anchor.href = URL.createObjectURL(blob);
|
||
anchor.download = filename;
|
||
try {
|
||
anchor.click();
|
||
} finally {
|
||
URL.revokeObjectURL(anchor.href);
|
||
}
|
||
};
|
||
|
||
export {
|
||
compact,
|
||
hex,
|
||
locationReload,
|
||
setLocationSearch,
|
||
localConnection,
|
||
dateTimeFormat,
|
||
clearDateTimeFormatCache,
|
||
timezoneTime,
|
||
mask,
|
||
generateEncodedPath,
|
||
maskSecrets,
|
||
sortCollection,
|
||
mergeSources,
|
||
extendCollection,
|
||
translateEntries,
|
||
isoTimestamp,
|
||
download,
|
||
};
|