diff --git a/src/app/(feed)/feed/import/ImportDropzone.tsx b/src/app/(feed)/feed/import/ImportDropzone.tsx index 53ad0ae..02813fb 100644 --- a/src/app/(feed)/feed/import/ImportDropzone.tsx +++ b/src/app/(feed)/feed/import/ImportDropzone.tsx @@ -1,16 +1,14 @@ import clsx from "clsx"; -import { type DragEvent, useRef, useState } from "react"; +import { type DragEvent, type RefObject, useRef, useState } from "react"; type ImportDropzoneProps = { - inputElement: HTMLInputElement | null; + inputElementRef: RefObject | null; onSelectFile: () => void; - filename: string; }; export function ImportDropzone({ - inputElement, + inputElementRef, onSelectFile, - filename, }: ImportDropzoneProps) { const dropzoneRef = useRef(null); @@ -21,10 +19,6 @@ export function ImportDropzone({ e.stopPropagation(); }; - if (!!inputElement?.files?.length) { - return null; - } - return (
{ - inputElement?.click(); + inputElementRef?.current?.click(); }} className={clsx( - "hover:bg-muted/30 border-muted grid h-64 w-full cursor-pointer place-items-center rounded-xl border border-dashed transition-colors", + "hover:bg-muted/30 border-foreground/40 grid h-64 w-full cursor-pointer place-items-center rounded-xl border border-dashed transition-colors", { "bg-muted/50": isDraggingOverDropzone, }, )} >
- Drag and drop your {filename} file here, or click/tap to upload + Drag and drop your file here, or click/tap to upload
); diff --git a/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx b/src/app/(feed)/feed/import/YouTubeSubscriptionImportCarousel.tsx similarity index 100% rename from src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx rename to src/app/(feed)/feed/import/YouTubeSubscriptionImportCarousel.tsx diff --git a/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx b/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx deleted file mode 100644 index 6ea15a7..0000000 --- a/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { useFeeds } from "~/lib/data/feeds"; -import { ImportDropzone } from "../ImportDropzone"; -import { type SubscriptionImportMethodProps } from "../types"; -import { parseOPMLSubscriptionInput } from "./parseOPMLSubscriptionInput"; - -export function OPMLSubscriptionImport({ - importedChannels, - setImportedChannels, -}: SubscriptionImportMethodProps) { - const [inputElement, setInputElement] = useState( - null, - ); - - const { feeds } = useFeeds(); - - const onSelectFiles = async () => { - if (!inputElement || feeds === undefined) return; - - const newChannels = await parseOPMLSubscriptionInput(inputElement, feeds); - if (!newChannels) { - inputElement.value = ""; - return; - } - - setImportedChannels(newChannels); - }; - - return ( - <> -
-

Import File

-
- - - {!!importedChannels && inputElement && ( - - )} -
-
- - ); -} diff --git a/src/app/(feed)/feed/import/opml/parseOPMLSubscriptionInput.ts b/src/app/(feed)/feed/import/opml/parseOPMLSubscriptionInput.ts deleted file mode 100644 index d2739ba..0000000 --- a/src/app/(feed)/feed/import/opml/parseOPMLSubscriptionInput.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { type DatabaseFeed } from "~/server/db/schema"; -import { type SubscriptionImportChannel } from "../types"; -import { XMLParser } from "fast-xml-parser"; - -const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: "", -}); - -type OPMLFeed = { - text: string; - title: string; - description: string; - type: string; - version: string; - /** - * A link to the actual, non-RSS website. - */ - htmlUrl: string; - /** - * The rss feed link. - */ - xmlUrl: string; -}; - -type OPMLCategory = { - text: string; - title: string; - outline: OPMLFeed[]; -}; - -type OPMLResult = { - "?xml"?: { - version: string; - encoding: string; - }; - opml: { - head: { - title: string; - }; - body: { - outline: (OPMLFeed | OPMLCategory)[]; - }; - }; -}; - -function parseOPMLFeed( - opmlFeed: OPMLFeed, - feeds: DatabaseFeed[], - categories?: string[], -) { - let channelId = ""; - - if ( - opmlFeed.xmlUrl.includes( - "https://www.youtube.com/feeds/videos.xml?channel_id=", - ) - ) { - channelId = opmlFeed.xmlUrl.replace( - "https://www.youtube.com/feeds/videos.xml?channel_id=", - "", - ); - } - - const hasFeedAlready = !!feeds?.find((feed) => feed.url === opmlFeed.xmlUrl); - - let disabledReason: SubscriptionImportChannel["disabledReason"] = null; - if (hasFeedAlready) { - disabledReason = "added-already"; - } else if (!channelId) { - disabledReason = "not-supported"; - } - - return { - channelId, - feedUrl: opmlFeed.xmlUrl, - title: opmlFeed.title, - shouldImport: !disabledReason, - disabledReason, - categories: categories ?? [], - } as SubscriptionImportChannel; -} - -export async function parseOPMLSubscriptionInput( - input: HTMLInputElement, - feeds: DatabaseFeed[], -) { - if (!input.files) return; - - const file = input.files?.[0]; - if (!file) return; - - const fileContent = await file.text(); - - const opmlData = parser.parse(fileContent) as OPMLResult; - - const channels: SubscriptionImportChannel[] = - opmlData.opml.body.outline.flatMap((entry) => { - if ("outline" in entry) { - return entry.outline.map((feed) => - parseOPMLFeed(feed, feeds, [entry.title]), - ); - } else { - return parseOPMLFeed(entry, feeds, [entry.title]); - } - }); - - return channels; -} diff --git a/src/app/(feed)/feed/import/page.tsx b/src/app/(feed)/feed/import/page.tsx index e4c7205..811048e 100644 --- a/src/app/(feed)/feed/import/page.tsx +++ b/src/app/(feed)/feed/import/page.tsx @@ -1,208 +1,317 @@ "use client"; -import { CheckIcon, XIcon } from "lucide-react"; +import { + CheckIcon, + CircleQuestionMarkIcon, + GlobeIcon, + MinusIcon, + PlayCircleIcon, + TriangleAlertIcon, + YoutubeIcon, +} from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import FeedLoading from "~/app/loading"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; -import { Label } from "~/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; -import { useFeeds } from "~/lib/data/feeds"; import { - type SubscriptionImportMethod, - type SubscriptionImportChannel, -} from "./types"; -import { YouTubeSubscriptionImport } from "./youtube/YouTubeSubscriptionImport"; -import { OPMLSubscriptionImport } from "./opml/OPMLSubscriptionImport"; -import FeedLoading from "~/app/loading"; + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { useFeeds } from "~/lib/data/feeds"; import { useCreateFeedsFromSubscriptionImportMutation } from "~/lib/data/feeds/mutations"; +import type { BulkImportFromFileResult } from "~/server/api/routers/feed-router"; +import type { FeedPlatform } from "~/server/db/schema"; +import { ImportDropzone } from "./ImportDropzone"; +import { getInitialFeedDataFromFileInputElement } from "./utils/getInitialFeedDataFromFileInputElement"; +import type { ImportFeedDataItem } from "./utils/shared"; + +function PlatformIcon({ platform }: { platform: FeedPlatform }) { + switch (platform) { + case "youtube": + return ; + case "peertube": + return ; + case "website": + default: + return ; + } +} export default function EditFeedsPage() { - const [importMethod, setImportMethod] = - useState("subscriptions"); + const inputElementRef = useRef(null); - const [importedChannels, setImportedChannels] = useState< - SubscriptionImportChannel[] | null + const [feedsFoundFromFile, setFeedsFoundFromFile] = useState< + ImportFeedDataItem[] | null >(null); + const [feedResults, setFeedResults] = useState( + [], + ); const { mutateAsync: createFeedsFromSubscriptionImportMutation, isPending, isSuccess, + reset: resetCreateFeedsMutation, } = useCreateFeedsFromSubscriptionImportMutation(); - const channelImportCount = importedChannels?.filter( - (channel) => channel.shouldImport, + const channelImportCount = feedsFoundFromFile?.filter( + (feed) => feed.shouldImport, ).length; const { feeds } = useFeeds(); - if (isSuccess) { - return ( -
-

