From 0e03cec84200b405d9039cc297a1478b4c1c418b Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 28 Mar 2026 01:59:13 -0500 Subject: [PATCH] build ocr --- Dockerfile | 3 + backend/package.json | 1 + backend/src/db.ts | 16 ++- backend/src/index.ts | 2 + backend/src/routes/admin.ts | 52 ++++++++ backend/src/routes/memes.ts | 12 +- backend/src/services/ocr.ts | 47 ++++++++ backend/src/types.ts | 1 + frontend/src/api/client.ts | 11 ++ frontend/src/components/MemeDetail.tsx | 21 +++- frontend/src/components/RescaleModal.tsx | 57 +++++++-- frontend/src/components/SettingsModal.tsx | 140 ++++++++++++++++++++++ frontend/src/hooks/useMemes.ts | 19 +++ frontend/src/pages/Gallery.tsx | 19 ++- 14 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/services/ocr.ts create mode 100644 frontend/src/components/SettingsModal.tsx diff --git a/Dockerfile b/Dockerfile index 787d36c..c4c6579 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,9 @@ RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app +# Tesseract OCR — English language data only (add more langs as needed) +RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng + # Install production deps only COPY backend/package*.json ./ RUN npm install --omit=dev diff --git a/backend/package.json b/backend/package.json index b2533cf..a2c002a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@fastify/static": "^7.0.4", "better-sqlite3": "^9.4.3", "fastify": "^4.27.0", + "node-tesseract-ocr": "^2.2.1", "sharp": "^0.33.4", "uuid": "^9.0.1" }, diff --git a/backend/src/db.ts b/backend/src/db.ts index 6132132..cfefa09 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -34,6 +34,7 @@ db.exec(` height INTEGER NOT NULL, parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE, collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL, + ocr_text TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -54,15 +55,22 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id); `); -// Migration: add collection_id column if upgrading from earlier schema -// Must run BEFORE creating the index on that column +// Migrations — run after CREATE TABLE IF NOT EXISTS so they only apply to existing DBs const memesCols = db.prepare('PRAGMA table_info(memes)').all() as { name: string }[]; + if (!memesCols.find((c) => c.name === 'collection_id')) { db.exec('ALTER TABLE memes ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL'); } -// Create index after the column is guaranteed to exist (handles both fresh and migrated DBs) -db.exec('CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id)'); +if (!memesCols.find((c) => c.name === 'ocr_text')) { + db.exec('ALTER TABLE memes ADD COLUMN ocr_text TEXT'); +} + +// Indexes that depend on migrated columns — created after columns are guaranteed to exist +db.exec(` + CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id); + CREATE INDEX IF NOT EXISTS idx_memes_ocr ON memes(ocr_text) WHERE ocr_text IS NOT NULL; +`); // Seed the default UNSORTED collection const defaultCollection = db diff --git a/backend/src/index.ts b/backend/src/index.ts index 7c2b8c7..b221b55 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import { memesRoutes } from './routes/memes.js'; import { tagsRoutes } from './routes/tags.js'; import { authRoutes } from './routes/auth.js'; import { collectionsRoutes } from './routes/collections.js'; +import { adminRoutes } from './routes/admin.js'; // Ensure data dirs exist ensureImagesDir(); @@ -41,6 +42,7 @@ await app.register(authRoutes); await app.register(collectionsRoutes); await app.register(memesRoutes); await app.register(tagsRoutes); +await app.register(adminRoutes); // SPA fallback — serve index.html for all non-API, non-image routes app.setNotFoundHandler(async (req, reply) => { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..aeb9266 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance } from 'fastify'; +import db from '../db.js'; +import { requireAuth } from '../auth.js'; +import { extractText } from '../services/ocr.js'; +import type { Meme } from '../types.js'; + +export async function adminRoutes(app: FastifyInstance) { + /** + * POST /api/admin/reindex + * Re-runs OCR on every meme that has no ocr_text yet. + * Processes sequentially to avoid hammering the CPU. + * Returns counts so the caller knows progress. + */ + app.post('/api/admin/reindex', { preHandler: requireAuth }, async (_req, reply) => { + const pending = db + .prepare('SELECT id, file_path, mime_type FROM memes WHERE ocr_text IS NULL') + .all() as Pick[]; + + reply.raw.setHeader('Content-Type', 'application/json'); + + let done = 0; + let failed = 0; + + for (const meme of pending) { + const text = await extractText(meme.file_path, meme.mime_type); + if (text) { + db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, meme.id); + done++; + } else { + // Store empty string so it won't be retried on subsequent runs + db.prepare("UPDATE memes SET ocr_text = '' WHERE id = ?").run(meme.id); + failed++; + } + } + + return { total: pending.length, indexed: done, no_text_found: failed }; + }); + + /** + * GET /api/admin/reindex/status + * Returns how many memes still need OCR indexing. + */ + app.get('/api/admin/reindex/status', { preHandler: requireAuth }, async () => { + const { pending } = db + .prepare('SELECT COUNT(*) as pending FROM memes WHERE ocr_text IS NULL') + .get() as { pending: number }; + const { indexed } = db + .prepare("SELECT COUNT(*) as indexed FROM memes WHERE ocr_text IS NOT NULL AND ocr_text != ''") + .get() as { indexed: number }; + return { pending, indexed }; + }); +} diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts index b316b6d..87e63e8 100644 --- a/backend/src/routes/memes.ts +++ b/backend/src/routes/memes.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import db, { UNSORTED_ID } from '../db.js'; import { buildFilePath, deleteFile, getExtension } from '../services/storage.js'; import { extractMeta, resizeImage, saveBuffer } from '../services/image.js'; +import { extractText } from '../services/ocr.js'; import { requireAuth } from '../auth.js'; import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js'; @@ -72,8 +73,8 @@ export async function memesRoutes(app: FastifyInstance) { } if (q) { - conditions.push('(m.title LIKE ? OR m.description LIKE ?)'); - params.push(`%${q}%`, `%${q}%`); + conditions.push('(m.title LIKE ? OR m.description LIKE ? OR m.ocr_text LIKE ?)'); + params.push(`%${q}%`, `%${q}%`, `%${q}%`); } if (conditions.length) { @@ -98,7 +99,7 @@ export async function memesRoutes(app: FastifyInstance) { countParams.push(tag.toLowerCase()); } if (collection_id !== undefined) countParams.push(Number(collection_id)); - if (q) countParams.push(`%${q}%`, `%${q}%`); + if (q) countParams.push(`%${q}%`, `%${q}%`, `%${q}%`); if (countConditions.length) countSql += ' WHERE ' + countConditions.join(' AND '); @@ -159,6 +160,11 @@ export async function memesRoutes(app: FastifyInstance) { if (tagsRaw) setMemeTags(id, tagsRaw.split(',')); + // Fire OCR in the background — doesn't block the upload response + extractText(filePath, mimeType).then((text) => { + if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id); + }); + return reply.status(201).send(getMemeById(id)); }); diff --git a/backend/src/services/ocr.ts b/backend/src/services/ocr.ts new file mode 100644 index 0000000..978c43d --- /dev/null +++ b/backend/src/services/ocr.ts @@ -0,0 +1,47 @@ +import tesseract from 'node-tesseract-ocr'; +import sharp from 'sharp'; +import fs from 'fs'; +import path from 'path'; +import { absolutePath } from './storage.js'; + +const OCR_CONFIG = { + lang: 'eng', + oem: 1, // LSTM neural net mode — best accuracy + psm: 3, // Fully automatic page segmentation (good for varied meme layouts) +}; + +export async function extractText(relPath: string, mimeType: string): Promise { + const srcAbs = absolutePath(relPath); + let inputPath = srcAbs; + let tempPath: string | null = null; + + try { + // Animated GIFs: extract first frame as PNG for Tesseract (it can't read GIF directly) + if (mimeType === 'image/gif') { + tempPath = `${srcAbs}.ocr_tmp.png`; + await sharp(srcAbs, { animated: false }).png().toFile(tempPath); + inputPath = tempPath; + } + + const raw = await tesseract.recognize(inputPath, OCR_CONFIG); + + // Clean up: collapse whitespace, strip lines that are pure noise (< 2 chars) + const cleaned = raw + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length >= 2) + .join(' ') + .replace(/\s{2,}/g, ' ') + .trim(); + + return cleaned; + } catch (err) { + // OCR failure is non-fatal — image still gets saved, just won't be text-searchable + console.warn(`OCR failed for ${relPath}:`, (err as Error).message); + return ''; + } finally { + if (tempPath && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} diff --git a/backend/src/types.ts b/backend/src/types.ts index e5009ff..e7bb107 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -10,6 +10,7 @@ export interface Meme { height: number; parent_id: string | null; collection_id: number | null; + ocr_text: string | null; created_at: string; tags: string[]; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3f5689e..aeb1f45 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,6 +10,7 @@ export interface Meme { height: number; parent_id: string | null; collection_id: number | null; + ocr_text: string | null; created_at: string; tags: string[]; children?: Meme[]; @@ -140,6 +141,16 @@ export const api = { }, }, + admin: { + reindexStatus(): Promise<{ pending: number; indexed: number }> { + return apiFetch('/api/admin/reindex/status'); + }, + + reindex(): Promise<{ total: number; indexed: number; no_text_found: number }> { + return apiFetch('/api/admin/reindex', { method: 'POST' }); + }, + }, + imageUrl(filePath: string): string { return `/images/${filePath}`; }, diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx index ef35304..706388e 100644 --- a/frontend/src/components/MemeDetail.tsx +++ b/frontend/src/components/MemeDetail.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox } from 'lucide-react'; +import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox, ScanText, ChevronDown, ChevronUp } from 'lucide-react'; import { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes'; import { useAuth } from '../hooks/useAuth'; import { SharePanel } from './SharePanel'; @@ -33,6 +33,7 @@ export function MemeDetail({ memeId, onClose }: Props) { const { data: collections } = useCollections(); const [editing, setEditing] = useState(false); + const [ocrExpanded, setOcrExpanded] = useState(false); const [editTitle, setEditTitle] = useState(''); const [editDesc, setEditDesc] = useState(''); const [editTags, setEditTags] = useState(''); @@ -244,6 +245,24 @@ export function MemeDetail({ memeId, onClose }: Props) { )} + {/* OCR Text */} + {meme.ocr_text && ( +
+ + {ocrExpanded && ( +

+ {meme.ocr_text} +

+ )} +
+ )} + {/* Metadata */}

