Skip to content

Commit e5cb9aa

Browse files
feat: Add PDF screenshot generation and display (hoarder-app#995)
* Updated pdf2json to 3.1.5 * Extract and store a screenshot from PDF files using pdf2pic * Installing graphicsmagick and ghostscript * Generate Missing PDF screenshot with tidyAssets worker for backward support * Display PDF screenshot instead of the PDF in web if it exists. * Display PDF screenshot in mobile app if exists. * Updated pnpm-lock.yaml * Removed console.log * Revert the unnecessary changes in package.json * Revert pnpm-lock changes * Prevent rendering PDF files if the screenshot is not generated * refactor: replace useEffect with useMemo for section initialization * feat: show PDF file download button and handle large PDFs by defaulting to screenshot view * feat: add file size to openapi spec * feature: Add Assets preprocessing in fix mode to admin actions * i18n: add reprocess_assets_fix_mode translation * i18n: Add missing ar translations * A bunch of fixes * Fix openspec schema --------- Co-authored-by: Mohamed Bassem <[email protected]>
1 parent a14be10 commit e5cb9aa

34 files changed

+545
-101
lines changed

apps/mobile/components/bookmarks/BookmarkCard.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React from "react";
12
import {
23
ActivityIndicator,
34
Alert,
@@ -300,11 +301,15 @@ function AssetCard({
300301
}
301302
const title = bookmark.title ?? bookmark.content.fileName;
302303

304+
const assetImage =
305+
bookmark.assets.find((r) => r.assetType == "assetScreenshot")?.id ??
306+
bookmark.content.assetId;
307+
303308
return (
304309
<View className="flex gap-2">
305310
<Pressable onPress={onOpenBookmark}>
306311
<BookmarkAssetImage
307-
assetId={bookmark.content.assetId}
312+
assetId={assetImage}
308313
className="h-56 min-h-56 w-full object-cover"
309314
/>
310315
</Pressable>

apps/web/components/admin/AdminActions.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ export default function AdminActions() {
3737
},
3838
});
3939

40+
const { mutate: reprocessAssetsFixMode, isPending: isReprocessingPending } =
41+
api.admin.reprocessAssetsFixMode.useMutation({
42+
onSuccess: () => {
43+
toast({
44+
description: "Reprocessing enqueued",
45+
});
46+
},
47+
onError: (e) => {
48+
toast({
49+
variant: "destructive",
50+
description: e.message,
51+
});
52+
},
53+
});
54+
4055
const {
4156
mutate: reRunInferenceOnAllBookmarks,
4257
isPending: isInferencePending,
@@ -124,6 +139,13 @@ export default function AdminActions() {
124139
>
125140
{t("admin.actions.reindex_all_bookmarks")}
126141
</ActionButton>
142+
<ActionButton
143+
variant="destructive"
144+
loading={isReprocessingPending}
145+
onClick={() => reprocessAssetsFixMode()}
146+
>
147+
{t("admin.actions.reprocess_assets_fix_mode")}
148+
</ActionButton>
127149
<ActionButton
128150
variant="destructive"
129151
loading={isTidyAssetsPending}

apps/web/components/dashboard/bookmarks/AssetCard.tsx

+23-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import Image from "next/image";
44
import Link from "next/link";
5+
import { cn } from "@/lib/utils";
6+
import { FileText } from "lucide-react";
57

68
import type { ZBookmarkTypeAsset } from "@hoarder/shared/types/bookmarks";
79
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
@@ -32,12 +34,28 @@ function AssetImage({
3234
);
3335
}
3436
case "pdf": {
37+
const screenshotAssetId = bookmark.assets.find(
38+
(r) => r.assetType === "assetScreenshot",
39+
)?.id;
40+
if (!screenshotAssetId) {
41+
return (
42+
<div
43+
className={cn(className, "flex items-center justify-center")}
44+
title="PDF screenshot not available. Run asset preprocessing job to generate one screenshot"
45+
>
46+
<FileText size={80} />
47+
</div>
48+
);
49+
}
3550
return (
36-
<iframe
37-
title={bookmarkedAsset.assetId}
38-
className={className}
39-
src={getAssetUrl(bookmarkedAsset.assetId)}
40-
/>
51+
<Link href={`/dashboard/preview/${bookmark.id}`}>
52+
<Image
53+
alt="asset"
54+
src={getAssetUrl(screenshotAssetId)}
55+
fill={true}
56+
className={className}
57+
/>
58+
</Link>
4159
);
4260
}
4361
default: {
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,117 @@
1+
import { useMemo, useState } from "react";
12
import Image from "next/image";
23
import Link from "next/link";
4+
import {
5+
Select,
6+
SelectContent,
7+
SelectGroup,
8+
SelectItem,
9+
SelectTrigger,
10+
SelectValue,
11+
} from "@/components/ui/select";
12+
import { useTranslation } from "@/lib/i18n/client";
313

14+
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
415
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
516

6-
export function AssetContentSection({ bookmark }: { bookmark: ZBookmark }) {
17+
// 20 MB
18+
const BIG_FILE_SIZE = 20 * 1024 * 1024;
19+
20+
function PDFContentSection({ bookmark }: { bookmark: ZBookmark }) {
721
if (bookmark.content.type != BookmarkTypes.ASSET) {
822
throw new Error("Invalid content type");
923
}
24+
const { t } = useTranslation();
1025

11-
switch (bookmark.content.assetType) {
12-
case "image": {
13-
return (
14-
<div className="relative h-full min-w-full">
15-
<Link
16-
href={`/api/assets/${bookmark.content.assetId}`}
17-
target="_blank"
18-
>
19-
<Image
20-
alt="asset"
21-
fill={true}
22-
className="object-contain"
23-
src={`/api/assets/${bookmark.content.assetId}`}
24-
/>
25-
</Link>
26-
</div>
27-
);
26+
const initialSection = useMemo(() => {
27+
if (bookmark.content.type != BookmarkTypes.ASSET) {
28+
throw new Error("Invalid content type");
2829
}
29-
case "pdf": {
30-
return (
31-
<iframe
32-
title={bookmark.content.assetId}
33-
className="h-full w-full"
34-
src={`/api/assets/${bookmark.content.assetId}`}
35-
/>
36-
);
30+
31+
const screenshot = bookmark.assets.find(
32+
(item) => item.assetType === "assetScreenshot",
33+
);
34+
const bigSize =
35+
bookmark.content.size && bookmark.content.size > BIG_FILE_SIZE;
36+
if (bigSize && screenshot) {
37+
return "screenshot";
3738
}
38-
default: {
39+
return "pdf";
40+
}, [bookmark]);
41+
const [section, setSection] = useState(initialSection);
42+
43+
const screenshot = bookmark.assets.find(
44+
(r) => r.assetType === "assetScreenshot",
45+
)?.id;
46+
47+
const content =
48+
section === "screenshot" && screenshot ? (
49+
<div className="relative h-full min-w-full">
50+
<Image
51+
alt="screenshot"
52+
src={getAssetUrl(screenshot)}
53+
fill={true}
54+
className="object-contain"
55+
/>
56+
</div>
57+
) : (
58+
<iframe
59+
title={bookmark.content.assetId}
60+
className="h-full w-full"
61+
src={getAssetUrl(bookmark.content.assetId)}
62+
/>
63+
);
64+
65+
return (
66+
<div className="flex h-full flex-col items-center gap-2">
67+
<div className="flex w-full items-center justify-center gap-4">
68+
<Select onValueChange={setSection} value={section}>
69+
<SelectTrigger className="w-fit">
70+
<SelectValue />
71+
</SelectTrigger>
72+
<SelectContent>
73+
<SelectGroup>
74+
<SelectItem value="screenshot" disabled={!screenshot}>
75+
{t("common.screenshot")}
76+
</SelectItem>
77+
<SelectItem value="pdf">PDF</SelectItem>
78+
</SelectGroup>
79+
</SelectContent>
80+
</Select>
81+
</div>
82+
{content}
83+
</div>
84+
);
85+
}
86+
87+
function ImageContentSection({ bookmark }: { bookmark: ZBookmark }) {
88+
if (bookmark.content.type != BookmarkTypes.ASSET) {
89+
throw new Error("Invalid content type");
90+
}
91+
return (
92+
<div className="relative h-full min-w-full">
93+
<Link href={getAssetUrl(bookmark.content.assetId)} target="_blank">
94+
<Image
95+
alt="asset"
96+
fill={true}
97+
className="object-contain"
98+
src={getAssetUrl(bookmark.content.assetId)}
99+
/>
100+
</Link>
101+
</div>
102+
);
103+
}
104+
105+
export function AssetContentSection({ bookmark }: { bookmark: ZBookmark }) {
106+
if (bookmark.content.type != BookmarkTypes.ASSET) {
107+
throw new Error("Invalid content type");
108+
}
109+
switch (bookmark.content.assetType) {
110+
case "image":
111+
return <ImageContentSection bookmark={bookmark} />;
112+
case "pdf":
113+
return <PDFContentSection bookmark={bookmark} />;
114+
default:
39115
return <div>Unsupported asset type</div>;
40-
}
41116
}
42117
}

apps/web/components/dashboard/preview/AttachmentBox.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
4545
const { t } = useTranslation();
4646
const typeToIcon: Record<ZAssetType, React.ReactNode> = {
4747
screenshot: <Camera className="size-4" />,
48+
assetScreenshot: <Camera className="size-4" />,
4849
fullPageArchive: <Archive className="size-4" />,
4950
precrawledArchive: <Archive className="size-4" />,
5051
bannerImage: <Image className="size-4" />,

apps/web/lib/i18n/locales/ar/translation.json

+27-6
Original file line numberDiff line numberDiff line change
@@ -153,23 +153,44 @@
153153
}
154154
},
155155
"admin": {
156-
"admin_settings": "إعدادات المدير",
156+
"admin_settings": "إعدادات المشرف",
157157
"server_stats": {
158158
"server_stats": "إحصائيات الخادم",
159159
"total_users": "إجمالي المستخدمين",
160160
"total_bookmarks": "إجمالي الإشارات المرجعية",
161161
"server_version": "إصدار الخادم"
162162
},
163163
"background_jobs": {
164-
"background_jobs": "المهام التلقائية",
164+
"background_jobs": "المهام الخلفية",
165165
"crawler_jobs": "مهام الاستكشاف",
166166
"indexing_jobs": "مهام الفهرسة",
167-
"inference_jobs": "مهام التحليل الذكي",
168-
"tidy_assets_jobs": "مهام تنظيم الملفات",
167+
"inference_jobs": "مهام الاستدلال",
168+
"tidy_assets_jobs": "مهام تنظيم الوسائط",
169169
"job": "مهمة",
170170
"queued": "في قائمة الانتظار",
171-
"pending": "معلق",
172-
"failed": "فشل"
171+
"pending": "قيد الانتظار",
172+
"failed": "فشلت"
173+
},
174+
"actions": {
175+
"recrawl_failed_links_only": "إعادة استكشاف الروابط الفاشلة فقط",
176+
"recrawl_all_links": "إعادة استكشاف جميع الروابط",
177+
"without_inference": "بدون استدلال",
178+
"regenerate_ai_tags_for_failed_bookmarks_only": "إعادة إنشاء علامات الذكاء الاصطناعي للإشارات المرجعية الفاشلة فقط",
179+
"regenerate_ai_tags_for_all_bookmarks": "إعادة إنشاء علامات الذكاء الاصطناعي لجميع الإشارات المرجعية",
180+
"reindex_all_bookmarks": "إعادة فهرسة جميع الإشارات المرجعية",
181+
"compact_assets": "ضغط الوسائط",
182+
"reprocess_assets_fix_mode": "إعادة معالجة الوسائط (وضع الإصلاح)"
183+
},
184+
"users_list": {
185+
"users_list": "قائمة المستخدمين",
186+
"create_user": "إنشاء مستخدم",
187+
"change_role": "تغيير الدور",
188+
"reset_password": "إعادة تعيين كلمة المرور",
189+
"delete_user": "حذف المستخدم",
190+
"num_bookmarks": "عدد الإشارات المرجعية",
191+
"asset_sizes": "أحجام الوسائط",
192+
"local_user": "مستخدم محلي",
193+
"confirm_password": "تأكيد كلمة المرور"
173194
}
174195
},
175196
"options": {

apps/web/lib/i18n/locales/da/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@
9494
"recrawl_all_links": "Gennemsøg alle links",
9595
"without_inference": "Uden inferens",
9696
"regenerate_ai_tags_for_all_bookmarks": "Genopret AI-tags for alle bogmærker",
97-
"reindex_all_bookmarks": "Genindeksér alle bogmærker"
97+
"reindex_all_bookmarks": "Genindeksér alle bogmærker",
98+
"reprocess_assets_fix_mode": "Genbehandling af aktiver (Fix Mode)"
9899
},
99100
"background_jobs": {
100101
"inference_jobs": "Inferensopgaver",

apps/web/lib/i18n/locales/de/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@
175175
"regenerate_ai_tags_for_failed_bookmarks_only": "KI-Tags nur für fehlgeschlagene Lesezeichen neu generieren",
176176
"regenerate_ai_tags_for_all_bookmarks": "KI-Tags für alle Lesezeichen neu generieren",
177177
"reindex_all_bookmarks": "Alle Lesezeichen neu indizieren",
178-
"compact_assets": "Assets komprimieren"
178+
"compact_assets": "Assets komprimieren",
179+
"reprocess_assets_fix_mode": "Assets neu verarbeiten (Fix-Modus)"
179180
},
180181
"users_list": {
181182
"users_list": "Benutzerliste",

apps/web/lib/i18n/locales/en/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@
178178
"regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only",
179179
"regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks",
180180
"reindex_all_bookmarks": "Reindex All Bookmarks",
181-
"compact_assets": "Compact Assets"
181+
"compact_assets": "Compact Assets",
182+
"reprocess_assets_fix_mode": "Reprocess Assets (Fix Mode)"
182183
},
183184
"users_list": {
184185
"users_list": "Users List",

apps/web/lib/i18n/locales/es/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@
146146
"compact_assets": "Optimizar multimedia",
147147
"without_inference": "Sin inferencia",
148148
"recrawl_failed_links_only": "Recrawlear solo los enlaces fallidos",
149-
"recrawl_all_links": "Recrawlear todos los enlaces"
149+
"recrawl_all_links": "Recrawlear todos los enlaces",
150+
"reprocess_assets_fix_mode": "Reprocesar assets (modo fijo)"
150151
},
151152
"users_list": {
152153
"users_list": "Lista de usuarios",

apps/web/lib/i18n/locales/fr/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@
146146
"regenerate_ai_tags_for_failed_bookmarks_only": "Régénérer les tags AI uniquement pour les favoris échoués",
147147
"regenerate_ai_tags_for_all_bookmarks": "Régénérer les tags AI pour tous les favoris",
148148
"reindex_all_bookmarks": "Réindexer tous les favoris",
149-
"compact_assets": "Compacter les assets"
149+
"compact_assets": "Compacter les assets",
150+
"reprocess_assets_fix_mode": "Reprocesser les assets (mode fix)"
150151
},
151152
"users_list": {
152153
"users_list": "Liste des utilisateurs",

apps/web/lib/i18n/locales/gl/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@
178178
"regenerate_ai_tags_for_failed_bookmarks_only": "Rexenerar etiquetas IA so en marcadores errados",
179179
"regenerate_ai_tags_for_all_bookmarks": "Rexenerar etiquetas IA para todos os marcadores",
180180
"reindex_all_bookmarks": "Reindexar marcadores",
181-
"compact_assets": "Optimizar multimedia"
181+
"compact_assets": "Optimizar multimedia",
182+
"reprocess_assets_fix_mode": "Reprocesar assets (modo fixo)"
182183
},
183184
"users_list": {
184185
"users_list": "Listado de usuarios",

apps/web/lib/i18n/locales/hr/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"recrawl_all_links": "Ponovno pregledavanje svih veza",
3737
"regenerate_ai_tags_for_all_bookmarks": "Ponovno generiranje AI oznaka za sve oznake",
3838
"without_inference": "Bez zaključivanja",
39-
"compact_assets": "Kompaktiranje resursa"
39+
"compact_assets": "Kompaktiranje resursa",
40+
"reprocess_assets_fix_mode": "Ponovno postupanje s resursima (fiksni mod)"
4041
}
4142
},
4243
"layouts": {

apps/web/lib/i18n/locales/hu/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@
258258
"regenerate_ai_tags_for_all_bookmarks": "Minden könyvjelző MI címkéjének lecserélése",
259259
"regenerate_ai_tags_for_failed_bookmarks_only": "Hibás könyvjelzők MI címkéjének lecserélése",
260260
"reindex_all_bookmarks": "Minden könyvjelző újraindexelése",
261-
"compact_assets": "Kompakt tulajdonok"
261+
"compact_assets": "Kompakt tulajdonok",
262+
"reprocess_assets_fix_mode": "Tulajdonok függvényezése (Fix Mod)"
262263
},
263264
"users_list": {
264265
"asset_sizes": "Tulajdon méretek",

apps/web/lib/i18n/locales/it/translation.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@
201201
"regenerate_ai_tags_for_failed_bookmarks_only": "Rigenera tag AI solo per i segnalibri falliti",
202202
"regenerate_ai_tags_for_all_bookmarks": "Rigenera tag AI per tutti i segnalibri",
203203
"compact_assets": "Compatta asset",
204-
"reindex_all_bookmarks": "Reindicizza tutti i segnalibri"
204+
"reindex_all_bookmarks": "Reindicizza tutti i segnalibri",
205+
"reprocess_assets_fix_mode": "Riprocessa asset (modalità fissa)"
205206
},
206207
"users_list": {
207208
"users_list": "Lista utenti",

0 commit comments

Comments
 (0)