Import Feeds

-
Import success! Channels added successfully.
- - - -
+ const onSelectFiles = async () => { + if (!inputElementRef.current || feeds === undefined) return; + + const feedResult = await getInitialFeedDataFromFileInputElement( + inputElementRef.current, ); + inputElementRef.current.value = ""; + + if (feedResult.success) { + setFeedsFoundFromFile(feedResult.data); + } + }; + + const onFeedImport = async () => { + if (!feedsFoundFromFile?.length) return; + + const channelsToImport = feedsFoundFromFile + .filter((channel) => channel.shouldImport) + .map((feed) => ({ + categories: feed.categories, + feedUrl: feed.feedUrl, + })); + + const results = await createFeedsFromSubscriptionImportMutation({ + feeds: channelsToImport, + }); + + setFeedResults(results); + }; + + const onReset = () => { + setFeedsFoundFromFile(null); + setFeedResults([]); + resetCreateFeedsMutation(); + }; + + if (isPending) { + return ; } return (
- {isPending && }

Import Feeds

- {!importedChannels && ( -
-

Method

-
- - setImportMethod(v as SubscriptionImportMethod) - } - > -
- - -
-
- - -
-
-
-
- )} - {importMethod === "subscriptions" && ( - + {!isSuccess && ( + <> +

Serial supports importing:

+
    +
  • + + subscriptions.csv + {" "} + files from a Google Takeout export +
  • +
  • + + *.opml + {" "} + files from another RSS reader's export +
  • +
+ + )} - {importMethod === "opml" && ( - + {isSuccess && ( + <> +

+ Imported finished! Check below to see the status of specific feed + imports. +

+
+ + + + +
+ )} - {!!importedChannels && ( + + {!!feedsFoundFromFile && ( <>
-
-

Channels To Import

- -
-
- {importedChannels + } + }} + > + {channelImportCount === 0 ? "Select All" : "Deselect All"} + +
+ )} +
+ {feedsFoundFromFile ?.sort((a, b) => { + if (!a.title && !b.title) return 0; + if (!a.title) return -1; + if (!b.title) return -1; return a.title.localeCompare(b.title); }) - .map((channel, i) => ( -
- + .map((channel, i) => { + const displayTitle = channel.title ?? channel.feedUrl; + const result = feedResults.find( + (result) => result.feedUrl === channel.feedUrl, + ); + + return (
- {channel.disabledReason === "added-already" && ( - - Already added - - )} - {channel.disabledReason === "not-supported" && ( - - Not supported - - )} - {!channel.disabledReason && ( - { - setImportedChannels((prevChannels) => { - if (!prevChannels?.[i]) { - return prevChannels; - } - - prevChannels[i] = { - ...prevChannels[i], - shouldImport: value.valueOf() as boolean, - }; - return [...prevChannels]; - }); - }} - disabled={ - !!feeds?.find( - (feed) => feed.url === channel.feedUrl, - ) - } - /> + + + + + {!isSuccess && ( + + {channel.categories.map((category) => ( + + {category} + + ))} + )} +
+ {!isSuccess && ( + { + setFeedsFoundFromFile((prevChannels) => { + if (!prevChannels?.[i]) { + return prevChannels; + } + + prevChannels[i] = { + ...prevChannels[i], + shouldImport: value.valueOf() as boolean, + }; + return [...prevChannels]; + }); + }} + disabled={ + !!feeds?.find( + (feed) => feed.url === channel.feedUrl, + ) + } + /> + )} + {!!result && + (result.success ? ( + + + + + + Imported Successfully! + + + ) : ( + + + + + {result.error} + + ))} + {!result && + !!feedResults.length && + channel.shouldImport && ( + + + + + + We don't know what happened with this + import. Feel free to file a bug report with this + feed URL! + + + )} + {!result && + !!feedResults.length && + !channel.shouldImport && ( + + + + + + This feed was excluded from the import. + + + )} +
-
- ))} -
-
-
-
- + })}
+ {!isSuccess && ( +
+
+ +
+
+ )}
)} diff --git a/src/app/(feed)/feed/import/utils/getInitialFeedDataFromCSVInput.ts b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromCSVInput.ts new file mode 100644 index 0000000..508d085 --- /dev/null +++ b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromCSVInput.ts @@ -0,0 +1,60 @@ +import { getAssumedFeedPlatform } from "~/server/rss/validateFeedUrl"; +import { + formError, + formSuccess, + type ImportFeedDataFromFileResult, + type ImportFeedDataItem, +} from "./shared"; + +const YT_CHANNEL_ID_COLUMN_LOWERCASE_NAME = "channel id"; +const YT_CHANNEL_URL_COLUMN_LOWERCASE_NAME = "channel url"; +const YT_CHANNEL_TITLE_COLUMN_LOWERCASE_NAME = "channel title"; + +export function getInitialFeedDataFromCSVInput( + fileContent: string, +): ImportFeedDataFromFileResult { + const rows = fileContent.split("\n"); + const [headerRow, ...channelRows] = rows; + + if (!headerRow) { + return formError("File doesn't match expected length."); + } + + const [idTitle, urlTitle, titleTitle] = headerRow.split(","); + + if ( + idTitle?.toLowerCase() !== YT_CHANNEL_ID_COLUMN_LOWERCASE_NAME || + urlTitle?.toLowerCase() !== YT_CHANNEL_URL_COLUMN_LOWERCASE_NAME || + titleTitle?.toLowerCase() !== YT_CHANNEL_TITLE_COLUMN_LOWERCASE_NAME + ) { + return formError( + 'File doesn\'t match expected format. Ensure your CSV has the "Channel Id", "Channel Url", and "Channel Title" column headers.', + ); + } + + const initialFeedData = channelRows + .map((row): ImportFeedDataItem | null => { + const [channelId, channelUrl, title] = row.split(","); + + if (!channelId || !channelUrl || !title) { + return null; + } + + const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; + + return { + title, + feedUrl, + categories: [], + platform: getAssumedFeedPlatform(feedUrl), + shouldImport: true, + }; + }) + .filter(Boolean); + + if (!!initialFeedData.length) { + return formSuccess(initialFeedData); + } + + return formError("Something went wrong."); +} diff --git a/src/app/(feed)/feed/import/utils/getInitialFeedDataFromFileInputElement.ts b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromFileInputElement.ts new file mode 100644 index 0000000..7d45459 --- /dev/null +++ b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromFileInputElement.ts @@ -0,0 +1,34 @@ +import { getInitialFeedDataFromCSVInput } from "./getInitialFeedDataFromCSVInput"; +import { getInitialFeedDataFromOPMLInput } from "./getInitialFeedDataFromOPMLInput"; +import { formError, type ImportFeedDataFromFileResult } from "./shared"; + +export async function getInitialFeedDataFromFileInputElement( + inputElement: HTMLInputElement, +): Promise { + if (!inputElement.files) { + return formError("Couldn't find a file."); + } + + const file = inputElement.files?.[0]; + if (!file) { + return formError("Couldn't find a file."); + } + + const fileContent = await file.text(); + const [, fileExtension] = file.name.split("."); + + // subscriptions.csv + if (fileExtension === "csv") { + return getInitialFeedDataFromCSVInput(fileContent); + } + + // *.opml + else if (fileExtension === "opml") { + return getInitialFeedDataFromOPMLInput(fileContent); + } + + // rest + else { + return formError("This file type is not supported."); + } +} diff --git a/src/app/(feed)/feed/import/utils/getInitialFeedDataFromOPMLInput.ts b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromOPMLInput.ts new file mode 100644 index 0000000..79a5fd4 --- /dev/null +++ b/src/app/(feed)/feed/import/utils/getInitialFeedDataFromOPMLInput.ts @@ -0,0 +1,85 @@ +import { XMLParser } from "fast-xml-parser"; +import { getAssumedFeedPlatform } from "~/server/rss/validateFeedUrl"; +import { + formSuccess, + type ImportFeedDataFromFileResult, + type ImportFeedDataItem, +} from "./shared"; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", +}); + +type OPMLFeed = { + text: string; + title: string; + description: string; + type: string; + version: string; + /** + * A link to the actual, non-RSS website. + */ + htmlUrl: string; + /** + * The rss feed link. + */ + xmlUrl: string; +}; + +type OPMLCategory = { + text: string; + title: string; + outline: OPMLFeed | OPMLFeed[]; +}; + +type OPMLResult = { + "?xml"?: { + version: string; + encoding: string; + }; + opml: { + head: { + title: string; + }; + body: { + outline: (OPMLFeed | OPMLCategory)[]; + }; + }; +}; + +function parseOPMLFeed( + opmlFeed: OPMLFeed, + categories?: string[], +): ImportFeedDataItem { + return { + feedUrl: opmlFeed.xmlUrl, + title: opmlFeed.title, + shouldImport: true, + categories: categories?.filter(Boolean) ?? [], + platform: getAssumedFeedPlatform(opmlFeed.xmlUrl), + }; +} + +export function getInitialFeedDataFromOPMLInput( + fileContent: string, +): ImportFeedDataFromFileResult { + const opmlData = parser.parse(fileContent) as OPMLResult; + + const feeds: ImportFeedDataItem[] = opmlData.opml.body.outline.flatMap( + (entry) => { + if ("outline" in entry) { + if (entry.outline instanceof Array) { + return entry.outline.map((feed) => + parseOPMLFeed(feed, [entry.title]), + ); + } + return [parseOPMLFeed(entry.outline, [entry.title])]; + } else { + return parseOPMLFeed(entry, [entry.title]); + } + }, + ); + + return formSuccess(feeds); +} diff --git a/src/app/(feed)/feed/import/utils/shared.ts b/src/app/(feed)/feed/import/utils/shared.ts new file mode 100644 index 0000000..d39940a --- /dev/null +++ b/src/app/(feed)/feed/import/utils/shared.ts @@ -0,0 +1,39 @@ +import type { FeedPlatform } from "~/server/db/schema"; + +export type ImportFeedDataItem = { + feedUrl: string; + title?: string; + categories: string[]; + platform: FeedPlatform; + shouldImport: boolean; +}; + +export type ImportFeedDataFromFileSuccess = { + success: true; + data: ImportFeedDataItem[]; +}; +export type ImportFeedDataFromFileError = { + success: false; + error: string; +}; +export type ImportFeedDataFromFileResult = + | ImportFeedDataFromFileError + | ImportFeedDataFromFileSuccess; + +export function formError( + error: ImportFeedDataFromFileError["error"], +): ImportFeedDataFromFileError { + return { + success: false, + error, + }; +} + +export function formSuccess( + data: ImportFeedDataFromFileSuccess["data"], +): ImportFeedDataFromFileSuccess { + return { + success: true, + data, + }; +} diff --git a/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImport.tsx b/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImport.tsx deleted file mode 100644 index e846e1d..0000000 --- a/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImport.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { useFeeds } from "~/lib/data/feeds"; -import { ImportDropzone } from "../ImportDropzone"; -import { type SubscriptionImportMethodProps } from "../types"; -import { parseYouTubeSubscriptionInput } from "./parseYouTubeSubscriptionInput"; -import { YouTubeSubscriptionImportCarousel } from "./YouTubeSubscriptionImportCarousel"; - -export function YouTubeSubscriptionImport({ - importedChannels, - setImportedChannels, -}: SubscriptionImportMethodProps) { - const [inputElement, setInputElement] = useState( - null, - ); - - const { feeds } = useFeeds(); - - const onSelectFiles = async () => { - if (!inputElement || feeds === undefined) return; - - const newChannels = await parseYouTubeSubscriptionInput( - inputElement, - feeds, - ); - if (!newChannels) return; - - setImportedChannels(newChannels); - }; - - return ( - <> -
-

Import File

-
- - - {!!importedChannels && inputElement && ( - - )} -
-
- {!importedChannels && ( -
-

- How do I find my "subscriptions.csv" file? -

-
-
- -
-
- )} - - ); -} diff --git a/src/app/(feed)/feed/import/youtube/parseYouTubeSubscriptionInput.ts b/src/app/(feed)/feed/import/youtube/parseYouTubeSubscriptionInput.ts deleted file mode 100644 index d7cef4b..0000000 --- a/src/app/(feed)/feed/import/youtube/parseYouTubeSubscriptionInput.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type DatabaseFeed } from "~/server/db/schema"; -import { type SubscriptionImportChannel } from "../types"; - -export async function parseYouTubeSubscriptionInput( - input: HTMLInputElement, - feeds: DatabaseFeed[], -) { - if (!input.files) return; - - const file = input.files?.[0]; - if (!file) return; - - const fileContent = await file.text(); - const rows = fileContent.split("\n"); - - const [headerRow, ...restRows] = rows; - if (!headerRow) return; - - const [idTitle, urlTitle, titleTitle] = headerRow.split(","); - - if ( - idTitle !== "Channel Id" || - urlTitle !== "Channel Url" || - titleTitle !== "Channel Title" - ) { - console.error("doesn't match format"); - return; - } - - const channels: SubscriptionImportChannel[] = restRows - .map((row) => { - const [channelId, channelUrl, title] = row.split(","); - - if (!channelId || !channelUrl || !title) { - return null; - } - - const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; - - const hasFeedAlready = !!feeds?.find((feed) => feed.url === feedUrl); - - return { - channelId, - feedUrl, - title, - shouldImport: !hasFeedAlready, - disabledReason: hasFeedAlready ? "added-already" : null, - categories: [], - } as SubscriptionImportChannel; - }) - .filter(Boolean); - - return channels; -} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index afbd61e..7e5ecbd 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,4 +1,3 @@ -import { feedRouter } from "~/server/api/routers/feedRouter"; import { feedItemRouter } from "~/server/api/routers/feedItemRouter"; import { contentCategoriesRouter } from "~/server/api/routers/contentCategoriesRouter"; import { feedCategoriesRouter } from "~/server/api/routers/feedCategoriesRouter"; @@ -6,6 +5,7 @@ import { createTRPCRouter } from "~/server/api/trpc"; import { userConfigRouter } from "./routers/userConfigRouter"; import { userRouter } from "./routers/userRouter"; import { viewRouter } from "./routers/viewRouter"; +import { feedRouter } from "./routers/feed-router"; export const appRouter = createTRPCRouter({ user: userRouter, diff --git a/src/server/api/routers/feedRouter.ts b/src/server/api/routers/feed-router/index.ts similarity index 59% rename from src/server/api/routers/feedRouter.ts rename to src/server/api/routers/feed-router/index.ts index 0c7d077..e9181d5 100644 --- a/src/server/api/routers/feedRouter.ts +++ b/src/server/api/routers/feed-router/index.ts @@ -6,7 +6,6 @@ import { parseArrayOfSchema } from "~/lib/schemas/utils"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { contentCategories, - type DatabaseFeed, feedCategories, feedItems, feeds, @@ -14,10 +13,20 @@ import { openLocationSchema, } from "~/server/db/schema"; import { fetchFeedData, fetchNewFeedDetails } from "~/server/rss/fetchFeeds"; +import { findExistingFeedThatMatches } from "./utils"; -const importUrlSchema = z - .string() - .startsWith("https://www.youtube.com/feeds/videos.xml?channel_id="); +type BulkImportFromFileSuccess = { + feedUrl: string; + success: true; +}; +type BulkImportFromFileError = { + feedUrl: string; + success: false; + error: string; +}; +export type BulkImportFromFileResult = + | BulkImportFromFileError + | BulkImportFromFileSuccess; export const feedRouter = createTRPCRouter({ create: protectedProcedure @@ -36,11 +45,9 @@ export const feedRouter = createTRPCRouter({ newFeeds.map(async (newFeed) => { if (!newFeed.url) return "No feed url found."; - const existingFeed = await tx.query.feeds.findFirst({ - where: and( - eq(feeds.url, newFeed.url), - eq(feeds.userId, ctx.auth!.user.id), - ), + const existingFeed = await findExistingFeedThatMatches(tx, { + feedUrl: newFeed.url, + userId: ctx.auth!.user.id, }); if (existingFeed) { @@ -81,55 +88,136 @@ export const feedRouter = createTRPCRouter({ createFeedsFromSubscriptionImport: protectedProcedure .input( z.object({ - channels: z + feeds: z .object({ - channelId: z.string(), feedUrl: z.string(), - title: z.string(), - shouldImport: z.boolean(), + categories: z.string().array(), }) .array(), }), ) - .mutation(async ({ ctx, input }) => { - if (!input.channels.length) return; - - const feedsToAdd: Omit[] = - input.channels - .filter((channel) => channel.shouldImport) - .filter( - (channel) => importUrlSchema.safeParse(channel.feedUrl).success, - ) - .map((channel) => ({ - userId: ctx.auth!.user.id, - name: channel.title, - platform: "youtube", - url: channel.feedUrl, - imageUrl: "", - openLocation: "serial", - })); - if (!feedsToAdd.length) return; + .mutation(async ({ ctx, input }): Promise => { + if (!input.feeds.length) { + return []; + } - await ctx.db.transaction(async (tx) => { - return await Promise.all( - feedsToAdd.map(async (newFeed) => { - if (!newFeed.url) return "No feed url found."; - - const existingFeed = await tx.query.feeds.findFirst({ - where: and( - eq(feeds.url, newFeed.url), - eq(feeds.userId, ctx.auth!.user.id), - ), + const promiseResults = await Promise.allSettled( + input.feeds.map(async (feed) => { + return await ctx.db.transaction(async (tx) => { + const newFeedDetails = await fetchNewFeedDetails(feed.feedUrl); + const newFeed = newFeedDetails?.[0]; + + if (!newFeed?.url) { + return { + feedUrl: feed.feedUrl, + success: false, + error: "Unsupported feed URL", + }; + } + + const existingFeed = await findExistingFeedThatMatches(tx, { + feedUrl: newFeed.url, + userId: ctx.auth!.user.id, }); if (existingFeed) { - return "Feed already exists"; + return { + feedUrl: newFeed.url, + success: false, + error: "Feed already exists", + }; } - await tx.insert(feeds).values(newFeed); - }), - ); - }); + const newFeeds = await tx + .insert(feeds) + .values({ + userId: ctx.auth!.user.id, + ...newFeed, + }) + .returning(); + const newFeedRow = newFeeds?.[0]; + + if (!newFeedRow) { + return { + feedUrl: newFeed.url, + success: false, + error: "Couldn't find new feed", + }; + } + + const matchingCategories = await tx + .select() + .from(contentCategories) + .where( + and( + inArray(contentCategories.name, feed.categories), + eq(contentCategories.userId, ctx.auth!.user.id), + ), + ) + .all(); + const matchingCategoryNames = matchingCategories.map( + (category) => category.name, + ); + + const nonMatchingCategories = feed.categories.filter( + (category) => !matchingCategoryNames.includes(category), + ); + + const matchingCategoryPromises = matchingCategories.map( + async (matchingCategory) => { + const categoryId = matchingCategory.id; + + return await tx.insert(feedCategories).values({ + feedId: newFeedRow.id, + categoryId: categoryId, + }); + }, + ); + + const nonMatchingCategoryPromises = nonMatchingCategories.map( + async (nonMatchingCategory) => { + const newContentCategoryList = await tx + .insert(contentCategories) + .values({ + name: nonMatchingCategory, + userId: ctx.auth!.user.id, + }) + .returning(); + const newContentCategory = newContentCategoryList?.[0]; + + if (!newContentCategory?.id) return; + + await tx.insert(feedCategories).values({ + feedId: newFeedRow.id, + categoryId: newContentCategory?.id, + }); + }, + ); + + await Promise.allSettled([ + ...matchingCategoryPromises, + ...nonMatchingCategoryPromises, + ]); + + return { + feedUrl: newFeed.url, + success: true, + }; + }); + }), + ); + + const results: BulkImportFromFileResult[] = promiseResults + .map((result) => { + if (result.status === "fulfilled") { + return result.value; + } + + return null; + }) + .filter(Boolean); + + return results; }), delete: protectedProcedure .input(z.number()) diff --git a/src/server/api/routers/feed-router/utils.ts b/src/server/api/routers/feed-router/utils.ts new file mode 100644 index 0000000..191fb08 --- /dev/null +++ b/src/server/api/routers/feed-router/utils.ts @@ -0,0 +1,28 @@ +import type { ResultSet } from "@libsql/client"; +import { and, eq, type ExtractTablesWithRelations } from "drizzle-orm"; +import type { SQLiteTransaction } from "drizzle-orm/sqlite-core"; + +import * as schema from "~/server/db/schema"; +type SerialSchema = typeof schema; + +type Transaction = SQLiteTransaction< + "async", + ResultSet, + SerialSchema, + ExtractTablesWithRelations +>; + +export async function findExistingFeedThatMatches( + tx: Transaction, + data: { + feedUrl: string; + userId: string; + }, +) { + return await tx.query.feeds.findFirst({ + where: and( + eq(schema.feeds.url, data.feedUrl), + eq(schema.feeds.userId, data.userId), + ), + }); +}