diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 126ec4ad2..e58c62e6a 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -26,5 +26,6 @@ export const widgetRouter = createTRPCRouter({ networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)), firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)), notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)), + timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts new file mode 100644 index 000000000..787be4c37 --- /dev/null +++ b/packages/api/src/router/widgets/timetable.ts @@ -0,0 +1,43 @@ +import z from "zod"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { timetableOptionsSchema } from "@homarr/integrations/types"; +import { + timetableGetTimetableRequestHandler, + timetableSearchStationsRequestHandler, +} from "@homarr/request-handler/timetable"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const timetableRouter = createTRPCRouter({ + getTimetable: publicProcedure + .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable"))) + .input(timetableOptionsSchema) + .query(async ({ input, ctx }) => { + const innerHandler = timetableGetTimetableRequestHandler.handler(ctx.integration, { + stationId: input.stationId, + limit: input.limit, + }); + + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return data; + }), + searchStations: publicProcedure + .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable"))) + .input( + z.object({ + query: z.string(), + }), + ) + .query(async ({ input, ctx }) => { + const innerHandler = timetableSearchStationsRequestHandler.handler(ctx.integration, { + query: input.query, + }); + + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return data; + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 5ab802124..7f2b2349f 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; import { networkControllerJob } from "./jobs/integrations/network-controller"; import { refreshNotificationsJob } from "./jobs/integrations/notifications"; +import { timetableJob } from "./jobs/integrations/timetable"; import { tracearrJob } from "./jobs/integrations/tracearr"; import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; @@ -51,6 +52,7 @@ export const jobGroup = createCronJobGroup({ firewallInterfaces: firewallInterfacesJob, refreshNotifications: refreshNotificationsJob, weather: weatherJob, + timetable: timetableJob, tracearr: tracearrJob, }); diff --git a/packages/cron-jobs/src/jobs/integrations/timetable.ts b/packages/cron-jobs/src/jobs/integrations/timetable.ts new file mode 100644 index 000000000..b3964dc38 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/timetable.ts @@ -0,0 +1,20 @@ +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable"; + +import { createCronJob } from "../../lib"; + +export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback( + createRequestIntegrationJobHandler(timetableGetTimetableRequestHandler.handler, { + widgetKinds: ["timetable"], + getInput: { + timetable: (options) => + options.station + ? { + stationId: options.station.value, + limit: 10, + } + : [], + }, + }), +); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 06f6aa73f..317fd1f7a 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -334,6 +334,15 @@ export const integrationDefs = { // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), }, + searchCh: { + name: "Search.ch", + secretKinds: [[]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", + defaultUrl: "https://search.ch", + category: ["timetable"], + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/search-ch"), + }, immich: { name: "Immich", secretKinds: [["apiKey"]], @@ -434,6 +443,7 @@ export const integrationCategories = [ "releasesProvider", "notifications", "firewall", + "timetable", "photoService", "mediaMonitoring", ] as const; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index cf94bd3fa..732b3f1bc 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -31,6 +31,7 @@ export const widgetKinds = [ "systemResources", "coolify", "systemDisks", + "timetable", "immich-serverStats", "immich-albumCarousel", "tracearr", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index b35fa5f63..c0743b5d4 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -40,6 +40,7 @@ import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import { QuayIntegration } from "../quay/quay-integration"; +import { SearchChIntegration } from "../search-ch/search-ch-integration"; import { SeerrIntegration } from "../seerr/seerr-integration"; import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; @@ -114,6 +115,7 @@ export const integrationCreators = { coolify: CoolifyIntegration, tracearr: TracearrIntegration, glances: GlancesIntegration, + searchCh: SearchChIntegration, immich: ImmichIntegration, } satisfies Record Promise]>; diff --git a/packages/integrations/src/interfaces/timetable/timetable-integration.ts b/packages/integrations/src/interfaces/timetable/timetable-integration.ts new file mode 100644 index 000000000..74e9d3c02 --- /dev/null +++ b/packages/integrations/src/interfaces/timetable/timetable-integration.ts @@ -0,0 +1,6 @@ +import type { Station, Timetable, TimetableOptions } from "./timetable-types"; + +export interface TimetableIntegration { + searchStationsAsync(query: string): Promise; + getTimetableAsync(options: TimetableOptions): Promise; +} diff --git a/packages/integrations/src/interfaces/timetable/timetable-types.ts b/packages/integrations/src/interfaces/timetable/timetable-types.ts new file mode 100644 index 000000000..72350b5a3 --- /dev/null +++ b/packages/integrations/src/interfaces/timetable/timetable-types.ts @@ -0,0 +1,33 @@ +import z from "zod"; + +export interface Station { + id: string; + name: string; +} + +export const timetableOptionsSchema = z.object({ + stationId: z.string(), + limit: z.number().default(10), +}); + +export type TimetableOptions = z.infer; + +export interface TimetableEntry { + timestamp: Date; + delay: number; + line: { + name: string; + color: string | null; + } | null; + location: string; + platform: { + name: string; + hasChanged: boolean; + } | null; +} + +export interface Timetable { + stationId: string; + timestamp: Date; + entries: TimetableEntry[]; +} diff --git a/packages/integrations/src/search-ch/search-ch-integration.ts b/packages/integrations/src/search-ch/search-ch-integration.ts new file mode 100644 index 000000000..cbaf8e80d --- /dev/null +++ b/packages/integrations/src/search-ch/search-ch-integration.ts @@ -0,0 +1,141 @@ +import z from "zod"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { TimetableIntegration } from "../interfaces/timetable/timetable-integration"; +import type { Station, Timetable, TimetableOptions } from "../interfaces/timetable/timetable-types"; + +const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; + +export class SearchChIntegration extends Integration implements TimetableIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/timetable/api/completion.json", { term: "Zürich" })); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + /** + * Searches for stations matching the given query. + * @param query The search query for station names. + * @returns A list of stations matching the search query. + * @docs https://search.ch/fahrplan/api/help + */ + public async searchStationsAsync(query: string): Promise { + const response = await fetchWithTrustedCertificatesAsync( + this.url("/timetable/api/completion.json", { + term: query, + show_ids: 1, + nofavorites: 1, + }), + ); + + if (!response.ok) { + throw new ResponseError(response); + } + + const data = await searchSchema.parseAsync(await response.json()); + + return ( + data + // Remove completions like addresses, we only want stations. + .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) + .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) + .filter((item) => item !== null) + ); + } + + /** + * Gets the departure timetable for a given station. + * @param options Timetable options including station ID and limit. + * @returns timetable for the given station. + * @docs https://search.ch/fahrplan/api/help + */ + public async getTimetableAsync(options: TimetableOptions): Promise { + const now = new Date(); + // The following format is used [01.01.2000, 23:59:00]. The API allows usage of DD.MM.YYYY format and HH:mm format + const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string]; + + const response = await fetchWithTrustedCertificatesAsync( + this.url("/timetable/api/stationboard.json", { + stop: options.stationId, + limit: options.limit, + show_delays: 1, + show_tracks: 1, + date, + time: time.substring(0, 5), // Only keep the HH:mm part of the time, as that's what the API expects + }), + ); + + if (!response.ok) { + throw new ResponseError(response); + } + + const data = await timetableSchema.parseAsync(await response.json()); + + return { + stationId: options.stationId, + timestamp: now, + entries: data.connections.map((connection) => { + // Color is in format A35~F10~ for foreground and background color. Some only have ~~ and therefore no color + const color = connection.color.split("~")[0]; + + return { + timestamp: connection.time, + line: connection.line + ? { + name: connection.line, + color: color && color.length >= 1 ? `#${color}` : null, + } + : null, + location: connection.terminal.name, + delay: connection.dep_delay, + platform: connection.track + ? { + // If the track name contains "!", it indicates a change in the track + // so we remove the "!" from the name and set hasChanged to true + name: connection.track.replace("!", ""), + hasChanged: connection.track.includes("!"), + } + : null, + }; + }), + }; + } +} + +const timetableSchema = z.object({ + connections: z.array( + z.object({ + time: z.string().transform((str) => new Date(str)), + line: z.string().optional(), + color: z.string(), + terminal: z.object({ + name: z.string(), + }), + track: z.string().optional(), + dep_delay: z + .string() + .optional() + .transform((str) => (str ? parseInt(str) : 0)), + }), + ), +}); + +const searchSchema = z.array( + z.object({ + id: z.string().optional(), + label: z.string(), + iconclass: z.string(), // This is in format sl-icon-type-{type} + }), +); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index debdd041b..7c7bc4780 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -12,4 +12,5 @@ export * from "./unifi-controller/unifi-controller-types"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; +export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts new file mode 100644 index 000000000..0946ac67d --- /dev/null +++ b/packages/request-handler/src/timetable.ts @@ -0,0 +1,36 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { Station, Timetable } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const timetableSearchStationsRequestHandler = createCachedIntegrationRequestHandler< + Station[], + IntegrationKindByCategory<"timetable">, + { query: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.searchStationsAsync(input.query); + }, + queryKey: "timetableSearchStations", + cacheDuration: dayjs.duration(1, "day"), +}); + +export const timetableGetTimetableRequestHandler = createCachedIntegrationRequestHandler< + Timetable, + IntegrationKindByCategory<"timetable">, + { stationId: string; limit: number } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getTimetableAsync({ + stationId: input.stationId, + limit: input.limit, + }); + }, + queryKey: "timetableGetTimetable", + cacheDuration: dayjs.duration(1, "minute"), +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 75812b45e..a069f9c4e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1525,6 +1525,17 @@ "offline": "Offline" } }, + "timetable": { + "name": "Timetable", + "description": "Displays departure times for a station", + "title": "Departures {station}", + "noStation": "No station selected", + "option": { + "station": { + "label": "Station" + } + } + }, "notebook": { "name": "Notebook", "description": "A simple notebook widget that supports markdown", @@ -3513,6 +3524,9 @@ "minecraftServerStatus": { "label": "Minecraft server status" }, + "timetable": { + "label": "Timetable" + }, "iconsUpdater": { "label": "Icons Updater" }, diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index b19ab8c9c..7018e8dc2 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -1,5 +1,6 @@ import type { WidgetOptionType } from "../options"; import { WidgetAppInput } from "./widget-app-input"; +import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input"; import { WidgetLocationInput } from "./widget-location-input"; import { WidgetMultiTextInput } from "./widget-multi-text-input"; import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input"; @@ -23,6 +24,7 @@ const mapping = { app: WidgetAppInput, sortableItemList: WidgetSortedItemListInput, multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput, + dynamicSelect: WidgetDynamicSelectInput, } satisfies Record; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx new file mode 100644 index 000000000..39b70b325 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { Group, Loader, Select } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck } from "@tabler/icons-react"; + +import { translateIfNecessary } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; + +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export interface DynamicSelectOption { + value: string; + label: string; +} + +export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => { + const t = useI18n(); + const tWidget = useWidgetInputTranslation(kind, property); + const form = useFormContext(); + const inputProps = form.getInputProps(`options.${property}`); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const { isPending, options: selectOptions } = options.useOptions(debouncedSearch, form.values.integrationIds); + const value = inputProps.value as DynamicSelectOption | null; + const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void; + + return ( +