beta. OPC fixed + tested
This commit is contained in:
+301
-58
@@ -116,21 +116,20 @@ function createRandomPointColor(seed) {
|
||||
export function normalizeEngineeringUnit(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "°C";
|
||||
return "\u00B0C";
|
||||
}
|
||||
|
||||
const normalized = raw
|
||||
.replace(/°/g, "°")
|
||||
.replace(/^\?C$/i, "°C")
|
||||
.replace(/^°\s*C$/i, "°C");
|
||||
if (/^\?C$/i.test(raw) || /^\u00B0\s*C$/i.test(raw) || (/[^\x00-\x7F]/.test(raw) && /C/i.test(raw))) {
|
||||
return "\u00B0C";
|
||||
}
|
||||
|
||||
return normalized || "°C";
|
||||
return raw;
|
||||
}
|
||||
|
||||
function createDefaultPoint(index) {
|
||||
return {
|
||||
alias: `Wert${index}`,
|
||||
unit: "°C",
|
||||
unit: "\u00B0C",
|
||||
factor: 1,
|
||||
kind: "analog",
|
||||
min: 0,
|
||||
@@ -164,7 +163,7 @@ async function ensureWideTrendColumns(pool, tableName) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWideTrendTable(pool, tableName) {
|
||||
export async function createLegacyWideTrendTable(pool, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`${safeTable}\` (
|
||||
@@ -181,18 +180,145 @@ export async function createWideTrendTable(pool, tableName) {
|
||||
return safeTable;
|
||||
}
|
||||
|
||||
export async function createTrendTable(pool, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`${safeTable}\` (
|
||||
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`userlevel\` INT(1) NOT NULL DEFAULT 0,
|
||||
\`source_id\` VARCHAR(191) NOT NULL,
|
||||
\`point_index\` INT NOT NULL,
|
||||
\`alias\` VARCHAR(255) NOT NULL,
|
||||
\`value\` TEXT NULL,
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_${safeTable}_datum\` (\`datum\`),
|
||||
KEY \`idx_${safeTable}_point_datum\` (\`point_index\`, \`datum\`),
|
||||
KEY \`idx_${safeTable}_source_point\` (\`source_id\`, \`point_index\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
return safeTable;
|
||||
}
|
||||
|
||||
async function loadSourcePointSeeds(pool, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
point_index AS pointIndex,
|
||||
alias,
|
||||
name,
|
||||
unit,
|
||||
kind
|
||||
FROM \`source_datapoint_registry\`
|
||||
WHERE table_name = ?
|
||||
ORDER BY point_index ASC, updated_at ASC
|
||||
`,
|
||||
[safeTable]
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
pointIndex: sanitizePointIndex(row.pointIndex),
|
||||
alias: String(row.alias || "").trim(),
|
||||
name: String(row.name || "").trim(),
|
||||
unit: String(row.unit || "").trim(),
|
||||
kind: row.kind === "digital" ? "digital" : "analog",
|
||||
}));
|
||||
} catch (error) {
|
||||
if (String(error?.message || "").includes("doesn't exist")) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSourceRegistryEntry(pool, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT 1 FROM `source_registry` WHERE table_name = ? LIMIT 1",
|
||||
[safeTable]
|
||||
);
|
||||
return rows.length > 0;
|
||||
} catch (error) {
|
||||
if (String(error?.message || "").includes("doesn't exist")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function describeTrendTable(pool, dbName, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
const usesExplicitSchema = Boolean(dbName);
|
||||
const [columns] = await pool.query(
|
||||
`
|
||||
SELECT COLUMN_NAME AS columnName
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ${usesExplicitSchema ? "?" : "DATABASE()"}
|
||||
AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`,
|
||||
usesExplicitSchema ? [dbName, safeTable] : [safeTable]
|
||||
);
|
||||
|
||||
const columnNames = columns.map((column) => column.columnName);
|
||||
const lowerNames = new Set(columnNames.map((name) => String(name).toLowerCase()));
|
||||
const widePointCount = columnNames.filter((name) => /^Wert\d+$/i.test(name)).length;
|
||||
|
||||
if (widePointCount > 0) {
|
||||
const [rows] = await pool.query(`SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\``);
|
||||
return {
|
||||
tableName: safeTable,
|
||||
storage: "wide",
|
||||
pointCount: widePointCount,
|
||||
rowCount: Number(rows[0]?.rowCount || 0),
|
||||
latestTimestamp: rows[0]?.latestTimestamp || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerNames.has("datum") && lowerNames.has("point_index") && lowerNames.has("value")) {
|
||||
const sourceSeeds = await loadSourcePointSeeds(pool, safeTable);
|
||||
const sourceBacked = await hasSourceRegistryEntry(pool, safeTable);
|
||||
const [rows] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) AS rowCount,
|
||||
MAX(datum) AS latestTimestamp,
|
||||
COALESCE(MAX(point_index), 0) AS pointCount
|
||||
FROM \`${safeTable}\`
|
||||
`
|
||||
);
|
||||
const measuredPointCount = Number(rows[0]?.pointCount || 0);
|
||||
const pointCount = sourceSeeds.length ? sourceSeeds.length : measuredPointCount;
|
||||
return {
|
||||
tableName: safeTable,
|
||||
storage: "narrow",
|
||||
pointCount: pointCount || (sourceBacked ? 0 : (safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT)),
|
||||
rowCount: Number(rows[0]?.rowCount || 0),
|
||||
latestTimestamp: rows[0]?.latestTimestamp || null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveTrendTablePointCount(pool, dbName, tableName) {
|
||||
const safeTable = sanitizeIspName(tableName);
|
||||
const info = await describeTrendTable(pool, dbName, safeTable);
|
||||
if (!info) {
|
||||
return safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT;
|
||||
}
|
||||
return Number(info.pointCount || 0) || (safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT);
|
||||
}
|
||||
|
||||
export async function getAvailableIsps(pool, dbName) {
|
||||
const [tables] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
t.TABLE_NAME AS tableName,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS c
|
||||
WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA
|
||||
AND c.TABLE_NAME = t.TABLE_NAME
|
||||
AND c.COLUMN_NAME LIKE 'Wert%'
|
||||
) AS pointCount
|
||||
SELECT t.TABLE_NAME AS tableName
|
||||
FROM information_schema.TABLES t
|
||||
WHERE t.TABLE_SCHEMA = ?
|
||||
AND (t.TABLE_NAME LIKE 'isp%' OR t.TABLE_NAME LIKE 'trend_%')
|
||||
@@ -201,36 +327,17 @@ export async function getAvailableIsps(pool, dbName) {
|
||||
[dbName]
|
||||
);
|
||||
|
||||
const stats = new Map();
|
||||
|
||||
await Promise.all(
|
||||
tables.map(async (table) => {
|
||||
const safeTable = sanitizeIspName(table.tableName);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\``
|
||||
);
|
||||
stats.set(safeTable, rows[0] || { rowCount: 0, latestTimestamp: null });
|
||||
})
|
||||
);
|
||||
|
||||
return tables.map((table) => {
|
||||
const safeTable = sanitizeIspName(table.tableName);
|
||||
const info = stats.get(safeTable) || {};
|
||||
|
||||
return {
|
||||
tableName: safeTable,
|
||||
pointCount: Number(table.pointCount || 0),
|
||||
rowCount: Number(info.rowCount || 0),
|
||||
latestTimestamp: info.latestTimestamp || null,
|
||||
};
|
||||
});
|
||||
const described = await Promise.all(tables.map((table) => describeTrendTable(pool, dbName, table.tableName)));
|
||||
return described.filter(Boolean);
|
||||
}
|
||||
|
||||
export function createDefaultMetadata(isp, pointCount = LEGACY_POINT_LIMIT) {
|
||||
const points = {};
|
||||
const pointOrder = [];
|
||||
|
||||
for (let index = 1; index <= pointCount; index += 1) {
|
||||
points[String(index)] = createDefaultPoint(index);
|
||||
const key = String(index);
|
||||
points[key] = createDefaultPoint(index);
|
||||
pointOrder.push(key);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -238,12 +345,18 @@ export function createDefaultMetadata(isp, pointCount = LEGACY_POINT_LIMIT) {
|
||||
displayName: isp.toUpperCase(),
|
||||
description: "",
|
||||
points,
|
||||
pointOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LIMIT) {
|
||||
export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_POINT_LIMIT) {
|
||||
const safeIsp = sanitizeIspName(isp);
|
||||
const key = `${META_PREFIX}${safeIsp}`;
|
||||
const sourceSeeds = await loadSourcePointSeeds(pool, safeIsp);
|
||||
const seedMap = new Map(sourceSeeds.map((seed) => [String(seed.pointIndex), seed]));
|
||||
const pointOrder = sourceSeeds.length
|
||||
? sourceSeeds.map((seed) => String(seed.pointIndex))
|
||||
: Array.from({ length: pointCount }, (_value, index) => String(index + 1));
|
||||
const existing = await redis.get(key);
|
||||
|
||||
if (existing) {
|
||||
@@ -255,19 +368,43 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (let index = 1; index <= pointCount; index += 1) {
|
||||
const pointKey = String(index);
|
||||
if (!Array.isArray(parsed.pointOrder)) {
|
||||
parsed.pointOrder = [];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (JSON.stringify(parsed.pointOrder) !== JSON.stringify(pointOrder)) {
|
||||
parsed.pointOrder = pointOrder;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
pointOrder.forEach((pointKey) => {
|
||||
const index = Number(pointKey);
|
||||
const defaultPoint = createDefaultPoint(index);
|
||||
const sourceSeed = seedMap.get(pointKey);
|
||||
const initialAlias = sourceSeed?.alias || sourceSeed?.name || defaultPoint.alias;
|
||||
|
||||
if (!parsed.points[pointKey]) {
|
||||
parsed.points[pointKey] = defaultPoint;
|
||||
parsed.points[pointKey] = {
|
||||
...defaultPoint,
|
||||
alias: initialAlias,
|
||||
unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit),
|
||||
kind: sourceSeed?.kind || defaultPoint.kind,
|
||||
};
|
||||
changed = true;
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const point = parsed.points[pointKey];
|
||||
if (!point.alias) {
|
||||
point.alias = defaultPoint.alias;
|
||||
if (!point.alias || point.alias === defaultPoint.alias) {
|
||||
const nextAlias = initialAlias || defaultPoint.alias;
|
||||
if (point.alias !== nextAlias) {
|
||||
point.alias = nextAlias;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (sourceSeed?.kind && !point.kind) {
|
||||
point.kind = sourceSeed.kind;
|
||||
changed = true;
|
||||
}
|
||||
const normalizedUnit = normalizeEngineeringUnit(point.unit);
|
||||
@@ -275,8 +412,15 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
|
||||
point.unit = normalizedUnit;
|
||||
changed = true;
|
||||
}
|
||||
if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit)) && sourceSeed?.unit) {
|
||||
const seededUnit = normalizeEngineeringUnit(sourceSeed.unit);
|
||||
if (point.unit !== seededUnit) {
|
||||
point.unit = seededUnit;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!point.kind) {
|
||||
point.kind = defaultPoint.kind;
|
||||
point.kind = sourceSeed?.kind || defaultPoint.kind;
|
||||
changed = true;
|
||||
}
|
||||
if (!Number.isFinite(Number(point.factor))) {
|
||||
@@ -300,7 +444,7 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
|
||||
point.color = defaultPoint.color;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
await redis.set(key, JSON.stringify(parsed));
|
||||
@@ -310,18 +454,31 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
|
||||
}
|
||||
|
||||
const metadata = createDefaultMetadata(safeIsp, pointCount);
|
||||
metadata.pointOrder = pointOrder;
|
||||
pointOrder.forEach((pointKey) => {
|
||||
const sourceSeed = seedMap.get(pointKey);
|
||||
if (!sourceSeed) {
|
||||
return;
|
||||
}
|
||||
metadata.points[pointKey] = {
|
||||
...metadata.points[pointKey],
|
||||
alias: sourceSeed.alias || sourceSeed.name || metadata.points[pointKey].alias,
|
||||
unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit),
|
||||
kind: sourceSeed.kind || metadata.points[pointKey].kind,
|
||||
};
|
||||
});
|
||||
await redis.set(key, JSON.stringify(metadata));
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function updateIspMetadata(redis, isp, updater, pointCount = LEGACY_POINT_LIMIT) {
|
||||
const metadata = await ensureIspMetadata(redis, isp, pointCount);
|
||||
export async function updateIspMetadata(pool, redis, isp, updater, pointCount = LEGACY_POINT_LIMIT) {
|
||||
const metadata = await ensureIspMetadata(pool, redis, isp, pointCount);
|
||||
const updated = updater(JSON.parse(JSON.stringify(metadata)));
|
||||
await redis.set(`${META_PREFIX}${sanitizeIspName(isp)}`, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getPointDescriptors(redis, points) {
|
||||
export async function getPointDescriptors(pool, redis, points) {
|
||||
const grouped = new Map();
|
||||
|
||||
points.forEach((point) => {
|
||||
@@ -338,10 +495,11 @@ export async function getPointDescriptors(redis, points) {
|
||||
const descriptors = [];
|
||||
|
||||
for (const [isp, pointIndexes] of grouped.entries()) {
|
||||
const metadata = await ensureIspMetadata(redis, isp, LOCAL_POINT_LIMIT);
|
||||
const pointCount = await resolveTrendTablePointCount(pool, null, isp);
|
||||
const metadata = await ensureIspMetadata(pool, redis, isp, pointCount);
|
||||
|
||||
pointIndexes.forEach((pointIndex) => {
|
||||
const pointMeta = metadata.points[String(pointIndex)];
|
||||
const pointMeta = metadata.points[String(pointIndex)] || createDefaultPoint(pointIndex);
|
||||
descriptors.push({
|
||||
isp,
|
||||
pointIndex,
|
||||
@@ -362,7 +520,7 @@ export async function getPointDescriptors(redis, points) {
|
||||
}
|
||||
|
||||
export async function fetchTrendSeries(pool, redis, points, range) {
|
||||
const descriptors = await getPointDescriptors(redis, points);
|
||||
const descriptors = await getPointDescriptors(pool, redis, points);
|
||||
const grouped = descriptors.reduce((accumulator, descriptor) => {
|
||||
if (!accumulator.has(descriptor.isp)) {
|
||||
accumulator.set(descriptor.isp, []);
|
||||
@@ -375,6 +533,43 @@ export async function fetchTrendSeries(pool, redis, points, range) {
|
||||
const mergedRows = new Map();
|
||||
|
||||
for (const [isp, pointDescriptors] of grouped.entries()) {
|
||||
const tableInfo = await describeTrendTable(pool, null, isp);
|
||||
if (!tableInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tableInfo.storage === "narrow") {
|
||||
const placeholders = pointDescriptors.map(() => "?").join(", ");
|
||||
const limit = Math.max(pointDescriptors.length, (range.maxRows || 4000) * Math.max(1, pointDescriptors.length));
|
||||
const [rows] = await pool.query(
|
||||
`
|
||||
SELECT datum, point_index, value
|
||||
FROM \`${isp}\`
|
||||
WHERE point_index IN (${placeholders})
|
||||
AND datum BETWEEN ? AND ?
|
||||
ORDER BY datum DESC, id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[...pointDescriptors.map((descriptor) => descriptor.pointIndex), range.from, range.to, limit]
|
||||
);
|
||||
|
||||
rows.reverse().forEach((row) => {
|
||||
const timestamp = new Date(row.datum).toISOString();
|
||||
if (!mergedRows.has(timestamp)) {
|
||||
mergedRows.set(timestamp, { timestamp });
|
||||
}
|
||||
const target = mergedRows.get(timestamp);
|
||||
const descriptor = pointDescriptors.find((item) => item.pointIndex === Number(row.point_index));
|
||||
if (!descriptor) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseTrendValue(row.value, descriptor.factor);
|
||||
target[descriptor.key] = parsed.numeric;
|
||||
target[`${descriptor.key}:raw`] = parsed.raw;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const columns = pointDescriptors
|
||||
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
||||
.join(", ");
|
||||
@@ -410,7 +605,7 @@ export async function fetchTrendSeries(pool, redis, points, range) {
|
||||
}
|
||||
|
||||
export async function fetchLatestValues(pool, redis, points) {
|
||||
const descriptors = await getPointDescriptors(redis, points);
|
||||
const descriptors = await getPointDescriptors(pool, redis, points);
|
||||
const grouped = descriptors.reduce((accumulator, descriptor) => {
|
||||
if (!accumulator.has(descriptor.isp)) {
|
||||
accumulator.set(descriptor.isp, []);
|
||||
@@ -423,6 +618,52 @@ export async function fetchLatestValues(pool, redis, points) {
|
||||
const values = [];
|
||||
|
||||
for (const [isp, pointDescriptors] of grouped.entries()) {
|
||||
const tableInfo = await describeTrendTable(pool, null, isp);
|
||||
if (!tableInfo) {
|
||||
pointDescriptors.forEach((descriptor) => {
|
||||
values.push({
|
||||
...descriptor,
|
||||
timestamp: null,
|
||||
raw: null,
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tableInfo.storage === "narrow") {
|
||||
const placeholders = pointDescriptors.map(() => "?").join(", ");
|
||||
const [rows] = await pool.query(
|
||||
`
|
||||
SELECT point_index, datum, value
|
||||
FROM (
|
||||
SELECT
|
||||
point_index,
|
||||
datum,
|
||||
value,
|
||||
ROW_NUMBER() OVER (PARTITION BY point_index ORDER BY datum DESC, id DESC) AS point_rank
|
||||
FROM \`${isp}\`
|
||||
WHERE point_index IN (${placeholders})
|
||||
) ranked
|
||||
WHERE point_rank = 1
|
||||
`,
|
||||
pointDescriptors.map((descriptor) => descriptor.pointIndex)
|
||||
);
|
||||
const latestByPoint = new Map(rows.map((row) => [Number(row.point_index), row]));
|
||||
|
||||
pointDescriptors.forEach((descriptor) => {
|
||||
const row = latestByPoint.get(descriptor.pointIndex);
|
||||
const parsed = parseTrendValue(row?.value, descriptor.factor);
|
||||
values.push({
|
||||
...descriptor,
|
||||
timestamp: row?.datum ? new Date(row.datum).toISOString() : null,
|
||||
raw: parsed.raw,
|
||||
value: parsed.numeric,
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const columns = pointDescriptors
|
||||
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
|
||||
.join(", ");
|
||||
@@ -459,3 +700,5 @@ export function normalizeRange(input = {}) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const SELECTION_SET_KEY = "selection:ids";
|
||||
const SELECTION_SEQ_KEY = "selection:seq";
|
||||
const ROLE_SET_KEY = "role:names";
|
||||
const UNIT_SET_KEY = "unit:symbols";
|
||||
|
||||
export const DEFAULT_ROLE_DEFINITIONS = [
|
||||
{
|
||||
@@ -23,10 +24,48 @@ export const DEFAULT_ROLE_DEFINITIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_UNIT_DEFINITIONS = [
|
||||
{ symbol: "\u00B0C", label: "Grad Celsius", isSystem: true },
|
||||
{ symbol: "%", label: "Prozent", isSystem: true },
|
||||
{ symbol: "K", label: "Kelvin", isSystem: true },
|
||||
{ symbol: "V", label: "Volt", isSystem: true },
|
||||
{ symbol: "A", label: "Ampere", isSystem: true },
|
||||
{ symbol: "Pa", label: "Pascal", isSystem: true },
|
||||
];
|
||||
|
||||
function safeParse(jsonValue) {
|
||||
return jsonValue ? JSON.parse(jsonValue) : null;
|
||||
}
|
||||
|
||||
function normalizeUnitSymbol(value, fallback = "") {
|
||||
const raw = String(value ?? "").trim();
|
||||
const collapsed = raw.replace(/\s+/g, "");
|
||||
|
||||
if (!collapsed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (/^\?C$/i.test(collapsed) || /^Â?\u00B0C$/i.test(collapsed)) {
|
||||
return "\u00B0C";
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function unitStorageKey(symbol) {
|
||||
return `unit:${encodeURIComponent(symbol)}`;
|
||||
}
|
||||
|
||||
function normalizeUnitRecord(unit) {
|
||||
const symbol = normalizeUnitSymbol(unit?.symbol, "\u00B0C");
|
||||
return {
|
||||
symbol,
|
||||
label: String(unit?.label || symbol).trim() || symbol,
|
||||
isSystem: Boolean(unit?.isSystem),
|
||||
updatedAt: unit?.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoleName(value) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
@@ -72,6 +111,22 @@ async function ensureDefaultRoles(redis) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDefaultUnits(redis) {
|
||||
for (const definition of DEFAULT_UNIT_DEFINITIONS) {
|
||||
const key = unitStorageKey(definition.symbol);
|
||||
const existing = safeParse(await redis.get(key));
|
||||
const payload = normalizeUnitRecord({
|
||||
...definition,
|
||||
...(existing || {}),
|
||||
symbol: definition.symbol,
|
||||
label: existing?.label || definition.label,
|
||||
isSystem: true,
|
||||
});
|
||||
await redis.set(key, JSON.stringify(payload));
|
||||
await redis.sadd(UNIT_SET_KEY, definition.symbol);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRoles(redis) {
|
||||
await ensureDefaultRoles(redis);
|
||||
const names = await redis.smembers(ROLE_SET_KEY);
|
||||
@@ -118,6 +173,47 @@ export async function saveRole(redis, role, previousName = "") {
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function listUnits(redis) {
|
||||
await ensureDefaultUnits(redis);
|
||||
const symbols = await redis.smembers(UNIT_SET_KEY);
|
||||
const items = await Promise.all(symbols.map((symbol) => redis.get(unitStorageKey(symbol))));
|
||||
|
||||
return items
|
||||
.map((item) => safeParse(item))
|
||||
.filter(Boolean)
|
||||
.map((unit) => normalizeUnitRecord(unit))
|
||||
.sort((left, right) => left.symbol.localeCompare(right.symbol, "de-DE"));
|
||||
}
|
||||
|
||||
export async function saveUnit(redis, unit, previousSymbol = "") {
|
||||
await ensureDefaultUnits(redis);
|
||||
|
||||
const existingSymbol = normalizeUnitSymbol(previousSymbol || unit?.symbol, "");
|
||||
const payload = normalizeUnitRecord(unit);
|
||||
const current = existingSymbol ? safeParse(await redis.get(unitStorageKey(existingSymbol))) : null;
|
||||
|
||||
if (current?.isSystem && existingSymbol !== payload.symbol) {
|
||||
throw new Error("Systemeinheiten können nicht umbenannt werden.");
|
||||
}
|
||||
|
||||
const merged = normalizeUnitRecord({
|
||||
...(current || {}),
|
||||
...payload,
|
||||
symbol: current?.isSystem ? existingSymbol : payload.symbol,
|
||||
isSystem: Boolean(current?.isSystem || payload.isSystem),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (existingSymbol && existingSymbol !== merged.symbol) {
|
||||
await redis.del(unitStorageKey(existingSymbol));
|
||||
await redis.srem(UNIT_SET_KEY, existingSymbol);
|
||||
}
|
||||
|
||||
await redis.set(unitStorageKey(merged.symbol), JSON.stringify(merged));
|
||||
await redis.sadd(UNIT_SET_KEY, merged.symbol);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function listSelections(redis, user) {
|
||||
const ids = await redis.smembers(SELECTION_SET_KEY);
|
||||
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`)));
|
||||
|
||||
Reference in New Issue
Block a user