Info

diff --git a/frontend/src/components/RescaleModal.tsx b/frontend/src/components/RescaleModal.tsx index aaade40..e4f9769 100644 --- a/frontend/src/components/RescaleModal.tsx +++ b/frontend/src/components/RescaleModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X, Minimize2 } from 'lucide-react'; +import { X, Minimize2, Link } from 'lucide-react'; import type { Meme } from '../api/client'; import { useRescaleMeme } from '../hooks/useMemes'; @@ -10,20 +10,40 @@ interface Props { } export function RescaleModal({ meme, onClose, onDone }: Props) { + const ratio = meme.width / meme.height; + const [width, setWidth] = useState(''); const [height, setHeight] = useState(''); const [quality, setQuality] = useState('85'); const [label, setLabel] = useState(''); const rescale = useRescaleMeme(); + function handleWidthChange(val: string) { + setWidth(val); + if (val && Number(val) > 0) { + setHeight(String(Math.round(Number(val) / ratio))); + } else { + setHeight(''); + } + } + + function handleHeightChange(val: string) { + setHeight(val); + if (val && Number(val) > 0) { + setWidth(String(Math.round(Number(val) * ratio))); + } else { + setWidth(''); + } + } + + // Send only width to the backend — Sharp derives height from aspect ratio async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!width && !height) return; + if (!width) return; await rescale.mutateAsync({ id: meme.id, - width: width ? Number(width) : undefined, - height: height ? Number(height) : undefined, + width: Number(width), quality: Number(quality), label: label || undefined, }); @@ -31,6 +51,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) { } const isGif = meme.mime_type === 'image/gif'; + const previewLabel = width ? `${width}w` : ''; return (
@@ -50,33 +71,43 @@ export function RescaleModal({ meme, onClose, onDone }: Props) { Creates a new derived image. Original ({meme.width}×{meme.height}) is never modified.

-
-
+ {/* Linked dimension inputs */} +
+
setWidth(e.target.value)} + onChange={(e) => handleWidthChange(e.target.value)} placeholder={String(meme.width)} min={1} + max={meme.width} className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" />
-
+ + {/* Lock icon between fields */} +
+ +
+ +
setHeight(e.target.value)} + onChange={(e) => handleHeightChange(e.target.value)} placeholder={String(meme.height)} min={1} + max={meme.height} className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" />
-

