diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 86db1c66c..7b3a8d657 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -4,6 +4,7 @@ import { Inter } from "next/font/google"; import "@homarr/ui/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; +import "~/styles/scroll-area.scss"; import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; diff --git a/apps/nextjs/src/styles/scroll-area.scss b/apps/nextjs/src/styles/scroll-area.scss new file mode 100644 index 000000000..1c9001f58 --- /dev/null +++ b/apps/nextjs/src/styles/scroll-area.scss @@ -0,0 +1,4 @@ +.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) { + width: 100%; + display: inherit !important; +} diff --git a/packages/api/src/middlewares/item.ts b/packages/api/src/middlewares/item.ts new file mode 100644 index 000000000..78cc623db --- /dev/null +++ b/packages/api/src/middlewares/item.ts @@ -0,0 +1,29 @@ +import { TRPCError } from "@trpc/server"; + +import { and, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; +import type { WidgetKind } from "@homarr/definitions"; +import { z } from "@homarr/validation"; + +import { publicProcedure } from "../trpc"; + +export const createOneItemMiddleware = (kind: WidgetKind) => { + return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => { + const item = await ctx.db.query.items.findFirst({ + where: and(eq(items.id, input.itemId), eq(items.kind, kind)), + }); + + if (!item) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Item with id ${input.itemId} not found`, + }); + } + + return next({ + ctx: { + item, + }, + }); + }); +}; diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index b918d32a8..23cdede73 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -4,6 +4,7 @@ import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; import { mediaServerRouter } from "./media-server"; import { notebookRouter } from "./notebook"; +import { rssFeedRouter } from "./rssFeed"; import { smartHomeRouter } from "./smart-home"; import { weatherRouter } from "./weather"; @@ -15,4 +16,5 @@ export const widgetRouter = createTRPCRouter({ smartHome: smartHomeRouter, mediaServer: mediaServerRouter, calendar: calendarRouter, + rssFeed: rssFeedRouter, }); diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts new file mode 100644 index 000000000..e17cb9815 --- /dev/null +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -0,0 +1,12 @@ +import type { RssFeed } from "@homarr/cron-jobs"; +import { createItemChannel } from "@homarr/redis"; + +import { createOneItemMiddleware } from "../../middlewares/item"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const rssFeedRouter = createTRPCRouter({ + getFeeds: publicProcedure.unstable_concat(createOneItemMiddleware("rssFeed")).query(async ({ input }) => { + const channel = createItemChannel(input.itemId); + return await channel.getAsync(); + }), +}); diff --git a/packages/cron-jobs-core/src/creator.ts b/packages/cron-jobs-core/src/creator.ts index d6e367b5b..7418ff158 100644 --- a/packages/cron-jobs-core/src/creator.ts +++ b/packages/cron-jobs-core/src/creator.ts @@ -38,7 +38,8 @@ const createCallback = [number]; +export type { RssFeed }; diff --git a/packages/cron-jobs/src/jobs/rss-feeds.ts b/packages/cron-jobs/src/jobs/rss-feeds.ts new file mode 100644 index 000000000..510eeef38 --- /dev/null +++ b/packages/cron-jobs/src/jobs/rss-feeds.ts @@ -0,0 +1,135 @@ +import type { FeedData, FeedEntry } from "@extractus/feed-extractor"; +import { extract } from "@extractus/feed-extractor"; +import SuperJSON from "superjson"; + +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; +import { logger } from "@homarr/log"; +import { createItemChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; + +// This import is done that way to avoid circular dependencies. +import type { WidgetComponentProps } from "../../../widgets"; +import { createCronJob } from "../lib"; + +export const rssFeedsJob = createCronJob("rssFeeds", EVERY_5_MINUTES).withCallback(async () => { + const itemsForIntegration = await db.query.items.findMany({ + where: eq(items.kind, "rssFeed"), + }); + + for (const item of itemsForIntegration) { + const options = SuperJSON.parse["options"]>(item.options); + + const feeds = await Promise.all( + options.feedUrls.map(async (feedUrl) => ({ + feedUrl, + feed: (await extract(feedUrl, { + getExtraEntryFields: (feedEntry) => { + const media = attemptGetImageFromEntry(feedUrl, feedEntry); + if (!media) { + return {}; + } + return { + enclosure: media, + }; + }, + })) as ExtendedFeedData, + })), + ); + + const channel = createItemChannel(item.id); + await channel.publishAndUpdateLastStateAsync(feeds); + } +}); + +const attemptGetImageFromEntry = (feedUrl: string, entry: object) => { + const media = getFirstMediaProperty(entry); + if (media !== null) { + return media; + } + return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry)); +}; + +const getImageFromStringAsFallback = (feedUrl: string, content: string) => { + const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i; + const result = regex.exec(content); + + if (result == null) { + return null; + } + + console.debug( + `Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`, + ); + return result[0]; +}; + +const mediaProperties = [ + { + path: ["enclosure", "@_url"], + }, + { + path: ["media:content", "@_url"], + }, +]; + +/** + * The RSS and Atom standards are poorly adhered to in most of the web. + * We want to show pretty background images on the posts and therefore need to extract + * the enclosure (aka. media images). This function uses the dynamic properties defined above + * to search through the possible paths and detect valid image URLs. + * @param feedObject The object to scan for. + * @returns the value of the first path that is found within the object + */ +const getFirstMediaProperty = (feedObject: object) => { + for (const mediaProperty of mediaProperties) { + let propertyIndex = 0; + let objectAtPath: object = feedObject; + while (propertyIndex < mediaProperty.path.length) { + const key = mediaProperty.path[propertyIndex]; + if (key === undefined) { + break; + } + const propertyEntries = Object.entries(objectAtPath); + const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key); + if (!propertyEntry) { + break; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [_, propertyEntryValue] = propertyEntry; + objectAtPath = propertyEntryValue as object; + propertyIndex++; + } + + const validationResult = z.string().url().safeParse(objectAtPath); + if (!validationResult.success) { + continue; + } + + logger.debug(`Found an image in the feed entry: ${validationResult.data}`); + return validationResult.data; + } + return null; +}; + +/** + * We extend the feed with custom properties. + * This interface adds properties on top of the default ones. + */ +interface ExtendedFeedEntry extends FeedEntry { + enclosure?: string; +} + +/** + * We extend the feed with custom properties. + * This interface omits the default entries with our custom definition. + */ +interface ExtendedFeedData extends Omit { + entries?: ExtendedFeedEntry; +} + +export interface RssFeed { + feedUrl: string; + feed: ExtendedFeedData; +} diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index a3f154c71..9b50fa9dc 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -10,5 +10,6 @@ export const widgetKinds = [ "smartHome-executeAutomation", "mediaServer", "calendar", + "rssFeed", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index f9c1480d3..61c3e42b9 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,6 +1,6 @@ import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel"; -export { createCacheChannel, createItemAndIntegrationChannel } from "./lib/channel"; +export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel } from "./lib/channel"; export const exampleChannel = createSubPubChannel<{ message: string }>("example"); export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>( diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 6279be9f1..0fd1b45f4 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -163,6 +163,14 @@ export const createCacheChannel = (name: string, cacheDurationMs: number export const createItemAndIntegrationChannel = (kind: WidgetKind, integrationId: string) => { const channelName = `item:${kind}:integration:${integrationId}`; + return createChannelWithLatestAndEvents(channelName); +}; + +export const createItemChannel = (itemId: string) => { + return createChannelWithLatestAndEvents(`item:${itemId}`); +}; + +const createChannelWithLatestAndEvents = (channelName: string) => { return { subscribe: (callback: (data: TData) => void) => { return ChannelSubscriptionTracker.subscribe(channelName, (message) => { diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index f33aca419..7f66bf580 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1033,6 +1033,21 @@ export default { description: "Show the current streams on your media servers", option: {}, }, + rssFeed: { + name: "RSS feeds", + description: "Monitor and display one or more generic RSS, ATOM or JSON feeds", + option: { + feedUrls: { + label: "Feed URLs", + }, + textLinesClamp: { + label: "Description line clamp", + }, + maximumAmountPosts: { + label: "Amount posts limit", + }, + }, + }, }, widgetPreview: { toggle: { @@ -1494,6 +1509,9 @@ export default { mediaOrganizer: { label: "Media Organizers", }, + rssFeeds: { + label: "RSS feeds", + }, }, }, }, diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 158f79330..d6e3e4528 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -22,6 +22,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@extractus/feed-extractor": "^7.1.3", "@homarr/api": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index c702d7238..09303c8b7 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -1,6 +1,7 @@ import type { WidgetOptionType } from "../options"; import { WidgetAppInput } from "./widget-app-input"; import { WidgetLocationInput } from "./widget-location-input"; +import { WidgetMultiTextInput } from "./widget-multi-text-input"; import { WidgetMultiSelectInput } from "./widget-multiselect-input"; import { WidgetNumberInput } from "./widget-number-input"; import { WidgetSelectInput } from "./widget-select-input"; @@ -12,7 +13,7 @@ const mapping = { text: WidgetTextInput, location: WidgetLocationInput, multiSelect: WidgetMultiSelectInput, - multiText: () => null, + multiText: WidgetMultiTextInput, number: WidgetNumberInput, select: WidgetSelectInput, slider: WidgetSliderInput, diff --git a/packages/widgets/src/_inputs/widget-multi-text-input.tsx b/packages/widgets/src/_inputs/widget-multi-text-input.tsx new file mode 100644 index 000000000..2da483fac --- /dev/null +++ b/packages/widgets/src/_inputs/widget-multi-text-input.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => { + const t = useWidgetInputTranslation(kind, property); + const tCommon = useScopedI18n("common"); + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), + }); + + const [search, setSearch] = useState(""); + + const form = useFormContext(); + const inputProps = form.getInputProps(`options.${property}`); + const values = inputProps.value as string[]; + const onChange = inputProps.onChange as (values: string[]) => void; + + const handleRemove = (optionIndex: number) => { + onChange(values.filter((_, index) => index !== optionIndex)); + }; + + const currentValidationResult = React.useMemo(() => { + if (!options.validate) { + return { + success: false, + result: null, + }; + } + + const validationResult = options.validate.safeParse(search); + return { + success: validationResult.success, + result: validationResult, + }; + }, [search]); + + const error = React.useMemo(() => { + /* hide the error when nothing is being typed since "" is not valid but is not an explicit error */ + if (!currentValidationResult.success && currentValidationResult.result && search.length !== 0) { + return currentValidationResult.result.error?.issues[0]?.message; + } + return null; + }, [currentValidationResult, search]); + + return ( + + + combobox.openDropdown()} + error={error} + > + + {values.map((option, index) => ( + handleRemove(index)} withRemoveButton> + {option} + + ))} + + + combobox.openDropdown()} + onBlur={() => combobox.closeDropdown()} + value={search} + placeholder={tCommon("multiText.placeholder")} + onChange={(event) => { + combobox.updateSelectedOptionIndex(); + setSearch(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === "Backspace" && search.length === 0) { + event.preventDefault(); + onChange(values.slice(0, -1)); + } else if (event.key === "Enter") { + event.preventDefault(); + if (search.length === 0 || !currentValidationResult.success) { + return; + } + if (values.includes(search)) { + return; + } + onChange([...values, search]); + setSearch(""); + } + }} + /> + + + + + + ); +}; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index fd4e46566..862fc3d20 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -14,6 +14,7 @@ import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; import * as mediaServer from "./media-server"; import * as notebook from "./notebook"; +import * as rssFeed from "./rssFeed"; import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as video from "./video"; @@ -37,6 +38,7 @@ export const widgetImports = { "smartHome-executeAutomation": smartHomeExecuteAutomation, mediaServer, calendar, + rssFeed, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 4d7fc15be..4b8966392 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -1,6 +1,6 @@ import { objectEntries } from "@homarr/common"; import type { WidgetKind } from "@homarr/definitions"; -import type { z } from "@homarr/validation"; +import type { z, ZodType } from "@homarr/validation"; import { widgetImports } from "."; import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input"; @@ -91,10 +91,12 @@ const optionsFactory = { }, withDescription: input?.withDescription ?? false, }), - multiText: (input?: CommonInput) => ({ + multiText: (input?: CommonInput & { validate?: ZodType }) => ({ type: "multiText" as const, defaultValue: input?.defaultValue ?? [], withDescription: input?.withDescription ?? false, + values: [] as string[], + validate: input?.validate, }), app: (input?: Omit, "defaultValue">) => ({ type: "app" as const, diff --git a/packages/widgets/src/rssFeed/component.module.scss b/packages/widgets/src/rssFeed/component.module.scss new file mode 100644 index 000000000..cd68be962 --- /dev/null +++ b/packages/widgets/src/rssFeed/component.module.scss @@ -0,0 +1,11 @@ +.backgroundImage { + position: absolute; + width: 100%; + height: 100%; + filter: blur(5px); + transform: scaleX(-1.05) scaleZ(-1.05); + opacity: 0.25; + top: 0; + left: 0; + object-fit: cover; +} diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx new file mode 100644 index 000000000..28d39b7bf --- /dev/null +++ b/packages/widgets/src/rssFeed/component.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core"; +import { IconClock } from "@tabler/icons-react"; +import dayjs from "dayjs"; + +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.scss"; + +export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) { + if (serverData?.initialData === undefined) { + return null; + } + + const entries = serverData.initialData + .filter((feedGroup) => feedGroup.feed.entries !== undefined) + .flatMap((feedGroup) => feedGroup.feed.entries) + .filter((entry) => entry !== undefined) + .sort((entryA, entryB) => { + if (!entryA.published || !entryB.published) { + return -1; + } + return new Date(entryB.published).getTime() - new Date(entryA.published).getTime(); + }) + .slice(0, options.maximumAmountPosts as number); + + return ( + + + {entries.map((feedEntry) => ( + + {feedEntry.enclosure && ( + backdrop + )} + + + + {feedEntry.title} + + {feedEntry.description && ( + + )} + + {feedEntry.published && } + + + ))} + + + ); +} + +const InfoDisplay = ({ date }: { date: string }) => ( + + + + {date} + + +); diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts new file mode 100644 index 000000000..6751bb859 --- /dev/null +++ b/packages/widgets/src/rssFeed/index.ts @@ -0,0 +1,32 @@ +import { IconRss } from "@tabler/icons-react"; + +import { z } from "@homarr/validation"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +/** + * Feed must conform to one of the following standards: + * - https://www.rssboard.org/rss-specification (https://web.resource.org/rss/1.0/spec) + * - https://datatracker.ietf.org/doc/html/rfc5023 + * - https://www.jsonfeed.org/version/1.1/ + */ +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", { + icon: IconRss, + options: optionsBuilder.from((factory) => ({ + feedUrls: factory.multiText({ + defaultValue: [], + validate: z.string().url(), + }), + textLinesClamp: factory.number({ + defaultValue: 5, + validate: z.number().min(1).max(50), + }), + maximumAmountPosts: factory.number({ + defaultValue: 100, + validate: z.number().min(1).max(9999), + }), + })), +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/rssFeed/serverData.ts b/packages/widgets/src/rssFeed/serverData.ts new file mode 100644 index 000000000..a583a9141 --- /dev/null +++ b/packages/widgets/src/rssFeed/serverData.ts @@ -0,0 +1,21 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../definition"; + +export default async function getServerDataAsync({ itemId }: WidgetProps<"rssFeed">) { + if (!itemId) { + return { + initialData: undefined, + lastUpdatedAt: null, + }; + } + const data = await api.widget.rssFeed.getFeeds({ + itemId, + }); + return { + initialData: data?.data, + lastUpdatedAt: data?.timestamp, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4a06f3e6..36842a2c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -671,6 +671,9 @@ importers: packages/cron-jobs: dependencies: + '@extractus/feed-extractor': + specifier: ^7.1.3 + version: 7.1.3 '@homarr/analytics': specifier: workspace:^0.1.0 version: link:../analytics @@ -707,6 +710,9 @@ importers: '@homarr/translation': specifier: workspace:^0.1.0 version: link:../translation + '@homarr/validation': + specifier: workspace:^0.1.0 + version: link:../validation devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1248,6 +1254,9 @@ importers: packages/widgets: dependencies: + '@extractus/feed-extractor': + specifier: ^7.1.3 + version: 7.1.3 '@homarr/api': specifier: workspace:^0.1.0 version: link:../api @@ -2047,6 +2056,10 @@ packages: resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@extractus/feed-extractor@7.1.3': + resolution: {integrity: sha512-USRVpGw4fWlnz8O8gB95UDJJaU5wg2EFESDe9nut0mHFJ8bOxDKGoo3g6EaKU24YzaPUBweKENdEJfuTUgr/uA==} + engines: {node: '>= 18'} + '@floating-ui/core@1.6.0': resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} @@ -3285,6 +3298,10 @@ packages: resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} engines: {node: '>= 10.0.0'} + bellajs@11.2.0: + resolution: {integrity: sha512-Wjss+Bc674ZABPr+SCKWTqA4V1pyYFhzDTjNBJy4jdmgOv0oGIGXeKBRJyINwP5tIy+iIZD9SfgZpztduzQ5QA==} + engines: {node: '>= 18.4'} + better-sqlite3@11.1.2: resolution: {integrity: sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==} @@ -3545,6 +3562,9 @@ packages: engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} hasBin: true + cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -4116,6 +4136,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@4.4.0: + resolution: {integrity: sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==} + hasBin: true + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -4384,6 +4408,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.5.2: + resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5976,6 +6003,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -7119,6 +7149,15 @@ snapshots: '@eslint/object-schema@2.1.4': {} + '@extractus/feed-extractor@7.1.3': + dependencies: + bellajs: 11.2.0 + cross-fetch: 4.0.0 + fast-xml-parser: 4.4.0 + html-entities: 2.5.2 + transitivePeerDependencies: + - encoding + '@floating-ui/core@1.6.0': dependencies: '@floating-ui/utils': 0.2.1 @@ -8556,6 +8595,8 @@ snapshots: - encoding - supports-color + bellajs@11.2.0: {} + better-sqlite3@11.1.2: dependencies: bindings: 1.5.0 @@ -8836,6 +8877,12 @@ snapshots: dependencies: cross-spawn: 7.0.3 + cross-fetch@4.0.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -9584,6 +9631,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@4.4.0: + dependencies: + strnum: 1.0.5 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -9873,6 +9924,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.5.2: {} + html-escaper@2.0.2: {} http-proxy-agent@7.0.2: @@ -11639,6 +11692,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@1.0.5: {} + styled-jsx@5.1.1(@babel/core@7.24.6)(react@18.3.1): dependencies: client-only: 0.0.1