From 7f4202afd73105b850498b55ad66922b3505f0e3 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Sun, 14 Dec 2025 16:39:25 -0800 Subject: feat: Add unified reader settings with local overrides (#2230) * Add initial impl * fix some format inconsistencies, add indicator in user settings when local is out of sync * Fix sliders in user settings, unify constants and formatting * address CodeRabbit suggestions * add mobile implementation * address coderabbit nitpicks * fix responsiveness of the reader settings popover * Move more of the web UI strings to i18n * update translations for more coverage * remove duplicate logic/definitions * fix android font family * add shared reading setting hook between web and mobile * unify reader settings context for both web and mobile * remove unused export * address coderabbit suggestions * fix tests --- .gitignore | 3 + apps/mobile/app/dashboard/(tabs)/settings.tsx | 12 + apps/mobile/app/dashboard/_layout.tsx | 8 + .../app/dashboard/bookmarks/[slug]/index.tsx | 27 +- .../app/dashboard/settings/reader-settings.tsx | 264 ++ .../components/bookmarks/BookmarkLinkPreview.tsx | 43 +- .../bookmarks/BookmarkLinkTypeSelector.tsx | 6 +- apps/mobile/lib/providers.tsx | 5 +- apps/mobile/lib/readerSettings.tsx | 93 + apps/mobile/lib/settings.ts | 8 + apps/web/app/dashboard/layout.tsx | 37 +- apps/web/app/reader/[bookmarkId]/page.tsx | 140 +- apps/web/app/reader/layout.tsx | 39 + apps/web/app/settings/info/page.tsx | 2 + apps/web/app/settings/layout.tsx | 15 +- .../dashboard/preview/LinkContentSection.tsx | 34 +- .../dashboard/preview/ReaderSettingsPopover.tsx | 457 +++ apps/web/components/settings/ReaderSettings.tsx | 288 ++ apps/web/lib/i18n/locales/en/translation.json | 33 + apps/web/lib/readerSettings.tsx | 155 + apps/web/lib/userSettings.tsx | 3 + packages/db/drizzle/0070_add_reader_settings.sql | 3 + packages/db/drizzle/meta/0070_snapshot.json | 2969 ++++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/schema.ts | 8 + packages/shared-react/hooks/reader-settings.tsx | 285 ++ packages/shared/types/readers.ts | 59 + packages/shared/types/users.ts | 10 + packages/trpc/models/users.ts | 37 +- packages/trpc/routers/users.test.ts | 15 + 30 files changed, 4866 insertions(+), 199 deletions(-) create mode 100644 apps/mobile/app/dashboard/settings/reader-settings.tsx create mode 100644 apps/mobile/lib/readerSettings.tsx create mode 100644 apps/web/app/reader/layout.tsx create mode 100644 apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx create mode 100644 apps/web/components/settings/ReaderSettings.tsx create mode 100644 apps/web/lib/readerSettings.tsx create mode 100644 packages/db/drizzle/0070_add_reader_settings.sql create mode 100644 packages/db/drizzle/meta/0070_snapshot.json create mode 100644 packages/shared-react/hooks/reader-settings.tsx create mode 100644 packages/shared/types/readers.ts diff --git a/.gitignore b/.gitignore index 6a4a421a..e5aa93d8 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ data .vscode auth_failures.log .claude/settings.local.json + +# Local directory for AI agent contexts +.aicontext/ diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx index 76216e00..7c1e00d6 100644 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -87,6 +87,18 @@ export default function Dashboard() { + + + + Reader Text Settings + + + + Show note preview in bookmark + ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..8fd04115 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -13,12 +13,14 @@ import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -87,11 +89,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - setBookmarkLinkType(type)} - bookmark={bookmark} - /> + + {bookmarkLinkType === "reader" && ( + + router.push("/dashboard/settings/reader-settings") + } + > + + + )} + setBookmarkLinkType(type)} + bookmark={bookmark} + /> + ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..6c522557 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue(effectiveFontSize); + const fontSizeMin = useSharedValue( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue(effectiveLineHeight); + const lineHeightMin = useSharedValue( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + + + {/* Font Family Selection */} + + + Font Family + {localOverrides.fontFamily !== undefined && ( + (local) + )} + + + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + + handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + + {formatFontFamily(fontFamily)} + + {isChecked && } + + {index < fontFamilyOptions.length - 1 && ( + + )} + + ); + })} + + + + {/* Font Size */} + + + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + (local) + )} + + + + {READER_SETTING_CONSTRAINTS.fontSize.min} + + + null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + + + {READER_SETTING_CONSTRAINTS.fontSize.max} + + + + + {/* Line Height */} + + + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + (local) + )} + + + + {READER_SETTING_CONSTRAINTS.lineHeight.min} + + + null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + + + {READER_SETTING_CONSTRAINTS.lineHeight.max} + + + + + {/* Preview */} + + + Preview + + + + The quick brown fox jumps over the lazy dog. Pack my box with five + dozen liquor jugs. How vexingly quick daft zebras jump! + + + + + + + {/* Save as Default */} + + + Save as Default (All Devices) + + + + {/* Clear Local */} + {hasLocalOverrides && ( + + + Clear Local Overrides + + )} + + {/* Clear Server */} + {hasServerDefaults && ( + + + Clear Server Defaults + + )} + + + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index 730bcd08..e0b592d6 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -5,6 +5,7 @@ import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; +import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; import { api } from "@/lib/trpc"; import { useColorScheme } from "@/lib/useColorScheme"; @@ -38,6 +39,7 @@ export function BookmarkLinkReaderPreview({ bookmark: ZBookmark; }) { const { isDarkColorScheme: isDark } = useColorScheme(); + const { settings: readerSettings } = useReaderSettings(); const { data: bookmarkWithContent, @@ -61,6 +63,10 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } + const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily]; + const fontSize = readerSettings.fontSize; + const lineHeight = readerSettings.lineHeight; + return ( diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index 58cbcc8d..c7fd4be3 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -43,7 +43,7 @@ export default function BookmarkLinkTypeSelector({ }: BookmarkLinkTypeSelectorProps) { const availableTypes = getAvailableViewTypes(bookmark); - const allActions = [ + const viewActions = [ { id: "reader" as const, title: "Reader View", @@ -66,7 +66,7 @@ export default function BookmarkLinkTypeSelector({ }, ]; - const availableActions = allActions.filter((action) => + const availableViewActions = viewActions.filter((action) => availableTypes.includes(action.id), ); @@ -76,7 +76,7 @@ export default function BookmarkLinkTypeSelector({ Haptics.selectionAsync(); onChange(nativeEvent.event as BookmarkLinkType); }} - actions={availableActions} + actions={availableViewActions} shouldOpenOnLongPress={false} > Haptics.selectionAsync()} color="gray" /> diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 938b8aeb..36ed7e71 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -4,6 +4,7 @@ import { ToastProvider } from "@/components/ui/Toast"; import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { ReaderSettingsProvider } from "./readerSettings"; import useAppSettings from "./settings"; export function Providers({ children }: { children: React.ReactNode }) { @@ -20,7 +21,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx new file mode 100644 index 00000000..9a3fc835 --- /dev/null +++ b/apps/mobile/lib/readerSettings.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useCallback } from "react"; +import { Platform } from "react-native"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { ReaderSettingsPartial } from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +import { useSettings } from "./settings"; + +// Mobile-specific font families for native Text components +// On Android, use generic font family names: "serif", "sans-serif", "monospace" +// On iOS, use specific font names like "Georgia" and "Courier" +// Note: undefined means use the system default font +export const MOBILE_FONT_FAMILIES: Record< + ZReaderFontFamily, + string | undefined +> = Platform.select({ + android: { + serif: "serif", + sans: undefined, + mono: "monospace", + }, + default: { + serif: "Georgia", + sans: undefined, + mono: "Courier", + }, +})!; + +// Font families for WebView HTML content (CSS font stacks) +export const WEBVIEW_FONT_FAMILIES: Record = { + serif: "Georgia, 'Times New Roman', serif", + sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace", +} as const; + +/** + * Mobile-specific provider for reader settings. + * Wraps the shared provider with mobile storage callbacks. + */ +export function ReaderSettingsProvider({ children }: { children: ReactNode }) { + // Read from zustand store directly to keep callback stable (empty deps). + const getLocalOverrides = useCallback((): ReaderSettingsPartial => { + const currentSettings = useSettings.getState().settings.settings; + return { + fontSize: currentSettings.readerFontSize, + lineHeight: currentSettings.readerLineHeight, + fontFamily: currentSettings.readerFontFamily, + }; + }, []); + + const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => { + const currentSettings = useSettings.getState().settings.settings; + // Remove reader settings keys first, then add back only defined ones + const { + readerFontSize: _fs, + readerLineHeight: _lh, + readerFontFamily: _ff, + ...rest + } = currentSettings; + + const newSettings = { ...rest }; + if (overrides.fontSize !== undefined) { + (newSettings as typeof currentSettings).readerFontSize = + overrides.fontSize; + } + if (overrides.lineHeight !== undefined) { + (newSettings as typeof currentSettings).readerLineHeight = + overrides.lineHeight; + } + if (overrides.fontFamily !== undefined) { + (newSettings as typeof currentSettings).readerFontFamily = + overrides.fontFamily; + } + + useSettings.getState().setSettings(newSettings); + }, []); + + return ( + + {children} + + ); +} + +// Re-export the context hook as useReaderSettings for mobile consumers +export { useReaderSettingsContext as useReaderSettings }; diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 40a33976..745c778d 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -2,6 +2,8 @@ import * as SecureStore from "expo-secure-store"; import { z } from "zod"; import { create } from "zustand"; +import { zReaderFontFamilySchema } from "@karakeep/shared/types/users"; + const SETTING_NAME = "settings"; const zSettingsSchema = z.object({ @@ -16,6 +18,10 @@ const zSettingsSchema = z.object({ .default("reader"), showNotes: z.boolean().optional().default(false), customHeaders: z.record(z.string(), z.string()).optional().default({}), + // Reader settings (local device overrides) + readerFontSize: z.number().int().min(12).max(24).optional(), + readerLineHeight: z.number().min(1.2).max(2.5).optional(), + readerFontFamily: zReaderFontFamilySchema.optional(), }); export type Settings = z.infer; @@ -73,3 +79,5 @@ export default function useAppSettings() { return { ...settings, setSettings, load }; } + +export { useSettings }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 911d542c..be65e66a 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { Separator } from "@/components/ui/separator"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; @@ -98,23 +99,25 @@ export default async function Dashboard({ return ( - - - - - } - /> - } - mobileSidebar={} - modal={modal} - > - {children} - + + + + + + } + /> + } + mobileSidebar={} + modal={modal} + > + {children} + + ); } diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx index e32811a9..133bb601 100644 --- a/apps/web/app/reader/[bookmarkId]/page.tsx +++ b/apps/web/app/reader/[bookmarkId]/page.tsx @@ -3,36 +3,18 @@ import { Suspense, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import HighlightCard from "@/components/dashboard/highlights/HighlightCard"; +import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover"; import ReaderView from "@/components/dashboard/preview/ReaderView"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Slider } from "@/components/ui/slider"; -import { - HighlighterIcon as Highlight, - Minus, - Plus, - Printer, - Settings, - Type, - X, -} from "lucide-react"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { HighlighterIcon as Highlight, Printer, X } from "lucide-react"; import { useSession } from "next-auth/react"; import { api } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; export default function ReaderViewPage() { @@ -47,19 +29,10 @@ export default function ReaderViewPage() { const { data: session } = useSession(); const router = useRouter(); - const [fontSize, setFontSize] = useState([18]); - const [lineHeight, setLineHeight] = useState([1.6]); - const [fontFamily, setFontFamily] = useState("serif"); + const { settings } = useReaderSettings(); const [showHighlights, setShowHighlights] = useState(false); - const [showSettings, setShowSettings] = useState(false); const isOwner = session?.user?.id === bookmark?.userId; - const fontFamilies = { - serif: "ui-serif, Georgia, Cambria, serif", - sans: "ui-sans-serif, system-ui, sans-serif", - mono: "ui-monospace, Menlo, Monaco, monospace", - }; - const onClose = () => { if (window.history.length > 1) { router.back(); @@ -89,94 +62,7 @@ export default function ReaderViewPage() { - - - - - -
-
- -

Reading Settings

-
- -
-
- - -
- -
-
- - - {fontSize[0]}px - -
-
- - - -
-
- -
-
- - - {lineHeight[0]} - -
- -
-
-
-
-
+ + + + +

{getSettingsTooltip()}

+
+ + +
+
+
+ +

+ {t("settings.info.reader_settings.title")} +

+
+ {hasSessionChanges && ( + + {t("settings.info.reader_settings.preview")} + + )} +
+ +
+
+
+ +
+ {sessionOverrides.fontFamily !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} +

+
+
+ )} +
+
+ +
+ +
+
+ +
+ + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} +