- Aspect ratio is preserved automatically (fit: inside). +

+ + Aspect ratio locked — editing either dimension updates the other.

{!isGif && ( @@ -99,7 +130,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) { type="text" value={label} onChange={(e) => setLabel(e.target.value)} - placeholder={`e.g. "thumb" or "${width || meme.width}w"`} + placeholder={previewLabel || `e.g. "thumb"`} className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" />
@@ -118,7 +149,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) { +
+ +
+ {/* Library Stats */} +
+

+ Library +

+
+ + + +
+
+ +
+ + {/* OCR Re-index */} +
+

+ OCR Index +

+ + {/* Status row */} +
+ {statusLoading ? ( + Checking… + ) : ( +
+
+ Indexed + {status?.indexed ?? 0} +
+
+ Pending + + {status?.pending ?? 0} + +
+
+ )} + +
+ + {/* Result banner */} + {reindexResult && !reindex.isPending && ( +
0 + ? 'bg-amber-900/30 border border-amber-800/50 text-amber-300' + : 'bg-emerald-900/30 border border-emerald-800/50 text-emerald-300' + }`}> + {reindexResult.no_text_found > 0 + ? + : + } + + Processed {reindexResult.total} image{reindexResult.total !== 1 ? 's' : ''}.{' '} + {reindexResult.indexed} indexed + {reindexResult.no_text_found > 0 && <>, {reindexResult.no_text_found} had no readable text}. + +
+ )} + + +

+ Runs OCR on images that haven't been scanned yet. Already-indexed images are skipped. + New uploads are scanned automatically in the background. +

+
+
+
+
+ + ); +} + +function StatCard({ label, value }: { label: string; value: number | string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/frontend/src/hooks/useMemes.ts b/frontend/src/hooks/useMemes.ts index 554e124..cb69e78 100644 --- a/frontend/src/hooks/useMemes.ts +++ b/frontend/src/hooks/useMemes.ts @@ -106,6 +106,25 @@ export function useDeleteMeme() { }); } +export function useReindexStatus() { + return useQuery({ + queryKey: ['admin', 'reindex-status'], + queryFn: () => api.admin.reindexStatus(), + staleTime: 0, + }); +} + +export function useReindex() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.admin.reindex(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'reindex-status'] }); + qc.invalidateQueries({ queryKey: ['memes'] }); + }, + }); +} + export function useRescaleMeme() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/Gallery.tsx b/frontend/src/pages/Gallery.tsx index 1a4ec91..5344670 100644 --- a/frontend/src/pages/Gallery.tsx +++ b/frontend/src/pages/Gallery.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { Search, Upload as UploadIcon, X, Share2, Lock, LogOut } from 'lucide-react'; +import { Search, Upload as UploadIcon, X, Share2, Lock, LogOut, Settings } from 'lucide-react'; import { useMemes, useTags, useCollections } from '../hooks/useMemes'; import { useAuth, useLogout } from '../hooks/useAuth'; import { GalleryGrid } from '../components/GalleryGrid'; @@ -8,6 +8,7 @@ import { UploadModal } from '../components/UploadModal'; import { LoginModal } from '../components/LoginModal'; import { SharePanel } from '../components/SharePanel'; import { CollectionBar } from '../components/CollectionBar'; +import { SettingsModal } from '../components/SettingsModal'; import type { Meme } from '../api/client'; export function Gallery() { @@ -19,6 +20,7 @@ export function Gallery() { const [quickShareMeme, setQuickShareMeme] = useState(null); const [showUpload, setShowUpload] = useState(false); const [showLogin, setShowLogin] = useState(false); + const [showSettings, setShowSettings] = useState(false); const { data: auth } = useAuth(); const logout = useLogout(); @@ -116,6 +118,16 @@ export function Gallery() { Upload + {isAdmin && ( + + )} + {isAdmin ? (
); }