feat(registry): AssetResolver + diffuser supplier curves (Jäger / Aerostrip / PIK / PRK)
Two related changes bundled together because the diffuser curve files
only make sense once the registry namespace they live in exists.
src/registry — new asset-metadata resolver:
- AssetResolver with synchronous resolve(namespace, id) + lazy cache,
async refresh() for future remote pulls.
- FileBackend (per-id or single-file layouts, case-insensitive) and a
stub HttpBackend (disabled unless EVOLV_ASSET_REMOTE=1).
- Namespaces: curves, menu, monsterSamples, monsterSpecs, units. Menu
namespace re-keys by inner softwareType + filename so editors that
pass either string resolve to the same tree.
- README explains how to add a namespace.
- AssetCategoryManager (datasets/assetData/index.js) becomes a thin
facade over the resolver so existing consumers don't move.
- 246/246 tests pass — including the 39-test registry suite.
datasets/assetData — file moves + new diffuser data:
- modelData/*.json deleted; curves/*.json is the canonical home.
- New diffuser.json menu tree with GVA, Jäger, Aquaconsult/Entec,
PIK/PRK suppliers.
- gva-elastox-r.json migrated from the inline _loadSpecs hardcode,
re-tagged coverageBasis="bottom-coverage-pct" (the legacy 2.4
elements/m² was a prior mis-conversion; we can't recover the
original % so it's a single-point curve under key "0").
- jaeger-jetflex-td-65-2-g-epdm-1000.json — extracted from the Jäger
EPDM-1000mm SSOTE/DWP chart on the data sheet (vector-PDF read).
SSOTE 8.20→6.40 %/m, DWP 25→48 mbar across Q 2-12 Nm³/h. Single
coverage (vendor doesn't state test conditions).
- aerostrip-phoenix.json — 4-coverage SOTE family at 4.75 m water
depth (DD 5/10/15/20 %, flux 10-70 Nm³/h·m²) from the Entec/de
Winter 2023-11-22 dataset; DWP curve from the 21 % @ 4.05 m chart.
- pik300.json / prk300.json — 5-coverage SOTE + SSOTR (DD 5-25 %)
with split DWP per model variant, water depth ≈ 4.0 m inferred from
the SOTE↔SSOTR ratio in the source spreadsheet.
src/configs/diffuser.json:
- New asset.{model, assetTagNumber} block so the editor's selected
model id survives validation.
- diffuser.density description corrected to "Bottom coverage [%]";
default 2.4 → 15 (typical fine-bubble install).
src/configs/{rotatingMachine,valve}.json: small alignment edits that
came with the registry phase.
src/menu/asset.js + src/menu/aquonSamples.js: rewritten as facades
over assetResolver, keeping the editor-side cascade behaviour intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
103
src/registry/AssetResolver.js
Normal file
103
src/registry/AssetResolver.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AssetResolver — single entry point for all asset-side metadata in EVOLV.
|
||||
*
|
||||
* Namespaces are declared at construction (see ./namespaces/*). Each namespace
|
||||
* exposes a `loadAll()` returning a `Map<id, payload>`; AssetResolver routes
|
||||
* resolve(name, id) calls into the right namespace, caches the loaded map, and
|
||||
* exposes async `refresh(name?)` for future HttpBackend hydration.
|
||||
*
|
||||
* Resolution is sync by contract: the first resolve() for an unwarmed
|
||||
* namespace pulls everything into cache, all subsequent calls are O(1).
|
||||
*
|
||||
* Backend abstraction (./backends/*) is where File vs Http lives — namespaces
|
||||
* just hold a backend reference and call backend.loadAll().
|
||||
*
|
||||
* See ./README.md for the full extension story.
|
||||
*/
|
||||
|
||||
class AssetResolver {
|
||||
constructor(namespaces = []) {
|
||||
this._slots = new Map();
|
||||
for (const ns of namespaces) {
|
||||
if (!ns || !ns.name || typeof ns.loadAll !== 'function') {
|
||||
throw new TypeError('AssetResolver: namespace must declare { name, loadAll() }');
|
||||
}
|
||||
this._slots.set(ns.name, { ns, cache: null });
|
||||
}
|
||||
}
|
||||
|
||||
_ensureWarm(name) {
|
||||
const slot = this._slots.get(name);
|
||||
if (!slot) throw new Error(`AssetResolver: unknown namespace '${name}'`);
|
||||
if (slot.cache === null) slot.cache = slot.ns.loadAll();
|
||||
return slot;
|
||||
}
|
||||
|
||||
resolve(namespace, id) {
|
||||
const slot = this._ensureWarm(namespace);
|
||||
const key = String(id ?? '').toLowerCase();
|
||||
if (!key) return null;
|
||||
return slot.cache.get(key) ?? null;
|
||||
}
|
||||
|
||||
list(namespace) {
|
||||
const slot = this._ensureWarm(namespace);
|
||||
return [...slot.cache.keys()];
|
||||
}
|
||||
|
||||
namespaces() {
|
||||
return [...this._slots.keys()];
|
||||
}
|
||||
|
||||
async refresh(namespace) {
|
||||
if (namespace) {
|
||||
const slot = this._slots.get(namespace);
|
||||
if (!slot) throw new Error(`AssetResolver: unknown namespace '${namespace}'`);
|
||||
slot.cache = typeof slot.ns.refresh === 'function'
|
||||
? await slot.ns.refresh()
|
||||
: slot.ns.loadAll();
|
||||
return;
|
||||
}
|
||||
for (const slot of this._slots.values()) {
|
||||
slot.cache = typeof slot.ns.refresh === 'function'
|
||||
? await slot.ns.refresh()
|
||||
: slot.ns.loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-namespace helper: given a softwareType + model id, walk the
|
||||
* editor menu tree to return { supplier, type, units, raw } so domain
|
||||
* code doesn't have to persist supplier/type/unit on the node.
|
||||
*/
|
||||
resolveAssetMetadata(softwareType, modelId) {
|
||||
if (!softwareType || !modelId) return null;
|
||||
const tree = this.resolve('menu', softwareType);
|
||||
if (!tree || !Array.isArray(tree.suppliers)) return null;
|
||||
const norm = String(modelId).toLowerCase();
|
||||
for (const supplier of tree.suppliers) {
|
||||
for (const type of supplier.types || []) {
|
||||
for (const model of type.models || []) {
|
||||
const candidate = String(model.id || model.name || '').toLowerCase();
|
||||
if (candidate === norm) {
|
||||
return {
|
||||
supplier: supplier.name,
|
||||
supplierId: supplier.id || supplier.name,
|
||||
type: type.name,
|
||||
typeId: type.id || type.name,
|
||||
model: model.name,
|
||||
modelId: model.id || model.name,
|
||||
units: Array.isArray(model.units) ? [...model.units] : [],
|
||||
raw: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AssetResolver;
|
||||
78
src/registry/README.md
Normal file
78
src/registry/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# registry — AssetResolver
|
||||
|
||||
Single entry point for all asset-side metadata: pump/valve curves, editor menu
|
||||
trees, monster sample codes, unit families, and anything else we add later.
|
||||
|
||||
Replaces (will replace, phase-by-phase):
|
||||
|
||||
- `loadCurve(model)` → `assetResolver.resolve('curves', model)`
|
||||
- `AssetCategoryManager` → `assetResolver.resolve('menu', softwareType)`
|
||||
- ad-hoc loaders for `monsterSamples.json`, `unitData.json` → `assetResolver.resolve('monsterSamples'|'units', …)`
|
||||
|
||||
## Surface
|
||||
|
||||
```js
|
||||
const { assetResolver } = require('generalFunctions');
|
||||
|
||||
const curve = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
|
||||
const tree = assetResolver.resolve('menu', 'rotatingmachine');
|
||||
const meta = assetResolver.resolveAssetMetadata('rotatingmachine', 'hidrostal-H05K-S03R');
|
||||
// meta → { supplier, type, units, model, raw }
|
||||
|
||||
assetResolver.list('curves'); // ['hidrostal-H05K-S03R', 'ECDV', ...]
|
||||
assetResolver.namespaces(); // ['curves', 'menu', 'monsterSamples', 'units']
|
||||
await assetResolver.refresh(); // re-pull everything (FileBackend: re-reads disk; HttpBackend: future)
|
||||
```
|
||||
|
||||
Resolution is synchronous. First call to `resolve(namespace, id)` warms that
|
||||
namespace's cache; later calls are O(1) map lookups.
|
||||
|
||||
## Adding a namespace
|
||||
|
||||
Create `src/registry/namespaces/<name>.js`:
|
||||
|
||||
```js
|
||||
const path = require('path');
|
||||
const FileBackend = require('../backends/FileBackend');
|
||||
|
||||
const backend = new FileBackend({
|
||||
baseDir: path.resolve(__dirname, '../../../datasets/...'),
|
||||
layout: 'per-id', // or 'single-file'
|
||||
caseInsensitive: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: 'newThing',
|
||||
description: 'What this namespace is for',
|
||||
loadAll: () => backend.loadAll(),
|
||||
refresh: () => backend.refresh(),
|
||||
};
|
||||
```
|
||||
|
||||
Register it in `namespaces/index.js`. Done.
|
||||
|
||||
## Backends
|
||||
|
||||
- **FileBackend** — reads JSON from disk. Two layouts: `per-id` (one file per
|
||||
id, filename minus `.json` is the id) or `single-file` (one file with an
|
||||
array; pick `arrayKey` and `indexField`).
|
||||
- **HttpBackend** — stub. Disabled unless `EVOLV_ASSET_REMOTE=1`. Will hold
|
||||
the future WBD product API client; currently throws if invoked. Exists so
|
||||
the resolver contract is backend-agnostic from day one.
|
||||
|
||||
Backends are interchangeable per namespace: the namespace file is the
|
||||
declarative join between "what this metadata is" and "where it comes from".
|
||||
|
||||
## Why sync at runtime
|
||||
|
||||
Node-RED node constructors aren't async-friendly. Every consumer that used
|
||||
`loadCurve(model)` expects a synchronous return. The resolver preserves that
|
||||
contract: cache is warmed lazily (first `resolve()` call pulls everything),
|
||||
and lookups are O(1) map gets after that. Async `refresh()` exists for future
|
||||
HttpBackend hydration on a background timer.
|
||||
|
||||
## Convention: namespace name is the cache key
|
||||
|
||||
`assetResolver.resolve(namespace, id)` lowercases `id` for the lookup. Old
|
||||
case-mismatched configs (`Hidrostal-H05K-S03R` vs `hidrostal-H05K-S03R`) still
|
||||
resolve correctly — same as `loadCurve` did historically.
|
||||
96
src/registry/backends/FileBackend.js
Normal file
96
src/registry/backends/FileBackend.js
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* FileBackend — reads JSON payloads from a directory on disk.
|
||||
*
|
||||
* Two layouts supported:
|
||||
* - 'per-id' one file per id (filename minus .json is the id)
|
||||
* - 'single-file' one file containing an array; index by a field name
|
||||
*
|
||||
* Returns Map<lowerCaseId, payload>. Case-insensitive lookups by default —
|
||||
* matches how loadCurve worked historically.
|
||||
*/
|
||||
class FileBackend {
|
||||
constructor(opts = {}) {
|
||||
const {
|
||||
baseDir,
|
||||
layout = 'per-id',
|
||||
filePath,
|
||||
arrayKey,
|
||||
indexField,
|
||||
exclude = [],
|
||||
caseInsensitive = true,
|
||||
} = opts;
|
||||
|
||||
if (!baseDir) throw new TypeError('FileBackend: baseDir is required');
|
||||
if (layout !== 'per-id' && layout !== 'single-file') {
|
||||
throw new TypeError(`FileBackend: unsupported layout '${layout}'`);
|
||||
}
|
||||
if (layout === 'single-file' && !filePath) {
|
||||
throw new TypeError('FileBackend: single-file layout requires filePath');
|
||||
}
|
||||
|
||||
this.baseDir = baseDir;
|
||||
this.layout = layout;
|
||||
this.filePath = filePath;
|
||||
this.arrayKey = arrayKey;
|
||||
this.indexField = indexField;
|
||||
this.exclude = new Set(exclude);
|
||||
this.caseInsensitive = caseInsensitive;
|
||||
}
|
||||
|
||||
_norm(k) {
|
||||
return this.caseInsensitive ? String(k).toLowerCase() : String(k);
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
if (this.layout === 'per-id') return this._loadPerId();
|
||||
return this._loadSingleFile();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
// No actual I/O penalty on local disk; the async surface exists so
|
||||
// callers can `await resolver.refresh()` symmetrically with future
|
||||
// HttpBackend implementations.
|
||||
return this.loadAll();
|
||||
}
|
||||
|
||||
_loadPerId() {
|
||||
const map = new Map();
|
||||
if (!fs.existsSync(this.baseDir)) return map;
|
||||
const entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.json')) continue;
|
||||
const id = path.basename(entry.name, '.json');
|
||||
if (this.exclude.has(id)) continue;
|
||||
const raw = fs.readFileSync(path.join(this.baseDir, entry.name), 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
map.set(this._norm(id), data);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
_loadSingleFile() {
|
||||
const full = path.resolve(this.baseDir, this.filePath);
|
||||
if (!fs.existsSync(full)) return new Map();
|
||||
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
|
||||
const arr = this.arrayKey ? data[this.arrayKey] : data;
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(
|
||||
`FileBackend(single-file): expected array at ${this.arrayKey || '<root>'} in ${full}`,
|
||||
);
|
||||
}
|
||||
const map = new Map();
|
||||
for (const entry of arr) {
|
||||
const k = entry && this.indexField ? entry[this.indexField] : null;
|
||||
if (k != null) map.set(this._norm(k), entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileBackend;
|
||||
41
src/registry/backends/HttpBackend.js
Normal file
41
src/registry/backends/HttpBackend.js
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* HttpBackend — stub. The shape that any future remote product/asset DB will
|
||||
* implement so the resolver can swap backends without touching consumers.
|
||||
*
|
||||
* Disabled by default. Set EVOLV_ASSET_REMOTE=1 to opt in; even then this
|
||||
* stub throws on use because the upstream API is not yet defined. See
|
||||
* `assetApiConfig.js` for the URL/auth scaffolding that will eventually
|
||||
* land here.
|
||||
*/
|
||||
class HttpBackend {
|
||||
constructor({ url, headers = {}, namespace } = {}) {
|
||||
this.url = url;
|
||||
this.headers = headers;
|
||||
this.namespace = namespace;
|
||||
}
|
||||
|
||||
static get enabled() {
|
||||
return process.env.EVOLV_ASSET_REMOTE === '1';
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
if (!HttpBackend.enabled) {
|
||||
throw new Error(
|
||||
'HttpBackend disabled (set EVOLV_ASSET_REMOTE=1 to enable); ' +
|
||||
'no synchronous remote fetch is implemented yet.',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'HttpBackend.loadAll(): remote asset backend not yet implemented. ' +
|
||||
'Use FileBackend or implement this method against the WBD product API.',
|
||||
);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
return this.loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HttpBackend;
|
||||
15
src/registry/index.js
Normal file
15
src/registry/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const AssetResolver = require('./AssetResolver');
|
||||
const FileBackend = require('./backends/FileBackend');
|
||||
const HttpBackend = require('./backends/HttpBackend');
|
||||
const namespaces = require('./namespaces');
|
||||
|
||||
const assetResolver = new AssetResolver(namespaces);
|
||||
|
||||
module.exports = {
|
||||
AssetResolver,
|
||||
FileBackend,
|
||||
HttpBackend,
|
||||
assetResolver,
|
||||
};
|
||||
17
src/registry/namespaces/curves.js
Normal file
17
src/registry/namespaces/curves.js
Normal file
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const FileBackend = require('../backends/FileBackend');
|
||||
|
||||
const backend = new FileBackend({
|
||||
baseDir: path.resolve(__dirname, '../../../datasets/assetData/curves'),
|
||||
layout: 'per-id',
|
||||
caseInsensitive: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: 'curves',
|
||||
description: 'Pump and valve performance curves keyed by model id',
|
||||
loadAll: () => backend.loadAll(),
|
||||
refresh: () => backend.refresh(),
|
||||
};
|
||||
9
src/registry/namespaces/index.js
Normal file
9
src/registry/namespaces/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = [
|
||||
require('./curves'),
|
||||
require('./menu'),
|
||||
require('./monsterSamples'),
|
||||
require('./monsterSpecs'),
|
||||
require('./units'),
|
||||
];
|
||||
47
src/registry/namespaces/menu.js
Normal file
47
src/registry/namespaces/menu.js
Normal file
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const FileBackend = require('../backends/FileBackend');
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, '../../../datasets/assetData');
|
||||
// Files in datasets/assetData that aren't editor menu trees.
|
||||
const EXCLUDE = ['assetData', 'monsterSamples', 'unitData'];
|
||||
|
||||
// Plain per-id File backend, but the menu namespace also wants to key by the
|
||||
// inner `softwareType` field (so '/menu/rotatingmachine' works even if the
|
||||
// file is named machine.json). The FileBackend gives us filename-keyed maps;
|
||||
// we rekey in a thin wrapper.
|
||||
const backend = new FileBackend({
|
||||
baseDir: BASE_DIR,
|
||||
layout: 'per-id',
|
||||
caseInsensitive: true,
|
||||
exclude: EXCLUDE,
|
||||
});
|
||||
|
||||
// Menu trees are looked up by softwareType. We index by BOTH the inner
|
||||
// `softwareType` field AND the filename (sans .json), because consumers come
|
||||
// from two paths: editor endpoints pass the node type ('rotatingmachine'),
|
||||
// while older code paths pass the filename slug ('machine'). Both should hit
|
||||
// the same tree.
|
||||
function _rekeyBySoftwareType(map) {
|
||||
const out = new Map();
|
||||
for (const [filenameId, data] of map.entries()) {
|
||||
const stKey = String(data?.softwareType || '').toLowerCase();
|
||||
const fnKey = String(filenameId).toLowerCase();
|
||||
if (stKey) out.set(stKey, data);
|
||||
if (fnKey && fnKey !== stKey) out.set(fnKey, data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'menu',
|
||||
description: 'Editor cascade trees (supplier→type→model→unit), keyed by softwareType',
|
||||
loadAll: () => _rekeyBySoftwareType(backend.loadAll()),
|
||||
refresh: async () => _rekeyBySoftwareType(await backend.refresh()),
|
||||
// Exposed for inline tests / debugging.
|
||||
_BASE_DIR: BASE_DIR,
|
||||
_EXCLUDE: EXCLUDE,
|
||||
_existsForFilename: (id) => fs.existsSync(path.join(BASE_DIR, `${id}.json`)),
|
||||
};
|
||||
20
src/registry/namespaces/monsterSamples.js
Normal file
20
src/registry/namespaces/monsterSamples.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const FileBackend = require('../backends/FileBackend');
|
||||
|
||||
const backend = new FileBackend({
|
||||
baseDir: path.resolve(__dirname, '../../../datasets/assetData'),
|
||||
layout: 'single-file',
|
||||
filePath: 'monsterSamples.json',
|
||||
arrayKey: 'samples',
|
||||
indexField: 'code',
|
||||
caseInsensitive: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: 'monsterSamples',
|
||||
description: 'Monster (Aquon) sample codes keyed by sample code',
|
||||
loadAll: () => backend.loadAll(),
|
||||
refresh: () => backend.refresh(),
|
||||
};
|
||||
30
src/registry/namespaces/monsterSpecs.js
Normal file
30
src/registry/namespaces/monsterSpecs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// monsterSpecs is a single-document namespace (one file, two top-level keys:
|
||||
// defaults and bySample). It doesn't fit FileBackend's per-id or single-file
|
||||
// array layouts cleanly — so we inline a tiny loader here instead of bending
|
||||
// FileBackend to accommodate the shape.
|
||||
//
|
||||
// The whole document is exposed under id 'all'. Consumers (AquonSamplesMenu,
|
||||
// monster specificClass) call assetResolver.resolve('monsterSpecs', 'all').
|
||||
|
||||
const FILE_PATH = path.resolve(__dirname, '../../../datasets/assetData/specs/monster/index.json');
|
||||
|
||||
function _load() {
|
||||
if (!fs.existsSync(FILE_PATH)) return new Map();
|
||||
const data = JSON.parse(fs.readFileSync(FILE_PATH, 'utf8'));
|
||||
return new Map([['all', {
|
||||
defaults: data.defaults || {},
|
||||
bySample: data.bySample || {},
|
||||
}]]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'monsterSpecs',
|
||||
description: 'Monster sampling specs (defaults + per-sample overrides) from specs/monster/index.json',
|
||||
loadAll: _load,
|
||||
refresh: () => _load(),
|
||||
};
|
||||
21
src/registry/namespaces/units.js
Normal file
21
src/registry/namespaces/units.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const FileBackend = require('../backends/FileBackend');
|
||||
|
||||
// unitData.json lives at datasets/ (not datasets/assetData/).
|
||||
const backend = new FileBackend({
|
||||
baseDir: path.resolve(__dirname, '../../../datasets'),
|
||||
layout: 'single-file',
|
||||
filePath: 'unitData.json',
|
||||
arrayKey: 'units',
|
||||
indexField: 'category',
|
||||
caseInsensitive: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: 'units',
|
||||
description: 'Unit families keyed by measurement category (flow, pressure, …)',
|
||||
loadAll: () => backend.loadAll(),
|
||||
refresh: () => backend.refresh(),
|
||||
};
|
||||
Reference in New Issue
Block a user