diff --git a/app/components/device/new/custom-device-config.tsx b/app/components/device/new/custom-device-config.tsx index 587a725c..0527d4b9 100644 --- a/app/components/device/new/custom-device-config.tsx +++ b/app/components/device/new/custom-device-config.tsx @@ -1,7 +1,8 @@ import { FileJson, Library, Lock, Search, X } from 'lucide-react' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useFormContext, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { useLoaderData } from 'react-router' import { type CustomDeviceSchemaUpload, type Sensor } from './sensors-info' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' @@ -12,6 +13,13 @@ import { Label } from '@/components/ui/label' import { Separator } from '~/components/ui/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs' import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' +import { + getSensorWikiAliasSuggestions, + matchSensorWikiAlias, + type SensorWikiAliasEntry, + type SensorWikiAliasSuggestion, +} from '~/lib/device-schemas/sensor-wiki-aliases' +import { type loader } from '~/routes/device.new' type RegistryDeviceSchema = { id: string @@ -33,7 +41,24 @@ type RegistryResponse = { schemas: RegistryDeviceSchema[] } +function enrichSensorWithAlias( + sensor: T, + sensorWikiAliasEntries: SensorWikiAliasEntry[], +): T { + const match = matchSensorWikiAlias(sensor, sensorWikiAliasEntries) + + if (!match) return sensor + + return { + ...sensor, + sensorWikiPhenomenon: + sensor.sensorWikiPhenomenon ?? match.sensorWikiPhenomenon, + sensorWikiUnit: sensor.sensorWikiUnit ?? match.sensorWikiUnit, + } +} + export function CustomDeviceConfig() { + const { sensorWikiAliasEntries } = useLoaderData() const { control, setValue } = useFormContext() const sensors = (useWatch({ control, name: 'selectedSensors' }) as Sensor[] | undefined) ?? @@ -41,7 +66,7 @@ export function CustomDeviceConfig() { const deviceSchema = useWatch({ control, name: 'deviceSchema', - }) as CustomDeviceSchemaUpload | undefined + }) as CustomDeviceSchemaUpload const deviceSchemaVersionId = useWatch({ control, name: 'deviceSchemaVersionId', @@ -61,8 +86,20 @@ export function CustomDeviceConfig() { unit: '', sensorType: '', }) + const [isSuggestionListOpen, setIsSuggestionListOpen] = useState(false) const { t } = useTranslation('newdevice') + const sensorSuggestions = useMemo( + () => getSensorWikiAliasSuggestions(newSensor, 5, sensorWikiAliasEntries), + [newSensor, sensorWikiAliasEntries], + ) + const sensorWikiMatch = useMemo( + () => matchSensorWikiAlias(newSensor, sensorWikiAliasEntries), + [newSensor, sensorWikiAliasEntries], + ) + const hasManualSensorTitle = newSensor.title.trim().length >= 2 + const firstSensorSuggestion = sensorSuggestions[0] + useEffect(() => { const abortController = new AbortController() const timeout = setTimeout(async () => { @@ -100,14 +137,48 @@ export function CustomDeviceConfig() { }, [registryQuery, t]) const updateNewSensor = (field: keyof Sensor, value: string) => { - setNewSensor((prev) => ({ ...prev, [field]: value })) + setNewSensor((prev) => { + const nextSensor = { ...prev, [field]: value } + + if (field === 'title') { + return { + ...nextSensor, + sensorWikiPhenomenon: undefined, + sensorWikiUnit: undefined, + } + } + + if (field === 'unit') { + return { + ...nextSensor, + sensorWikiUnit: undefined, + } + } + + return nextSensor + }) + } + + const applySensorSuggestion = (suggestion: SensorWikiAliasSuggestion) => { + setNewSensor((prev) => ({ + ...prev, + title: suggestion.title, + unit: prev.unit || suggestion.unit || '', + sensorWikiPhenomenon: suggestion.sensorWikiPhenomenon, + sensorWikiUnit: suggestion.sensorWikiUnit, + })) + setIsSuggestionListOpen(false) } const addSensor = () => { if (deviceSchema || deviceSchemaVersionId) return if (!newSensor.title || !newSensor.unit || !newSensor.sensorType) return - setValue('selectedSensors', [...sensors, newSensor]) + const updatedSensors = [ + ...sensors, + enrichSensorWithAlias(newSensor, sensorWikiAliasEntries), + ] + setValue('selectedSensors', updatedSensors) setNewSensor({ title: '', unit: '', sensorType: '' }) } @@ -124,7 +195,13 @@ export function CustomDeviceConfig() { try { const parsedJson = JSON.parse(await file.text()) const parsedSchema = uploadedDeviceSchemaV1.parse(parsedJson) - const schemaSensors = parsedSchema.sensors.map((sensor) => ({ + const enrichedSchema = { + ...parsedSchema, + sensors: parsedSchema.sensors.map((sensor) => + enrichSensorWithAlias(sensor, sensorWikiAliasEntries), + ), + } + const schemaSensors = enrichedSchema.sensors.map((sensor) => ({ id: sensor.id, title: sensor.title, unit: sensor.unit, @@ -135,7 +212,7 @@ export function CustomDeviceConfig() { sensorWikiUnit: sensor.sensorWikiUnit, })) - setValue('deviceSchema', parsedSchema) + setValue('deviceSchema', enrichedSchema) setValue('deviceSchemaVersionId', undefined) setValue('deviceSchemaRegistrySelection', undefined) setValue('selectedSensors', schemaSensors) @@ -339,16 +416,71 @@ export function CustomDeviceConfig() { )}
+

+ {t('manual_sensors_sensor_wiki_hint')} +

- updateNewSensor('title', e.target.value)} - placeholder="e.g., Temperature" - disabled={userHasSelectedSchema} - /> +
+ { + updateNewSensor('title', e.target.value) + setIsSuggestionListOpen(true) + }} + onFocus={() => setIsSuggestionListOpen(true)} + onBlur={() => setIsSuggestionListOpen(false)} + placeholder="e.g., Temperature" + disabled={userHasSelectedSchema} + autoComplete="off" + aria-autocomplete="list" + aria-expanded={ + isSuggestionListOpen && sensorSuggestions.length > 0 + } + aria-controls="sensor-wiki-suggestions" + /> + {isSuggestionListOpen && sensorSuggestions.length > 0 && ( +
+ {sensorSuggestions.map((suggestion) => ( + + ))} +
+ )} +
@@ -373,6 +505,46 @@ export function CustomDeviceConfig() { />
+ {!userHasSelectedSchema && hasManualSensorTitle && ( +
+ {sensorWikiMatch ? ( + <> + + {t( + sensorWikiMatch.confidence === 'high' + ? 'device_schema_alias_confidence_high' + : 'device_schema_alias_confidence_medium', + )} + + + {t('manual_sensor_wiki_matched', { + phenomenon: sensorWikiMatch.sensorWikiPhenomenon, + })} + + + ) : firstSensorSuggestion ? ( + <> + + {t('device_schema_alias_confidence_medium')} + + + {t('manual_sensor_wiki_suggestion_available', { + phenomenon: firstSensorSuggestion.sensorWikiPhenomenon, + })} + + + ) : ( + <> + + {t('manual_sensor_wiki_unmatched')} + + + {t('manual_sensor_wiki_unmatched_text')} + + + )} +
+ )} diff --git a/app/db/drizzle/0048_sensor_wiki_aliases.sql b/app/db/drizzle/0048_sensor_wiki_aliases.sql new file mode 100644 index 00000000..8d5561eb --- /dev/null +++ b/app/db/drizzle/0048_sensor_wiki_aliases.sql @@ -0,0 +1,14 @@ +CREATE TABLE "sensor_wiki_alias" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "sensor_wiki_phenomenon" text NOT NULL, + "sensor_wiki_unit" text, + "title" text NOT NULL, + "unit" text, + "title_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "unit_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "sensor_type_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "sensor_wiki_alias_key_unique" ON "sensor_wiki_alias" USING btree ("key");--> statement-breakpoint +CREATE INDEX "sensor_wiki_alias_phenomenon_idx" ON "sensor_wiki_alias" USING btree ("sensor_wiki_phenomenon"); \ No newline at end of file diff --git a/app/db/drizzle/meta/0048_snapshot.json b/app/db/drizzle/meta/0048_snapshot.json new file mode 100644 index 00000000..d6738169 --- /dev/null +++ b/app/db/drizzle/meta/0048_snapshot.json @@ -0,0 +1,2093 @@ +{ + "id": "f745826f-5e8c-4bb5-bbab-7e9e77ffdbf2", + "prevId": "3285f8be-bd73-4ead-b46a-8a783965c4d7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'custom'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "orphaned_at": { + "name": "orphaned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_schema_version_id": { + "name": "device_schema_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "device_user_id_user_id_fk": { + "name": "device_user_id_user_id_fk", + "tableFrom": "device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_device_schema_version_id_device_schema_version_id_fk": { + "name": "device_device_schema_version_id_device_schema_version_id_fk", + "tableFrom": "device", + "tableTo": "device_schema_version", + "columnsFrom": [ + "device_schema_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_schema": { + "name": "device_schema", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "device_schema_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_schema_owner_slug_unique": { + "name": "device_schema_owner_slug_unique", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_schema_visibility_idx": { + "name": "device_schema_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_schema_owner_user_id_user_id_fk": { + "name": "device_schema_owner_user_id_user_id_fk", + "tableFrom": "device_schema", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_schema_version": { + "name": "device_schema_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_schema_id": { + "name": "device_schema_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format_version": { + "name": "format_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_schema_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'current'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deprecated_at": { + "name": "deprecated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_schema_version_unique": { + "name": "device_schema_version_unique", + "columns": [ + { + "expression": "device_schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_schema_version_hash_unique": { + "name": "device_schema_version_hash_unique", + "columns": [ + { + "expression": "device_schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_schema_version_device_schema_id_device_schema_id_fk": { + "name": "device_schema_version_device_schema_id_device_schema_id_fk", + "tableFrom": "device_schema_version", + "tableTo": "device_schema", + "columnsFrom": [ + "device_schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_schema_version_created_by_user_id_user_id_fk": { + "name": "device_schema_version_created_by_user_id_user_id_fk", + "tableFrom": "device_schema_version", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "home_latitude": { + "name": "home_latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_longitude": { + "name": "home_longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_zoom": { + "name": "home_zoom", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_user_id_unique": { + "name": "profile_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": { + "sensor_device_id_idx": { + "name": "sensor_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_preference": { + "name": "theme_preference", + "type": "theme_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "newsletter_opt_in": { + "name": "newsletter_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_tos_version_id": { + "name": "accepted_tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_tos_at": { + "name": "accepted_tos_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_accepted_tos_version_id_tos_version_id_fk": { + "name": "user_accepted_tos_version_id_tos_version_id_fk", + "tableFrom": "user", + "tableTo": "tos_version", + "columnsFrom": [ + "accepted_tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_name_unique": { + "name": "user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_key": { + "name": "service_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_slug_unique": { + "name": "integration_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_user_state": { + "name": "tos_user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tos_version_id": { + "name": "tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tos_user_state_user_idx": { + "name": "tos_user_state_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tos_user_state_user_id_user_id_fk": { + "name": "tos_user_state_user_id_user_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tos_user_state_tos_version_id_tos_version_id_fk": { + "name": "tos_user_state_tos_version_id_tos_version_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "tos_version", + "columnsFrom": [ + "tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tos_user_state_user_id_tos_version_id_pk": { + "name": "tos_user_state_user_id_tos_version_id_pk", + "columns": [ + "user_id", + "tos_version_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_version": { + "name": "tos_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accept_by": { + "name": "accept_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tos_version_effective_from_idx": { + "name": "tos_version_effective_from_idx", + "columns": [ + { + "expression": "effective_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tos_version_accept_by_idx": { + "name": "tos_version_accept_by_idx", + "columns": [ + { + "expression": "accept_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tos_version_version_unique": { + "name": "tos_version_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_token": { + "name": "action_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "action_token_user_purpose_uq": { + "name": "action_token_user_purpose_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_token_expires_at_idx": { + "name": "action_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_token_user_id_user_id_fk": { + "name": "action_token_user_id_user_id_fk", + "tableFrom": "action_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "action_token_token_hash_unique": { + "name": "action_token_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor_wiki_alias": { + "name": "sensor_wiki_alias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_aliases": { + "name": "title_aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "unit_aliases": { + "name": "unit_aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "sensor_type_aliases": { + "name": "sensor_type_aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + } + }, + "indexes": { + "sensor_wiki_alias_key_unique": { + "name": "sensor_wiki_alias_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sensor_wiki_alias_phenomenon_idx": { + "name": "sensor_wiki_alias_phenomenon_idx", + "columns": [ + { + "expression": "sensor_wiki_phenomenon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_schema_version_status": { + "name": "device_schema_version_status", + "schema": "public", + "values": [ + "current", + "deprecated" + ] + }, + "public.device_schema_visibility": { + "name": "device_schema_visibility", + "schema": "public", + "values": [ + "private", + "public" + ] + }, + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "senseBox:Edu", + "luftdaten.info", + "custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + }, + "public.theme_preference": { + "name": "theme_preference", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/app/db/drizzle/meta/_journal.json b/app/db/drizzle/meta/_journal.json index aa7ef1a3..0f4c9705 100644 --- a/app/db/drizzle/meta/_journal.json +++ b/app/db/drizzle/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1786104483816, "tag": "0047_device_schemas", "breakpoints": true + }, + { + "idx": 48, + "version": "7", + "when": 1786346036020, + "tag": "0048_sensor_wiki_aliases", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/db/models/phenomena.server.ts b/app/db/models/phenomena.server.ts index 40f9a484..52218af2 100644 --- a/app/db/models/phenomena.server.ts +++ b/app/db/models/phenomena.server.ts @@ -1,7 +1,17 @@ -import { isNotNull, isNull, ne, and, asc, eq } from 'drizzle-orm' +import { isNotNull, isNull, ne, and, asc, eq, sql } from 'drizzle-orm' import { drizzleClient } from '~/db.server' +import { + PHENOMENON_FUZZY_MATCH_THRESHOLD, + getFuzzySensorWikiPhenomenonMatch, + getCanonicalSensorWikiPhenomenon, + getSensorWikiPhenomenonFilterValue, + getSensorWikiPhenomenonLabel, + getTitlePhenomenonFilterValue, + type PhenomenonFilterOption, +} from '~/lib/phenomenon-filter' import { type SensorWikiTranslation } from '~/lib/sensor-wiki' import { device, sensor } from '../schema' +import { getActiveSensorWikiAliasEntries } from './sensor-wiki-alias.server' export type Phenomenon = { id: number @@ -11,14 +21,25 @@ export type Phenomenon = { description: SensorWikiTranslation } +const MIN_FALLBACK_PHENOMENON_COUNT = 3 +const MAX_FALLBACK_PHENOMENA = 50 + /** - * Queries the database for a distinct list of all sensor titles / phenomena - * known to the application across all non-archived devices. + * Queries the database for a distinct list of phenomena known to the + * application across all non-archived devices. Known aliases are grouped under + * their canonical Sensor-Wiki phenomenon; frequent unmapped titles are shown as + * "Other" options when they are not likely variants of a canonical phenomenon. */ -export const getPhenomena = async function findPhenomena(): Promise { +export const getPhenomena = async function findPhenomena(): Promise< + PhenomenonFilterOption[] +> { const rows = await drizzleClient - .selectDistinct({ + .select({ title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + count: sql`count(*)::int`, }) .from(sensor) .innerJoin(device, eq(sensor.deviceId, device.id)) @@ -29,7 +50,84 @@ export const getPhenomena = async function findPhenomena(): Promise { ne(sensor.title, ''), ), ) + .groupBy( + sensor.title, + sensor.unit, + sensor.sensorType, + sensor.sensorWikiPhenomenon, + ) .orderBy(asc(sensor.title)) - return rows.map((row) => row.title) + const sensorWikiAliasEntries = await getActiveSensorWikiAliasEntries() + const optionsByValue = new Map() + const fallbackOptionsByValue = new Map< + string, + PhenomenonFilterOption & { count: number } + >() + + for (const row of rows) { + const canonicalPhenomenon = getCanonicalSensorWikiPhenomenon( + row, + sensorWikiAliasEntries, + ) + + if (canonicalPhenomenon) { + const value = getSensorWikiPhenomenonFilterValue(canonicalPhenomenon) + const option = optionsByValue.get(value) ?? { + value, + label: getSensorWikiPhenomenonLabel( + canonicalPhenomenon, + sensorWikiAliasEntries, + ), + source: 'sensor-wiki', + aliases: [], + } + + if (row.title && !option.aliases.includes(row.title)) { + option.aliases.push(row.title) + } + + option.description = option.aliases.slice(0, 4).join(', ') + optionsByValue.set(value, option) + continue + } + + const fuzzyMatch = getFuzzySensorWikiPhenomenonMatch( + row, + PHENOMENON_FUZZY_MATCH_THRESHOLD, + sensorWikiAliasEntries, + ) + + if (fuzzyMatch || row.count < MIN_FALLBACK_PHENOMENON_COUNT) continue + + const value = getTitlePhenomenonFilterValue(row.title) + const existingFallback = fallbackOptionsByValue.get(value) + + if (existingFallback) { + existingFallback.count += row.count + existingFallback.description = `${existingFallback.count} sensors` + continue + } + + fallbackOptionsByValue.set(value, { + value, + label: `Other: ${row.title}`, + description: `${row.count} sensors`, + source: 'title', + aliases: [row.title], + count: row.count, + }) + } + + const canonicalOptions = [...optionsByValue.values()].sort((left, right) => + left.label.localeCompare(right.label), + ) + const limitedFallbackOptions = [...fallbackOptionsByValue.values()] + .sort( + (left, right) => + right.count - left.count || left.label.localeCompare(right.label), + ) + .slice(0, MAX_FALLBACK_PHENOMENA) + + return [...canonicalOptions, ...limitedFallbackOptions] } diff --git a/app/db/models/sensor-wiki-alias.server.ts b/app/db/models/sensor-wiki-alias.server.ts new file mode 100644 index 00000000..dc01dd4c --- /dev/null +++ b/app/db/models/sensor-wiki-alias.server.ts @@ -0,0 +1,121 @@ +import { asc, eq } from 'drizzle-orm' +import { drizzleClient } from '~/db.server' +import { sensorWikiAlias } from '~/db/schema' +import { + createSensorWikiAliasKey, + sensorWikiAliasEntries, + type SensorWikiAliasEntry, +} from '~/lib/device-schemas/sensor-wiki-aliases' + +export async function getSensorWikiAliasesForAdmin() { + return drizzleClient + .select() + .from(sensorWikiAlias) + .orderBy( + asc(sensorWikiAlias.sensorWikiPhenomenon), + asc(sensorWikiAlias.sensorWikiUnit), + ) +} + +export async function createSensorWikiAlias(input: { + key: string + sensorWikiPhenomenon: string + sensorWikiUnit?: string | null + title: string + unit?: string | null + titleAliases: string[] + unitAliases: string[] + sensorTypeAliases: string[] +}) { + return drizzleClient.insert(sensorWikiAlias).values(input).returning() +} + +export async function updateSensorWikiAlias( + id: string, + input: { + key: string + sensorWikiPhenomenon: string + sensorWikiUnit?: string | null + title: string + unit?: string | null + titleAliases: string[] + unitAliases: string[] + sensorTypeAliases: string[] + }, +) { + return drizzleClient + .update(sensorWikiAlias) + .set(input) + .where(eq(sensorWikiAlias.id, id)) + .returning() +} + +export async function deleteSensorWikiAlias(id: string) { + return drizzleClient + .delete(sensorWikiAlias) + .where(eq(sensorWikiAlias.id, id)) + .returning() +} + +export async function seedMissingSensorWikiAliasesFromBundledEntries() { + let insertedCount = 0 + + for (const entry of sensorWikiAliasEntries) { + const inserted = await drizzleClient + .insert(sensorWikiAlias) + .values({ + key: createSensorWikiAliasKey(entry), + sensorWikiPhenomenon: entry.sensorWikiPhenomenon, + sensorWikiUnit: entry.sensorWikiUnit ?? null, + title: entry.title, + unit: entry.unit ?? null, + titleAliases: entry.titleAliases, + unitAliases: entry.unitAliases ?? [], + sensorTypeAliases: entry.sensorTypeAliases ?? [], + }) + .onConflictDoNothing() + .returning({ id: sensorWikiAlias.id }) + + insertedCount += inserted.length + } + + return insertedCount +} + +export async function getActiveSensorWikiAliasEntries(): Promise< + SensorWikiAliasEntry[] +> { + try { + const rows = await drizzleClient + .select({ + sensorWikiPhenomenon: sensorWikiAlias.sensorWikiPhenomenon, + sensorWikiUnit: sensorWikiAlias.sensorWikiUnit, + title: sensorWikiAlias.title, + unit: sensorWikiAlias.unit, + titleAliases: sensorWikiAlias.titleAliases, + unitAliases: sensorWikiAlias.unitAliases, + sensorTypeAliases: sensorWikiAlias.sensorTypeAliases, + }) + .from(sensorWikiAlias) + .orderBy( + asc(sensorWikiAlias.sensorWikiPhenomenon), + asc(sensorWikiAlias.sensorWikiUnit), + ) + + return rows.map((row) => ({ + sensorWikiPhenomenon: row.sensorWikiPhenomenon, + sensorWikiUnit: row.sensorWikiUnit ?? undefined, + title: row.title, + unit: row.unit ?? undefined, + titleAliases: row.titleAliases, + unitAliases: row.unitAliases, + sensorTypeAliases: row.sensorTypeAliases, + })) + } catch (error) { + console.warn( + 'Sensor-Wiki aliases could not be loaded from the database.', + error, + ) + return [] + } +} diff --git a/app/db/schema/index.ts b/app/db/schema/index.ts index b7d498d9..f203c94c 100644 --- a/app/db/schema/index.ts +++ b/app/db/schema/index.ts @@ -15,3 +15,4 @@ export * from './claim' export * from './integration' export * from './tos' export * from './action-token' +export * from './sensor-wiki-alias' diff --git a/app/db/schema/sensor-wiki-alias.ts b/app/db/schema/sensor-wiki-alias.ts new file mode 100644 index 00000000..130fbf3c --- /dev/null +++ b/app/db/schema/sensor-wiki-alias.ts @@ -0,0 +1,37 @@ +import { createId } from '@paralleldrive/cuid2' +import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core' +import { type InferInsertModel, type InferSelectModel, sql } from 'drizzle-orm' + +export const sensorWikiAlias = pgTable( + 'sensor_wiki_alias', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + key: text('key').notNull(), + sensorWikiPhenomenon: text('sensor_wiki_phenomenon').notNull(), + sensorWikiUnit: text('sensor_wiki_unit'), + title: text('title').notNull(), + unit: text('unit'), + titleAliases: text('title_aliases') + .array() + .default(sql`ARRAY[]::text[]`) + .notNull(), + unitAliases: text('unit_aliases') + .array() + .default(sql`ARRAY[]::text[]`) + .notNull(), + sensorTypeAliases: text('sensor_type_aliases') + .array() + .default(sql`ARRAY[]::text[]`) + .notNull(), + }, + (table) => [ + uniqueIndex('sensor_wiki_alias_key_unique').on(table.key), + index('sensor_wiki_alias_phenomenon_idx').on(table.sensorWikiPhenomenon), + ], +) + +export type SensorWikiAlias = InferSelectModel +export type InsertSensorWikiAlias = InferInsertModel diff --git a/app/lib/device-schemas/sensor-wiki-aliases.ts b/app/lib/device-schemas/sensor-wiki-aliases.ts new file mode 100644 index 00000000..003e2d13 --- /dev/null +++ b/app/lib/device-schemas/sensor-wiki-aliases.ts @@ -0,0 +1,457 @@ +export type SensorWikiAliasInput = { + title?: string | null + unit?: string | null + sensorType?: string | null +} + +export type SensorWikiAliasMatch = { + sensorWikiPhenomenon: string + sensorWikiUnit?: string + confidence: 'high' | 'medium' + source: 'curated-alias' | 'database-alias' + reasons: string[] +} + +export type SensorWikiAliasSuggestion = SensorWikiAliasMatch & { + title: string + unit?: string + aliases: string[] +} + +export type SensorWikiAliasEntry = { + sensorWikiPhenomenon: string + sensorWikiUnit?: string + title: string + unit?: string + titleAliases: string[] + unitAliases?: string[] + sensorTypeAliases?: string[] +} + +export const sensorWikiAliasEntries: SensorWikiAliasEntry[] = [ + { + sensorWikiPhenomenon: 'temperature', + sensorWikiUnit: 'Cel', + title: 'Temperature', + unit: '°C', + titleAliases: [ + 'air temperature', + 'bodentemperatur', + 'bodentemperatur 10cm', + 'bodentemperatur 30cm', + 'bodentemperatur_1', + 'bodentemperatur_2', + 'lufttemperatur', + 'temperatur', + 'temperatur bme280', + 'temperatur dht22', + 'temperatur heca', + 'temperatur scd30', + 'temperatura', + 'temperature', + 'temperature bme280', + 'temperature dht22', + 'temperature heca', + 'temperature scd30', + 'temp', + ], + unitAliases: ['c', 'cel', 'celsius', 'degc', 'degree celsius', '°c'], + }, + { + sensorWikiPhenomenon: 'relative_humidity', + sensorWikiUnit: '%', + title: 'Relative humidity', + unit: '%', + titleAliases: [ + 'humidity', + 'luftfeuchte', + 'luftfeuchtigkeit', + 'rel luftfeuchte', + 'rel luftfeuchte bme280', + 'rel luftfeuchte dht22', + 'rel luftfeuchte heca', + 'rel luftfeuchte scd30', + 'rel. luftfeuchte', + 'rel. luftfeuchte bme280', + 'rel. luftfeuchte dht22', + 'rel. luftfeuchte heca', + 'rel. luftfeuchte scd30', + 'rel luftfeuchtigkeit', + 'rel. luftfeuchtigkeit', + 'relative humidity', + ], + unitAliases: ['%', '%rh', 'percent', 'rh'], + }, + { + sensorWikiPhenomenon: 'barometric_pressure', + sensorWikiUnit: 'hPa', + title: 'Barometric pressure', + unit: 'hPa', + titleAliases: [ + 'air pressure', + 'atm luftdruck', + 'atm. luftdruck', + 'barametric pressure', + 'barometric pressure', + 'luftdruck absolut', + 'luftdruck bme280', + 'luftdruck bmp', + 'luftdruck relativ', + 'luftdruck', + 'presion atmosferica', + 'presión atmosferica', + 'pressure', + ], + unitAliases: ['hpa', 'inhg', 'mbar'], + }, + { + sensorWikiPhenomenon: 'barometric_pressure', + sensorWikiUnit: 'Pa', + title: 'Barometric pressure', + unit: 'Pa', + titleAliases: [ + 'air pressure', + 'atm luftdruck', + 'atm. luftdruck', + 'barametric pressure', + 'barometric pressure', + 'luftdruck bme280', + 'luftdruck bmp', + 'luftdruck', + 'presion atmosferica', + 'presión atmosferica', + 'pressure', + ], + unitAliases: ['pa'], + }, + { + sensorWikiPhenomenon: 'pm10', + sensorWikiUnit: 'ug/m3', + title: 'PM10', + unit: 'µg/m³', + titleAliases: [ + 'feinstaub pm10', + 'particle 10', + 'particulate matter 10', + 'pm 10', + 'pm10', + ], + unitAliases: ['ug/m3', 'µg/m³', 'μg/m³'], + }, + { + sensorWikiPhenomenon: 'pm25', + sensorWikiUnit: 'ug/m3', + title: 'PM2.5', + unit: 'µg/m³', + titleAliases: [ + 'particle 2 5', + 'particle 2,5', + 'particulate matter 2 5', + 'particulate matter 2.5', + 'feinstaub pm2 5', + 'feinstaub pm2.5', + 'pm 2 5', + 'pm 2.5', + 'pm2 5', + 'pm2.5', + 'pm25', + ], + unitAliases: ['ug/m3', 'µg/m³', 'μg/m³'], + }, + { + sensorWikiPhenomenon: 'ambient_light', + sensorWikiUnit: 'lx', + title: 'Ambient light', + unit: 'lx', + titleAliases: [ + 'ambient light', + 'beleuchtungsstarke', + 'beleuchtungsstärke', + 'helligkeit', + 'illuminance', + 'light', + ], + unitAliases: ['lux', 'lx'], + }, + { + sensorWikiPhenomenon: 'ultraviolet_a_light', + sensorWikiUnit: 'uW/cm2', + title: 'UV intensity', + unit: 'µW/cm²', + titleAliases: ['uv intensitat', 'uv intensität', 'uv intensity'], + unitAliases: ['uw/cm2', 'uw/cm²', 'µw/cm2', 'µw/cm²', 'μw/cm2', 'μw/cm²'], + }, + { + sensorWikiPhenomenon: 'ultraviolet_a_light', + sensorWikiUnit: 'W/m²', + title: 'Ultraviolet A light', + unit: 'W/m²', + titleAliases: ['ultraviolet a light'], + unitAliases: ['w/m2', 'w/m²'], + }, + { + sensorWikiPhenomenon: 'soil_moisture', + sensorWikiUnit: '%', + title: 'Soil moisture', + unit: '%', + titleAliases: [ + 'bodenfeuchte', + 'bodenfeuchte 10cm', + 'bodenfeuchte 30cm', + 'bodenfeuchte 60cm', + 'bodenfeuchte_1', + 'bodenfeuchte_2', + 'soil moisture', + ], + unitAliases: ['%', 'percent'], + }, + { + sensorWikiPhenomenon: 'co2', + sensorWikiUnit: 'ppm', + title: 'CO2', + unit: 'ppm', + titleAliases: ['carbon dioxide', 'co2'], + unitAliases: ['parts per million', 'ppm'], + }, + { + sensorWikiPhenomenon: 'humidity', + title: 'Humidity', + titleAliases: ['humedad', 'humidity'], + }, + { + sensorWikiPhenomenon: 'pm10_concentration', + sensorWikiUnit: 'ug/m3', + title: 'PM10 concentration', + unit: 'µg/m³', + titleAliases: ['particulate matter 10 concentration', 'pm10 concentration'], + unitAliases: ['ug/m3', 'µg/m³', 'μg/m³'], + }, + { + sensorWikiPhenomenon: 'air_temperature', + sensorWikiUnit: '°C', + title: 'Air temperature', + unit: '°C', + titleAliases: ['air temperature'], + unitAliases: ['c', 'cel', 'celsius', 'degc', 'degree celsius', '°c'], + }, + { + sensorWikiPhenomenon: 'precipitation', + sensorWikiUnit: 'mm', + title: 'Precipitation', + unit: 'mm', + titleAliases: [ + 'niederschlag', + 'precipitation', + 'rain rate', + 'rainfall', + 'regen stunde', + 'regen tag', + 'regenrate', + ], + unitAliases: ['in/hr', 'millimeter', 'mm', 'mm/d', 'mm/h'], + }, + { + sensorWikiPhenomenon: 'volatile_organic_compound_voc', + title: 'Volatile organic compound (VOC)', + titleAliases: ['tvoc', 'voc', 'volatile organic compound voc'], + }, + { + sensorWikiPhenomenon: 'voltage', + sensorWikiUnit: 'V', + title: 'Voltage', + unit: 'V', + titleAliases: [ + 'batteriespannung', + 'battery voltage', + 'betriebsspannung', + 'power supply', + 'solar voltage', + 'spannung', + 'voltage', + ], + unitAliases: ['mv', 'millivolt', 'v', 'volt'], + }, + { + sensorWikiPhenomenon: 'sound_level', + title: 'Sound level', + titleAliases: [ + 'durchschnitt umgebungslautstarke', + 'durchschnitt umgebungslautstärke', + 'lautstarke', + 'lautstärke', + 'minimum umgebungslautstarke', + 'minimum umgebungslautstärke', + 'noise', + 'sound level', + 'soundpresure dba', + 'soundpressure dba', + ], + unitAliases: ['db', 'dba', 'dbavg', 'dbmin'], + }, + { + sensorWikiPhenomenon: 'water_level', + title: 'Water level', + titleAliases: ['pegel', 'water level'], + }, + { + sensorWikiPhenomenon: 'water_temperature', + title: 'Water temperature', + titleAliases: ['water temperature'], + }, + { + sensorWikiPhenomenon: 'wind_direction', + title: 'Wind direction', + titleAliases: ['wind direction', 'windrichtung'], + }, + { + sensorWikiPhenomenon: 'wind_speed', + sensorWikiUnit: 'm/s', + title: 'Wind speed', + unit: 'm/s', + titleAliases: [ + 'viento', + 'wind boen', + 'wind böen', + 'wind gust speed', + 'wind speed', + 'windboen', + 'windböen', + 'windgeschwindigkeit', + 'windstarke', + 'windstärke', + ], + unitAliases: ['m/s', 'meter per second', 'miles per hour', 'mph'], + }, +] + +export function createSensorWikiAliasKey(entry: { + sensorWikiPhenomenon: string + sensorWikiUnit?: string | null +}) { + return `${entry.sensorWikiPhenomenon}:${entry.sensorWikiUnit ?? ''}` +} + +export function normalizeSensorWikiAliasValue(value?: string | null) { + return (value ?? '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/µ/g, 'u') + .replace(/μ/g, 'u') + .replace(/³/g, '3') + .replace(/²/g, '2') + .replace(/°/g, '') + .replace(/[^a-z0-9%/]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function matchSensorWikiAlias( + input: SensorWikiAliasInput, + entries: SensorWikiAliasEntry[] = [], +): SensorWikiAliasMatch | undefined { + const normalizedTitle = normalizeSensorWikiAliasValue(input.title) + const normalizedUnit = normalizeSensorWikiAliasValue(input.unit) + const normalizedSensorType = normalizeSensorWikiAliasValue(input.sensorType) + + for (const entry of entries) { + const titleMatched = [entry.title, ...entry.titleAliases].some( + (alias) => normalizeSensorWikiAliasValue(alias) === normalizedTitle, + ) + const unitMatched = + !!normalizedUnit && + entry.unitAliases?.some( + (alias) => normalizeSensorWikiAliasValue(alias) === normalizedUnit, + ) + const sensorTypeMatched = + !!normalizedSensorType && + entry.sensorTypeAliases?.some( + (alias) => + normalizeSensorWikiAliasValue(alias) === normalizedSensorType, + ) + + if (!titleMatched) continue + + const reasons = ['title-alias'] + if (unitMatched) reasons.push('unit-alias') + if (sensorTypeMatched) reasons.push('sensor-type-alias') + + return { + sensorWikiPhenomenon: entry.sensorWikiPhenomenon, + sensorWikiUnit: unitMatched ? entry.sensorWikiUnit : undefined, + confidence: unitMatched || sensorTypeMatched ? 'high' : 'medium', + source: + entries === sensorWikiAliasEntries ? 'curated-alias' : 'database-alias', + reasons, + } + } + + return undefined +} + +export function getSensorWikiAliasSuggestions( + input: SensorWikiAliasInput, + limit = 5, + entries: SensorWikiAliasEntry[] = [], +): SensorWikiAliasSuggestion[] { + const normalizedTitle = normalizeSensorWikiAliasValue(input.title) + const normalizedUnit = normalizeSensorWikiAliasValue(input.unit) + const normalizedSensorType = normalizeSensorWikiAliasValue(input.sensorType) + + if (normalizedTitle.length < 2) return [] + + return entries + .map((entry) => { + const normalizedAliases = [entry.title, ...entry.titleAliases].map( + (alias) => normalizeSensorWikiAliasValue(alias), + ) + const titleMatches = normalizedAliases.some((alias) => + alias.includes(normalizedTitle), + ) + const titleStartsWithQuery = normalizedAliases.some((alias) => + alias.startsWith(normalizedTitle), + ) + const unitMatched = + !!normalizedUnit && + entry.unitAliases?.some( + (alias) => normalizeSensorWikiAliasValue(alias) === normalizedUnit, + ) + const sensorTypeMatched = + !!normalizedSensorType && + entry.sensorTypeAliases?.some( + (alias) => + normalizeSensorWikiAliasValue(alias) === normalizedSensorType, + ) + + if (!titleMatches) return null + + const reasons = ['title-alias'] + if (unitMatched) reasons.push('unit-alias') + if (sensorTypeMatched) reasons.push('sensor-type-alias') + + return { + suggestion: { + title: entry.title, + unit: entry.unit, + sensorWikiPhenomenon: entry.sensorWikiPhenomenon, + sensorWikiUnit: entry.sensorWikiUnit, + confidence: unitMatched || sensorTypeMatched ? 'high' : 'medium', + source: + entries === sensorWikiAliasEntries + ? 'curated-alias' + : 'database-alias', + reasons, + aliases: entry.titleAliases, + } satisfies SensorWikiAliasSuggestion, + score: + (titleStartsWithQuery ? 2 : 1) + + (unitMatched ? 2 : 0) + + (sensorTypeMatched ? 1 : 0), + } + }) + .filter((result): result is NonNullable => result !== null) + .sort((a, b) => b.score - a.score) + .map(({ suggestion }) => suggestion) + .slice(0, limit) +} diff --git a/app/lib/phenomenon-filter.ts b/app/lib/phenomenon-filter.ts new file mode 100644 index 00000000..c6bc334b --- /dev/null +++ b/app/lib/phenomenon-filter.ts @@ -0,0 +1,250 @@ +import { + matchSensorWikiAlias, + normalizeSensorWikiAliasValue, + type SensorWikiAliasInput, + type SensorWikiAliasEntry, +} from '~/lib/device-schemas/sensor-wiki-aliases' + +const SENSOR_WIKI_FILTER_PREFIX = 'sensor-wiki:' +const TITLE_FILTER_PREFIX = 'title:' +export const PHENOMENON_FUZZY_MATCH_THRESHOLD = 0.82 + +export type PhenomenonFilterOption = { + value: string + label: string + description?: string + source: 'sensor-wiki' | 'title' + aliases: string[] +} + +export function getSensorWikiPhenomenonFilterValue(phenomenon: string) { + return `${SENSOR_WIKI_FILTER_PREFIX}${phenomenon}` +} + +export function getTitlePhenomenonFilterValue(title: string) { + return `${TITLE_FILTER_PREFIX}${encodeURIComponent(title)}` +} + +export function parsePhenomenonFilterValue(value: string): + | { + source: 'sensor-wiki' + phenomenon: string + } + | { + source: 'title' + title: string + } { + if (value.startsWith(SENSOR_WIKI_FILTER_PREFIX)) { + return { + source: 'sensor-wiki', + phenomenon: value.slice(SENSOR_WIKI_FILTER_PREFIX.length), + } + } + + if (value.startsWith(TITLE_FILTER_PREFIX)) { + return { + source: 'title', + title: decodeURIComponent(value.slice(TITLE_FILTER_PREFIX.length)), + } + } + + return { + source: 'title', + title: value, + } +} + +export function getSensorWikiPhenomenonLabel( + phenomenon: string, + entries: SensorWikiAliasEntry[] = [], +) { + return ( + entries.find((entry) => entry.sensorWikiPhenomenon === phenomenon)?.title ?? + phenomenon + ) +} + +export function getSensorWikiPhenomenonAliases( + phenomenon: string, + entries: SensorWikiAliasEntry[] = [], +) { + return entries + .filter((entry) => entry.sensorWikiPhenomenon === phenomenon) + .flatMap((entry) => [entry.title, ...entry.titleAliases]) +} + +export function getCanonicalSensorWikiPhenomenon( + input: SensorWikiAliasInput & { sensorWikiPhenomenon?: string | null }, + entries: SensorWikiAliasEntry[] = [], +) { + const existingCanonicalPhenomenon = entries.find( + (entry) => entry.sensorWikiPhenomenon === input.sensorWikiPhenomenon, + )?.sensorWikiPhenomenon + + return ( + existingCanonicalPhenomenon ?? + input.sensorWikiPhenomenon ?? + matchSensorWikiAlias(input, entries)?.sensorWikiPhenomenon + ) +} + +function tokenize(value: string) { + return value.split(' ').filter(Boolean) +} + +function levenshteinDistance(left: string, right: string) { + const rows = left.length + 1 + const columns = right.length + 1 + const distances = Array.from({ length: rows }, () => + Array.from({ length: columns }, () => 0), + ) + + for (let row = 0; row < rows; row += 1) distances[row][0] = row + for (let column = 0; column < columns; column += 1) { + distances[0][column] = column + } + + for (let row = 1; row < rows; row += 1) { + for (let column = 1; column < columns; column += 1) { + const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1 + + distances[row][column] = Math.min( + distances[row - 1][column] + 1, + distances[row][column - 1] + 1, + distances[row - 1][column - 1] + substitutionCost, + ) + } + } + + return distances[left.length][right.length] +} + +function levenshteinSimilarity(left: string, right: string) { + const longestLength = Math.max(left.length, right.length) + if (longestLength === 0) return 1 + + return 1 - levenshteinDistance(left, right) / longestLength +} + +function tokenDiceSimilarity(left: string, right: string) { + const leftTokens = tokenize(left) + const rightTokens = tokenize(right) + if (leftTokens.length === 0 || rightTokens.length === 0) return 0 + + const rightTokenSet = new Set(rightTokens) + const intersection = leftTokens.filter((token) => rightTokenSet.has(token)) + + return (2 * intersection.length) / (leftTokens.length + rightTokens.length) +} + +function titleSimilarity(left: string, right: string) { + if (!left || !right) return 0 + if (left === right) return 1 + if (left.includes(right) || right.includes(left)) return 0.92 + + return Math.max( + levenshteinSimilarity(left, right), + tokenDiceSimilarity(left, right), + ) +} + +function aliasIncludesValue(aliases: string[] | undefined, value: string) { + if (!value) return false + + return !!aliases?.some( + (alias) => normalizeSensorWikiAliasValue(alias) === value, + ) +} + +export function getFuzzySensorWikiPhenomenonMatch( + input: SensorWikiAliasInput, + threshold = PHENOMENON_FUZZY_MATCH_THRESHOLD, + entries: SensorWikiAliasEntry[] = [], +) { + const normalizedTitle = normalizeSensorWikiAliasValue(input.title) + const normalizedUnit = normalizeSensorWikiAliasValue(input.unit) + const normalizedSensorType = normalizeSensorWikiAliasValue(input.sensorType) + + if (normalizedTitle.length < 3) return undefined + + const bestMatch = entries + .map((entry) => { + const titleScore = [entry.title, ...entry.titleAliases].reduce( + (bestScore, alias) => + Math.max( + bestScore, + titleSimilarity( + normalizedTitle, + normalizeSensorWikiAliasValue(alias), + ), + ), + 0, + ) + const unitMatched = aliasIncludesValue(entry.unitAliases, normalizedUnit) + const sensorTypeMatched = aliasIncludesValue( + entry.sensorTypeAliases, + normalizedSensorType, + ) + const score = Math.max( + 0, + Math.min( + 1, + titleScore * 0.78 + + (unitMatched ? 0.17 : normalizedUnit ? -0.04 : 0) + + (sensorTypeMatched ? 0.05 : 0), + ), + ) + + return { + sensorWikiPhenomenon: entry.sensorWikiPhenomenon, + score, + } + }) + .sort((left, right) => right.score - left.score)[0] + + if (!bestMatch || bestMatch.score < threshold) return undefined + + return bestMatch +} + +export function sensorMatchesPhenomenonFilter( + sensor: SensorWikiAliasInput & { sensorWikiPhenomenon?: string | null }, + filterValue: string, + entries: SensorWikiAliasEntry[] = [], +) { + const parsedFilter = parsePhenomenonFilterValue(filterValue) + + if (parsedFilter.source === 'title') { + return ( + normalizeSensorWikiAliasValue(sensor.title) === + normalizeSensorWikiAliasValue(parsedFilter.title) + ) + } + + const canonicalPhenomenon = getCanonicalSensorWikiPhenomenon(sensor, entries) + if (canonicalPhenomenon === parsedFilter.phenomenon) return true + + const fuzzyPhenomenon = getFuzzySensorWikiPhenomenonMatch( + sensor, + PHENOMENON_FUZZY_MATCH_THRESHOLD, + entries, + ) + if (fuzzyPhenomenon?.sensorWikiPhenomenon === parsedFilter.phenomenon) { + return true + } + + const normalizedTitle = normalizeSensorWikiAliasValue(sensor.title) + return getSensorWikiPhenomenonAliases(parsedFilter.phenomenon, entries).some( + (alias) => normalizeSensorWikiAliasValue(alias) === normalizedTitle, + ) +} + +export function sensorMatchesAnyPhenomenonFilter( + sensor: SensorWikiAliasInput & { sensorWikiPhenomenon?: string | null }, + filterValues: string[], + entries: SensorWikiAliasEntry[] = [], +) { + return filterValues.some((filterValue) => + sensorMatchesPhenomenonFilter(sensor, filterValue, entries), + ) +} diff --git a/app/routes/admin._index.tsx b/app/routes/admin._index.tsx index 20154bf2..92d02e35 100644 --- a/app/routes/admin._index.tsx +++ b/app/routes/admin._index.tsx @@ -12,6 +12,9 @@ export default function AdminIndexRoute() {
Edit devices
+
+ Edit Sensor-Wiki aliases +
) diff --git a/app/routes/admin.sensor-wiki-aliases.tsx b/app/routes/admin.sensor-wiki-aliases.tsx new file mode 100644 index 00000000..108304b3 --- /dev/null +++ b/app/routes/admin.sensor-wiki-aliases.tsx @@ -0,0 +1,418 @@ +import { Form, Link, redirect, useActionData } from 'react-router' +import invariant from 'tiny-invariant' +import { type Route } from './+types/admin.sensor-wiki-aliases' +import { Button } from '~/components/ui/button' +import { Input } from '~/components/ui/input' +import { Label } from '~/components/ui/label' +import { Textarea } from '~/components/ui/textarea' +import { + createSensorWikiAlias, + deleteSensorWikiAlias, + getSensorWikiAliasesForAdmin, + seedMissingSensorWikiAliasesFromBundledEntries, + updateSensorWikiAlias, +} from '~/db/models/sensor-wiki-alias.server' +import { createSensorWikiAliasKey } from '~/lib/device-schemas/sensor-wiki-aliases' + +type ActionData = { + error?: boolean + message?: string + fieldErrors?: { + sensorWikiPhenomenon?: string + title?: string + } +} + +export async function loader({}: Route.LoaderArgs) { + const aliases = await getSensorWikiAliasesForAdmin() + return { aliases } +} + +export async function action({ + request, +}: Route.ActionArgs): Promise { + const formData = await request.formData() + const intent = getString(formData, '_action') + + switch (intent) { + case 'seed': { + const insertedCount = + await seedMissingSensorWikiAliasesFromBundledEntries() + return { + error: false, + message: `Seeded ${insertedCount} missing aliases from the bundled table.`, + } + } + + case 'create': { + const parsed = parseAliasFormData(formData) + if (parsed.error) return parsed.error + + try { + await createSensorWikiAlias(parsed.value) + return redirect('/admin/sensor-wiki-aliases') + } catch (error) { + return { + error: true, + message: + error instanceof Error ? error.message : 'Failed to create alias.', + } + } + } + + case 'update': { + const id = getString(formData, 'id') + invariant(id, 'Expected alias id') + const parsed = parseAliasFormData(formData) + if (parsed.error) return parsed.error + + try { + await updateSensorWikiAlias(id, parsed.value) + return redirect('/admin/sensor-wiki-aliases') + } catch (error) { + return { + error: true, + message: + error instanceof Error ? error.message : 'Failed to update alias.', + } + } + } + + case 'delete': { + const id = getString(formData, 'id') + invariant(id, 'Expected alias id') + + try { + await deleteSensorWikiAlias(id) + return redirect('/admin/sensor-wiki-aliases') + } catch (error) { + return { + error: true, + message: + error instanceof Error ? error.message : 'Failed to delete alias.', + } + } + } + + default: + return { + error: true, + message: 'Unknown action.', + } + } +} + +export default function AdminSensorWikiAliasesRoute({ + loaderData, +}: Route.ComponentProps) { + const { aliases } = loaderData + const actionData = useActionData() + + return ( +
+
+ + Back to admin + +
+
+

Sensor-Wiki aliases

+

+ Edit the database-backed alias table. If this table is empty, the + app falls back to the bundled aliases in code. +

+
+
+ +
+
+
+ + {actionData?.message ? ( +

+ {actionData.message} +

+ ) : null} + +
+

Create alias entry

+ +
+ +
+
+

+ Alias entries ({aliases.length}) +

+
+ + {aliases.length === 0 ? ( +

+ No database aliases exist yet. Use the seed action above to copy the + bundled alias table into the database. +

+ ) : ( +
+ {aliases.map((alias) => ( +
+
+
+

{alias.title}

+

+ {alias.sensorWikiPhenomenon} + {alias.sensorWikiUnit ? ` / ${alias.sensorWikiUnit}` : ''} +

+
+
+ + +
+
+ +
+ ))} +
+ )} +
+
+ ) +} + +function AliasForm({ + action, + alias, + fieldErrors, +}: { + action: 'create' | 'update' + alias?: { + id: string + sensorWikiPhenomenon: string + sensorWikiUnit: string + title: string + unit: string + titleAliases: string[] + unitAliases: string[] + sensorTypeAliases: string[] + } + fieldErrors?: ActionData['fieldErrors'] +}) { + return ( +
+ {alias ? : null} +
+
+ + + {fieldErrors?.sensorWikiPhenomenon ? ( +

+ {fieldErrors.sensorWikiPhenomenon} +

+ ) : null} +
+
+ + +
+
+ + + {fieldErrors?.title ? ( +

{fieldErrors.title}

+ ) : null} +
+
+ + +
+
+ +
+ + + +
+ + +
+ ) +} + +function ArrayTextarea({ + id, + name, + label, + values, +}: { + id: string + name: string + label: string + values: string[] +}) { + return ( +
+ +