+
+
+ )} +
+
+
+ + + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + +
+
+ +
+
+ +
+ + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} +

+
+
+ )} +
+
+
+ + + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + +
+
+ + {hasSessionChanges && ( + <> + + +
+ + +
+ + +
+ +

+ {t("settings.info.reader_settings.save_hint")} +

+
+ + )} + + {!hasSessionChanges && ( +

+ {t("settings.info.reader_settings.adjust_hint")} +

+ )} +
+
+
+ + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..ce4017c7 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState } from "react"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { AlertTriangle, BookOpen, Laptop, RotateCcw } from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; +import { toast } from "../ui/use-toast"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState(null); + const [draggingLineHeight, setDraggingLineHeight] = useState( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + + + + + {t("settings.info.reader_settings.title")} + + + {t("settings.info.reader_settings.description")} + + + + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + + + +
+

+ {t("settings.info.reader_settings.local_overrides_title")} +

+

+ {t( + "settings.info.reader_settings.local_overrides_description", + )} +

+
    + {localOverrides.fontFamily !== undefined && ( +
  • + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} +
  • + )} + {localOverrides.fontSize !== undefined && ( +
  • + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} +
  • + )} + {localOverrides.lineHeight !== undefined && ( +
  • + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} +
  • + )} +
+
+ +
+
+ )} + + {/* Font Family */} +
+ + + {serverSettings.fontFamily === null && ( +

+ {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} +

+ )} +
+ + {/* Font Size */} +
+
+ + + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + +
+ setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> +
+ + {/* Line Height */} +
+
+ + + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + +
+ setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> +
+ + {/* Clear Defaults Button */} + {hasServerSettings && ( + + )} + + {/* Preview */} +
+

+ {t("settings.info.reader_settings.preview")} +

+

+ {t("settings.info.reader_settings.preview_text")} +
+ {t("settings.info.reader_settings.preview_text")} +
+ {t("settings.info.reader_settings.preview_text")} +

+
+
+
+ ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33c7d6e2..d05ca702 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -1,5 +1,6 @@ { "common": { + "default": "Default", "url": "URL", "name": "Name", "email": "Email", @@ -131,6 +132,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "title": "Reader Settings", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "font_family": "Font Family", + "font_size": "Font Size", + "line_height": "Line Height", + "save_as_default": "Save as default", + "clear_defaults": "Clear all defaults", + "not_set": "Not set", + "using_default": "Using client default", + "preview": "Preview", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "defaults_cleared": "Reader defaults have been cleared", + "local_overrides_title": "Device-specific settings active", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "local_overrides_cleared": "Device-specific settings have been cleared", + "clear_local_overrides": "Clear device settings", + "serif": "Serif", + "sans": "Sans Serif", + "mono": "Monospace", + "tooltip_default": "Reading settings", + "tooltip_preview": "Unsaved preview changes", + "tooltip_local": "Device settings differ from global", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "reset_preview": "Reset preview", + "save_to_device": "This device", + "save_to_all_devices": "All devices", + "save_hint": "Save settings for this device only or sync across all devices", + "adjust_hint": "Adjust settings above to preview changes", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "preview_inline": "(preview)" } }, "stats": { diff --git a/apps/web/lib/readerSettings.tsx b/apps/web/lib/readerSettings.tsx new file mode 100644 index 00000000..5966f287 --- /dev/null +++ b/apps/web/lib/readerSettings.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +const LOCAL_STORAGE_KEY = "karakeep-reader-settings"; + +function getLocalOverridesFromStorage(): ReaderSettingsPartial { + if (typeof window === "undefined") return {}; + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function saveLocalOverridesToStorage(overrides: ReaderSettingsPartial): void { + if (typeof window === "undefined") return; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(overrides)); +} + +// Session overrides context - web-specific feature for live preview +interface SessionOverridesContextValue { + sessionOverrides: ReaderSettingsPartial; + setSessionOverrides: React.Dispatch< + React.SetStateAction + >; +} + +const SessionOverridesContext = + createContext(null); + +export function ReaderSettingsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sessionOverrides, setSessionOverrides] = + useState({}); + + const sessionValue = useMemo( + () => ({ + sessionOverrides, + setSessionOverrides, + }), + [sessionOverrides], + ); + + // Memoize callbacks to prevent unnecessary re-renders + const getLocalOverrides = useCallback(getLocalOverridesFromStorage, []); + const saveLocalOverrides = useCallback(saveLocalOverridesToStorage, []); + const onClearSessionOverrides = useCallback(() => { + setSessionOverrides({}); + }, []); + + return ( + + + {children} + + + ); +} + +export function useReaderSettings() { + const sessionContext = useContext(SessionOverridesContext); + if (!sessionContext) { + throw new Error( + "useReaderSettings must be used within a ReaderSettingsProvider", + ); + } + + const { sessionOverrides, setSessionOverrides } = sessionContext; + const baseSettings = useReaderSettingsContext(); + + // Update session override (live preview, not persisted) + const updateSession = useCallback( + (updates: ReaderSettingsPartial) => { + setSessionOverrides((prev) => ({ ...prev, ...updates })); + }, + [setSessionOverrides], + ); + + // Clear all session overrides + const clearSession = useCallback(() => { + setSessionOverrides({}); + }, [setSessionOverrides]); + + // Save current settings to local storage (this device only) + const saveToDevice = useCallback(() => { + const newLocalOverrides = { + ...baseSettings.localOverrides, + ...sessionOverrides, + }; + baseSettings.setLocalOverrides(newLocalOverrides); + saveLocalOverridesToStorage(newLocalOverrides); + setSessionOverrides({}); + }, [baseSettings, sessionOverrides, setSessionOverrides]); + + // Clear a single local override + const clearLocalOverride = useCallback( + (key: keyof ReaderSettings) => { + baseSettings.clearLocal(key); + }, + [baseSettings], + ); + + // Check if there are unsaved session changes + const hasSessionChanges = Object.keys(sessionOverrides).length > 0; + + return { + // Current effective settings (what should be displayed) + settings: baseSettings.settings, + + // Raw values for UI indicators + serverSettings: baseSettings.serverDefaults, + localOverrides: baseSettings.localOverrides, + sessionOverrides, + + // State indicators + hasSessionChanges, + hasLocalOverrides: baseSettings.hasLocalOverrides, + isSaving: baseSettings.isSaving, + + // Actions + updateSession, + clearSession, + saveToDevice, + clearLocalOverrides: baseSettings.clearAllLocal, + clearLocalOverride, + saveToServer: baseSettings.saveAsDefault, + updateServerSetting: baseSettings.saveAsDefault, + clearServerDefaults: baseSettings.clearAllDefaults, + }; +} diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx index c7a133b7..2bb7c8a5 100644 --- a/apps/web/lib/userSettings.tsx +++ b/apps/web/lib/userSettings.tsx @@ -13,6 +13,9 @@ export const UserSettingsContext = createContext({ backupsEnabled: false, backupsFrequency: "daily", backupsRetentionDays: 7, + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, }); export function UserSettingsContextProvider({ diff --git a/packages/db/drizzle/0070_add_reader_settings.sql b/packages/db/drizzle/0070_add_reader_settings.sql new file mode 100644 index 00000000..6a49c122 --- /dev/null +++ b/packages/db/drizzle/0070_add_reader_settings.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` ADD `readerFontSize` integer;--> statement-breakpoint +ALTER TABLE `user` ADD `readerLineHeight` real;--> statement-breakpoint +ALTER TABLE `user` ADD `readerFontFamily` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0070_snapshot.json b/packages/db/drizzle/meta/0070_snapshot.json new file mode 100644 index 00000000..4b9521e3 --- /dev/null +++ b/packages/db/drizzle/meta/0070_snapshot.json @@ -0,0 +1,2969 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ff48e807-eda8-4a9f-ab94-afd693f415eb", + "prevId": "d8334431-a9bc-4397-8bc8-21b09d7cd4b2", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backups": { + "name": "backups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkCount": { + "name": "bookmarkCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "backups_userId_idx": { + "name": "backups_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "backups_createdAt_idx": { + "name": "backups_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "backups_userId_user_id_fk": { + "name": "backups_userId_user_id_fk", + "tableFrom": "backups", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backups_assetId_assets_id_fk": { + "name": "backups_assetId_assets_id_fk", + "tableFrom": "backups", + "tableTo": "assets", + "columnsFrom": [ + "assetId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentAssetId": { + "name": "contentAssetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + }, + "bookmarks_userId_createdAt_id_idx": { + "name": "bookmarks_userId_createdAt_id_idx", + "columns": [ + "userId", + "createdAt", + "id" + ], + "isUnique": false + }, + "bookmarks_userId_archived_createdAt_id_idx": { + "name": "bookmarks_userId_archived_createdAt_id_idx", + "columns": [ + "userId", + "archived", + "createdAt", + "id" + ], + "isUnique": false + }, + "bookmarks_userId_favourited_createdAt_id_idx": { + "name": "bookmarks_userId_favourited_createdAt_id_idx", + "columns": [ + "userId", + "favourited", + "createdAt", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listMembershipId": { + "name": "listMembershipId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_bookmarkId_idx": { + "name": "bookmarksInLists_listId_bookmarkId_idx", + "columns": [ + "listId", + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listMembershipId_listCollaborators_id_fk": { + "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "listCollaborators", + "columnsFrom": [ + "listMembershipId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessionBookmarks": { + "name": "importSessionBookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "importSessionId": { + "name": "importSessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "importSessionBookmarks_sessionId_idx": { + "name": "importSessionBookmarks_sessionId_idx", + "columns": [ + "importSessionId" + ], + "isUnique": false + }, + "importSessionBookmarks_bookmarkId_idx": { + "name": "importSessionBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "importSessionBookmarks_importSessionId_bookmarkId_unique": { + "name": "importSessionBookmarks_importSessionId_bookmarkId_unique", + "columns": [ + "importSessionId", + "bookmarkId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "importSessionBookmarks_importSessionId_importSessions_id_fk": { + "name": "importSessionBookmarks_importSessionId_importSessions_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "importSessions", + "columnsFrom": [ + "importSessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessionBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessions": { + "name": "importSessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rootListId": { + "name": "rootListId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "importSessions_userId_idx": { + "name": "importSessions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "importSessions_userId_user_id_fk": { + "name": "importSessions_userId_user_id_fk", + "tableFrom": "importSessions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessions_rootListId_bookmarkLists_id_fk": { + "name": "importSessions_rootListId_bookmarkLists_id_fk", + "tableFrom": "importSessions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "rootListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invites_token_unique": { + "name": "invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invites_invitedBy_user_id_fk": { + "name": "invites_invitedBy_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "listCollaborators": { + "name": "listCollaborators", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "listCollaborators_listId_idx": { + "name": "listCollaborators_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "listCollaborators_userId_idx": { + "name": "listCollaborators_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "listCollaborators_listId_userId_unique": { + "name": "listCollaborators_listId_userId_unique", + "columns": [ + "listId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "listCollaborators_listId_bookmarkLists_id_fk": { + "name": "listCollaborators_listId_bookmarkLists_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_userId_user_id_fk": { + "name": "listCollaborators_userId_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_addedBy_user_id_fk": { + "name": "listCollaborators_addedBy_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "listInvitations": { + "name": "listInvitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "invitedAt": { + "name": "invitedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "listInvitations_listId_idx": { + "name": "listInvitations_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "listInvitations_userId_idx": { + "name": "listInvitations_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "listInvitations_status_idx": { + "name": "listInvitations_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "listInvitations_listId_userId_unique": { + "name": "listInvitations_listId_userId_unique", + "columns": [ + "listId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "listInvitations_listId_bookmarkLists_id_fk": { + "name": "listInvitations_listId_bookmarkLists_id_fk", + "tableFrom": "listInvitations", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listInvitations_userId_user_id_fk": { + "name": "listInvitations_userId_user_id_fk", + "tableFrom": "listInvitations", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listInvitations_invitedBy_user_id_fk": { + "name": "listInvitations_invitedBy_user_id_fk", + "tableFrom": "listInvitations", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passwordResetToken_token_unique": { + "name": "passwordResetToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "passwordResetTokens_userId_idx": { + "name": "passwordResetTokens_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_userId_user_id_fk": { + "name": "passwordResetToken_userId_user_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_bookmarkId_idx": { + "name": "rssFeedImports_rssFeedId_bookmarkId_idx", + "columns": [ + "rssFeedId", + "bookmarkId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "importTags": { + "name": "importTags", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_userId_idx": { + "name": "subscriptions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "subscriptions_stripeCustomerId_idx": { + "name": "subscriptions_stripeCustomerId_idx", + "columns": [ + "stripeCustomerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "tagsOnBookmarks_tagId_bookmarkId_idx": { + "name": "tagsOnBookmarks_tagId_bookmarkId_idx", + "columns": [ + "tagId", + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "bookmarkQuota": { + "name": "bookmarkQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageQuota": { + "name": "storageQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserCrawlingEnabled": { + "name": "browserCrawlingEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + }, + "archiveDisplayBehaviour": { + "name": "archiveDisplayBehaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'show'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "backupsEnabled": { + "name": "backupsEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "backupsFrequency": { + "name": "backupsFrequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "backupsRetentionDays": { + "name": "backupsRetentionDays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "readerFontSize": { + "name": "readerFontSize", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readerLineHeight": { + "name": "readerLineHeight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readerFontFamily": { + "name": "readerFontFamily", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 25b110c3..49e684a5 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -491,6 +491,13 @@ "when": 1765721715670, "tag": "0069_fix_pending_summarization", "breakpoints": true + }, + { + "idx": 70, + "version": "6", + "when": 1765744716304, + "tag": "0070_add_reader_settings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index fdae5d4f..cc4d4b56 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -7,6 +7,7 @@ import { index, integer, primaryKey, + real, sqliteTable, text, unique, @@ -69,6 +70,13 @@ export const users = sqliteTable("user", { .notNull() .default("weekly"), backupsRetentionDays: integer("backupsRetentionDays").notNull().default(30), + + // Reader view settings (nullable = opt-in, null means use client default) + readerFontSize: integer("readerFontSize"), + readerLineHeight: real("readerLineHeight"), + readerFontFamily: text("readerFontFamily", { + enum: ["serif", "sans", "mono"], + }), }); export const accounts = sqliteTable( diff --git a/packages/shared-react/hooks/reader-settings.tsx b/packages/shared-react/hooks/reader-settings.tsx new file mode 100644 index 00000000..2705a050 --- /dev/null +++ b/packages/shared-react/hooks/reader-settings.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + READER_DEFAULTS, + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +import { api } from "../trpc"; + +export interface UseReaderSettingsOptions { + /** + * Get local overrides (device-specific settings stored locally) + */ + getLocalOverrides: () => ReaderSettingsPartial; + /** + * Save local overrides to local storage + */ + saveLocalOverrides: (overrides: ReaderSettingsPartial) => void; + /** + * Optional session overrides (for live preview in web). + * If provided, these take highest precedence. + */ + sessionOverrides?: ReaderSettingsPartial; + /** + * Callback when session overrides should be cleared (after successful server save) + */ + onClearSessionOverrides?: () => void; +} + +export function useReaderSettings(options: UseReaderSettingsOptions) { + const { + getLocalOverrides, + saveLocalOverrides, + sessionOverrides = {}, + onClearSessionOverrides, + } = options; + + const [localOverrides, setLocalOverrides] = useState( + {}, + ); + const [pendingServerSave, setPendingServerSave] = + useState(null); + + const { data: serverSettings } = api.users.settings.useQuery(); + const apiUtils = api.useUtils(); + + // Load local overrides on mount + useEffect(() => { + setLocalOverrides(getLocalOverrides()); + }, [getLocalOverrides]); + + // Clear pending state when server settings match what we saved + useEffect(() => { + if (pendingServerSave && serverSettings) { + const serverMatches = + serverSettings.readerFontSize === pendingServerSave.fontSize && + // Tolerate minor float normalization differences for lineHeight + Math.abs( + (serverSettings.readerLineHeight ?? 0) - pendingServerSave.lineHeight, + ) < 1e-6 && + serverSettings.readerFontFamily === pendingServerSave.fontFamily; + if (serverMatches) { + setPendingServerSave(null); + } + } + }, [serverSettings, pendingServerSave]); + + const { mutate: updateServerSettings, isPending: isSaving } = + api.users.updateSettings.useMutation({ + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Separate mutation for saving defaults (clears local overrides on success) + const { mutate: saveServerSettings, isPending: isSavingDefaults } = + api.users.updateSettings.useMutation({ + onSuccess: () => { + // Clear local and session overrides after successful server save + setLocalOverrides({}); + saveLocalOverrides({}); + onClearSessionOverrides?.(); + }, + onError: () => { + // Clear pending state so we don't show values that failed to persist + setPendingServerSave(null); + }, + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Compute effective settings with precedence: session → local → pendingSave → server → default + const settings: ReaderSettings = useMemo( + () => ({ + fontSize: + sessionOverrides.fontSize ?? + localOverrides.fontSize ?? + pendingServerSave?.fontSize ?? + serverSettings?.readerFontSize ?? + READER_DEFAULTS.fontSize, + lineHeight: + sessionOverrides.lineHeight ?? + localOverrides.lineHeight ?? + pendingServerSave?.lineHeight ?? + serverSettings?.readerLineHeight ?? + READER_DEFAULTS.lineHeight, + fontFamily: + sessionOverrides.fontFamily ?? + localOverrides.fontFamily ?? + pendingServerSave?.fontFamily ?? + serverSettings?.readerFontFamily ?? + READER_DEFAULTS.fontFamily, + }), + [sessionOverrides, localOverrides, pendingServerSave, serverSettings], + ); + + // Get the server setting values (for UI indicators) + const serverDefaults: ReaderSettingsPartial = useMemo( + () => ({ + fontSize: serverSettings?.readerFontSize ?? undefined, + lineHeight: serverSettings?.readerLineHeight ?? undefined, + fontFamily: serverSettings?.readerFontFamily ?? undefined, + }), + [serverSettings], + ); + + // Update local override (per-device, immediate) + const updateLocal = useCallback( + (updates: ReaderSettingsPartial) => { + setLocalOverrides((prev) => { + const newOverrides = { ...prev, ...updates }; + saveLocalOverrides(newOverrides); + return newOverrides; + }); + }, + [saveLocalOverrides], + ); + + // Clear a specific local override + const clearLocal = useCallback( + (key: keyof ReaderSettings) => { + setLocalOverrides((prev) => { + const { [key]: _, ...rest } = prev; + saveLocalOverrides(rest); + return rest; + }); + }, + [saveLocalOverrides], + ); + + // Clear all local overrides + const clearAllLocal = useCallback(() => { + setLocalOverrides({}); + saveLocalOverrides({}); + }, [saveLocalOverrides]); + + // Save current effective settings as server default (syncs across devices) + const saveAsDefault = useCallback( + (settingsToSave?: ReaderSettingsPartial) => { + const toSave: ReaderSettings = { + fontSize: settingsToSave?.fontSize ?? settings.fontSize, + lineHeight: settingsToSave?.lineHeight ?? settings.lineHeight, + fontFamily: settingsToSave?.fontFamily ?? settings.fontFamily, + }; + // Set pending state to prevent flicker while server syncs + setPendingServerSave(toSave); + saveServerSettings({ + readerFontSize: toSave.fontSize, + readerLineHeight: toSave.lineHeight, + readerFontFamily: toSave.fontFamily, + }); + }, + [settings, saveServerSettings], + ); + + // Clear a specific server default (set to null) + const clearDefault = useCallback( + (key: keyof ReaderSettings) => { + const serverKeyMap = { + fontSize: "readerFontSize", + lineHeight: "readerLineHeight", + fontFamily: "readerFontFamily", + } as const; + updateServerSettings({ [serverKeyMap[key]]: null }); + }, + [updateServerSettings], + ); + + // Clear all server defaults + const clearAllDefaults = useCallback(() => { + updateServerSettings({ + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, + }); + }, [updateServerSettings]); + + // Check if there are any local overrides + const hasLocalOverrides = Object.keys(localOverrides).length > 0; + + // Check if there are any server defaults + const hasServerDefaults = + serverSettings?.readerFontSize != null || + serverSettings?.readerLineHeight != null || + serverSettings?.readerFontFamily != null; + + return { + // Current effective settings (what should be displayed) + settings, + + // Raw values for UI indicators + localOverrides, + serverDefaults, + + // Status flags + hasLocalOverrides, + hasServerDefaults, + isSaving: isSaving || isSavingDefaults, + + // Internal state setters (for web's context-based approach) + setLocalOverrides, + + // Actions + updateLocal, + clearLocal, + clearAllLocal, + saveAsDefault, + clearDefault, + clearAllDefaults, + }; +} + +// Context for sharing reader settings state across components +export type ReaderSettingsContextValue = ReturnType; + +const ReaderSettingsContext = createContext( + null, +); + +export interface ReaderSettingsProviderProps extends UseReaderSettingsOptions { + children: ReactNode; +} + +/** + * Provider that creates a single instance of reader settings state + * and shares it across all child components. + */ +export function ReaderSettingsProvider({ + children, + ...options +}: ReaderSettingsProviderProps) { + const value = useReaderSettings(options); + + return ( + + {children} + + ); +} + +/** + * Hook to access shared reader settings from context. + * Must be used within a ReaderSettingsProvider. + */ +export function useReaderSettingsContext() { + const context = useContext(ReaderSettingsContext); + if (!context) { + throw new Error( + "useReaderSettingsContext must be used within a ReaderSettingsProvider", + ); + } + return context; +} diff --git a/packages/shared/types/readers.ts b/packages/shared/types/readers.ts new file mode 100644 index 00000000..117dd51b --- /dev/null +++ b/packages/shared/types/readers.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { ZReaderFontFamily, zReaderFontFamilySchema } from "./users"; + +export const READER_DEFAULTS = { + fontSize: 18, + lineHeight: 1.6, + fontFamily: "serif" as const, +} as const; + +export const READER_FONT_FAMILIES: Record = { + serif: "ui-serif, Georgia, Cambria, serif", + sans: "ui-sans-serif, system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, monospace", +} as const; + +// Setting constraints for UI controls +export const READER_SETTING_CONSTRAINTS = { + fontSize: { min: 12, max: 24, step: 1 }, + lineHeight: { min: 1.2, max: 2.5, step: 0.1 }, +} as const; + +// Formatting functions for display +export function formatFontSize(value: number): string { + return `${value}px`; +} + +export function formatLineHeight(value: number): string { + return value.toFixed(1); +} + +export function formatFontFamily( + value: ZReaderFontFamily, + t?: (key: string) => string, +): string { + if (t) { + return t(`settings.info.reader_settings.${value}`); + } + // Fallback labels when no translation function provided + switch (value) { + case "serif": + return "Serif"; + case "sans": + return "Sans Serif"; + case "mono": + return "Monospace"; + } +} + +export const zReaderSettings = z.object({ + fontSize: z.number().int().min(12).max(24), + lineHeight: z.number().min(1.2).max(2.5), + fontFamily: zReaderFontFamilySchema, +}); + +export type ReaderSettings = z.infer; + +export const zReaderSettingsPartial = zReaderSettings.partial(); +export type ReaderSettingsPartial = z.infer; diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 9f020d52..73b99885 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -102,6 +102,9 @@ export const zUserStatsResponseSchema = z.object({ ), }); +export const zReaderFontFamilySchema = z.enum(["serif", "sans", "mono"]); +export type ZReaderFontFamily = z.infer; + export const zUserSettingsSchema = z.object({ bookmarkClickAction: z.enum([ "open_original_link", @@ -112,6 +115,10 @@ export const zUserSettingsSchema = z.object({ backupsEnabled: z.boolean(), backupsFrequency: z.enum(["daily", "weekly"]), backupsRetentionDays: z.number().int().min(1).max(365), + // Reader settings (nullable = opt-in, null means use client default) + readerFontSize: z.number().int().min(12).max(24).nullable(), + readerLineHeight: z.number().min(1.2).max(2.5).nullable(), + readerFontFamily: zReaderFontFamilySchema.nullable(), }); export type ZUserSettings = z.infer; @@ -123,6 +130,9 @@ export const zUpdateUserSettingsSchema = zUserSettingsSchema.partial().pick({ backupsEnabled: true, backupsFrequency: true, backupsRetentionDays: true, + readerFontSize: true, + readerLineHeight: true, + readerFontFamily: true, }); export const zUpdateBackupSettingsSchema = zUpdateUserSettingsSchema.pick({ diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index a1f32f02..85aed639 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -433,6 +433,9 @@ export class User { backupsEnabled: true, backupsFrequency: true, backupsRetentionDays: true, + readerFontSize: true, + readerLineHeight: true, + readerFontFamily: true, }, }); @@ -450,6 +453,9 @@ export class User { backupsEnabled: settings.backupsEnabled, backupsFrequency: settings.backupsFrequency, backupsRetentionDays: settings.backupsRetentionDays, + readerFontSize: settings.readerFontSize, + readerLineHeight: settings.readerLineHeight, + readerFontFamily: settings.readerFontFamily, }; } @@ -472,6 +478,9 @@ export class User { backupsEnabled: input.backupsEnabled, backupsFrequency: input.backupsFrequency, backupsRetentionDays: input.backupsRetentionDays, + readerFontSize: input.readerFontSize, + readerLineHeight: input.readerLineHeight, + readerFontFamily: input.readerFontFamily, }) .where(eq(users.id, this.user.id)); } @@ -553,23 +562,23 @@ export class User { // Top domains this.ctx.db .select({ - domain: sql`CASE - WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN - CASE + domain: sql`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 9) END - WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN - CASE + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 8) END - ELSE - CASE + ELSE + CASE WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) ELSE @@ -582,23 +591,23 @@ export class User { .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where(eq(bookmarks.userId, this.user.id)) .groupBy( - sql`CASE - WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN - CASE + sql`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 9) END - WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN - CASE + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 8) END - ELSE - CASE + ELSE + CASE WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) ELSE diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index a2f2be9f..557d025d 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -158,6 +158,11 @@ describe("User Routes", () => { backupsEnabled: false, backupsFrequency: "weekly", backupsRetentionDays: 30, + + // Reader settings + readerFontFamily: null, + readerFontSize: null, + readerLineHeight: null, }); // Update settings @@ -166,6 +171,11 @@ describe("User Routes", () => { backupsEnabled: true, backupsFrequency: "daily", backupsRetentionDays: 7, + + // Reader settings + readerFontFamily: "serif", + readerFontSize: 12, + readerLineHeight: 1.5, }); // Verify updated settings @@ -177,6 +187,11 @@ describe("User Routes", () => { backupsEnabled: true, backupsFrequency: "daily", backupsRetentionDays: 7, + + // Reader settings + readerFontFamily: "serif", + readerFontSize: 12, + readerLineHeight: 1.5, }); // Test invalid update (e.g., empty input, if schema enforces it) -- cgit v1.2.3-70-g09d2