Metadata ve çeviri ile ilgili düzeltmeler. UI değişiklikleri.

This commit is contained in:
2025-11-17 11:03:39 +03:00
parent daf39e35c0
commit b6c9fb795b
14 changed files with 859 additions and 226 deletions

View File

@@ -4,10 +4,11 @@ import cors from 'cors';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { tmpdir } from 'os';
import { join } from 'path';
import { dirname, join } from 'path';
import { promises as fs } from 'fs';
import { v4 as uuidV4 } from 'uuid';
import Epub from 'epub-gen';
import { fileURLToPath } from 'url';
const requiredEnv = [
'SUPABASE_URL',
@@ -22,6 +23,7 @@ requiredEnv.forEach((key) => {
}
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 4000;
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
@@ -260,8 +262,10 @@ app.post('/translate', async (req, res) => {
return res.status(400).json({ message: 'Çevrilecek metin bulunamadı.' });
}
console.log('[Translate] İstek alındı', { length: text.length, snippet: text.slice(0, 60) });
try {
const translated = await translateWithGlm(text);
console.log('[Translate] Çeviri başarıyla döndü');
return res.json({ text: translated });
} catch (error) {
console.error('GLM çeviri hatası:', error);
@@ -275,9 +279,17 @@ app.post('/generate-epub', async (req, res) => {
return res.status(400).json({ message: 'text is required' });
}
const title = meta?.title || 'imgPub OCR Export';
const author = meta?.author || 'imgPub';
const title = meta?.title?.trim() || 'imgPub OCR Export';
const filename = meta?.filename || `imgpub${Date.now()}.epub`;
const authors =
Array.isArray(meta?.authors) && meta.authors.length
? meta.authors.filter(Boolean)
: meta?.author
? [meta.author]
: ['imgPub'];
const publisher = meta?.publisher || 'imgPub';
const language = meta?.language || 'tr';
const description = meta?.description || title;
const content = [
{
@@ -288,6 +300,18 @@ app.post('/generate-epub', async (req, res) => {
const outputPath = join(tmpdir(), `imgpub-${uuidV4()}.epub`);
let coverPath;
const metadataPayload = {
subtitle: meta?.subtitle,
description: meta?.description,
categories: Array.isArray(meta?.categories) ? meta.categories : [],
publishedDate: meta?.publishedDate,
language: meta?.language,
pageCount: meta?.pageCount,
averageRating: meta?.averageRating,
ratingsCount: meta?.ratingsCount,
identifiers: Array.isArray(meta?.identifiers) ? meta.identifiers : [],
infoLink: meta?.infoLink,
};
try {
if (cover?.data) {
@@ -298,7 +322,16 @@ app.post('/generate-epub', async (req, res) => {
await fs.writeFile(coverPath, coverBuffer);
}
const epubOptions = { title, author, content };
const epubOptions = {
title,
author: authors,
publisher,
description,
lang: language,
content,
bookMetadata: metadataPayload,
customOpfTemplatePath: join(__dirname, 'templates', 'content.opf.ejs'),
};
if (coverPath) {
epubOptions.cover = coverPath;
}

View File

@@ -112,6 +112,12 @@ export const translateWithGlm = async (text) => {
};
}
console.log('[GLM] İstek hazırlanıyor', {
endpoint: GLM_API_URL,
model: GLM_MODEL,
snippet: text.slice(0, 80),
});
const response = await fetch(GLM_API_URL, {
method: 'POST',
headers: {
@@ -125,7 +131,20 @@ export const translateWithGlm = async (text) => {
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.error('[GLM] JSON parse başarısız', error);
}
console.log('[GLM] Yanıt alındı', {
status: response.status,
ok: response.ok,
hasOutput: Boolean(payload?.output || payload?.choices || payload?.content),
error: payload?.error,
});
if (!response.ok) {
const message =
payload?.error?.message ||
@@ -136,7 +155,9 @@ export const translateWithGlm = async (text) => {
const translated = extractContent(payload);
if (!translated) {
console.error('[GLM] Boş içerik döndü', payload);
throw new Error('GLM çıktısı boş döndü.');
}
console.log('[GLM] Çeviri tamamlandı');
return translated;
};

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf"
version="3.0"
unique-identifier="BookId"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
xml:lang="en"
xmlns:media="http://www.idpf.org/epub/vocab/overlays/#"
prefix="ibooks: http://vocabulary.itunes.apple.com/rdf/ibooks/vocabulary-extensions-1.0/">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:identifier id="BookId"><%= id %></dc:identifier>
<meta refines="#BookId" property="identifier-type" scheme="onix:codelist5">22</meta>
<meta property="dcterms:identifier" id="meta-identifier">BookId</meta>
<dc:title><%= title %></dc:title>
<% if (bookMetadata && bookMetadata.subtitle) { %>
<meta property="dcterms:alternative"><%= bookMetadata.subtitle %></meta>
<% } %>
<meta property="dcterms:title" id="meta-title"><%= title %></meta>
<dc:language><%= lang || "en" %></dc:language>
<meta property="dcterms:language" id="meta-language"><%= lang || "en" %></meta>
<meta property="dcterms:modified"><%= (new Date()).toISOString().split(".")[0]+ "Z" %></meta>
<dc:creator id="creator"><%= author.length ? author.join(",") : author %></dc:creator>
<meta refines="#creator" property="file-as"><%= author.length ? author.join(",") : author %></meta>
<meta property="dcterms:publisher"><%= publisher || "anonymous" %></meta>
<dc:publisher><%= publisher || "anonymous" %></dc:publisher>
<% var date = new Date(); var year = date.getFullYear(); var month = date.getMonth() + 1; var day = date.getDate(); var stringDate = "" + year + "-" + month + "-" + day; %>
<meta property="dcterms:date"><%= bookMetadata && bookMetadata.publishedDate ? bookMetadata.publishedDate : stringDate %></meta>
<dc:date><%= bookMetadata && bookMetadata.publishedDate ? bookMetadata.publishedDate : stringDate %></dc:date>
<% if (bookMetadata && bookMetadata.description) { %>
<dc:description><%= bookMetadata.description %></dc:description>
<meta property="dcterms:description"><%= bookMetadata.description %></meta>
<% } %>
<% if (bookMetadata && bookMetadata.categories && bookMetadata.categories.length) { bookMetadata.categories.forEach(function(category){ %>
<dc:subject><%= category %></dc:subject>
<% }); } %>
<% if (bookMetadata && bookMetadata.identifiers && bookMetadata.identifiers.length) { bookMetadata.identifiers.forEach(function(identifier, idx){ %>
<meta property="dcterms:identifier" id="extra-id-<%= idx %>"><%= identifier.identifier %></meta>
<% }); } %>
<% if (bookMetadata && bookMetadata.pageCount) { %>
<meta property="schema:pageCount"><%= bookMetadata.pageCount %></meta>
<% } %>
<% if (bookMetadata && bookMetadata.averageRating) { %>
<meta property="schema:ratingValue"><%= bookMetadata.averageRating %></meta>
<% } %>
<% if (bookMetadata && bookMetadata.ratingsCount) { %>
<meta property="schema:ratingCount"><%= bookMetadata.ratingsCount %></meta>
<% } %>
<% if (bookMetadata && bookMetadata.infoLink) { %>
<meta property="dcterms:source"><%= bookMetadata.infoLink %></meta>
<% } %>
<meta property="dcterms:rights">All rights reserved</meta>
<dc:rights>Copyright &#x00A9; <%= (new Date()).getFullYear() %> by <%= publisher || "anonymous" %></dc:rights>
<meta name="cover" content="image_cover"/>
<meta name="generator" content="epub-gen" />
<meta property="ibooks:specified-fonts">true</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml" />
<item id="toc" href="toc.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="css" href="style.css" media-type="text/css" />
<% if(locals.cover) { %>
<item id="image_cover" href="cover.<%= _coverExtension %>" media-type="<%= _coverMediaType %>" />
<% } %>
<% images.forEach(function(image, index){ %>
<item id="image_<%= index %>" href="images/<%= image.id %>.<%= image.extension %>" media-type="<%= image.mediaType %>" />
<% }) %>
<% content.forEach(function(content, index){ %>
<item id="content_<%= index %>_<%= content.id %>" href="<%= content.href %>" media-type="application/xhtml+xml" />
<% }) %>
<% fonts.forEach(function(font, index){%>
<item id="font_<%= index%>" href="fonts/<%= font %>" media-type="application/x-font-ttf" />
<%})%>
</manifest>
<spine toc="ncx">
<% content.forEach(function(content, index){ %>
<% if(content.beforeToc && !content.excludeFromToc){ %>
<itemref idref="content_<%= index %>_<%= content.id %>"/>
<% } %>
<% }) %>
<itemref idref="toc" />
<% content.forEach(function(content, index){ %>
<% if(!content.beforeToc && !content.excludeFromToc){ %>
<itemref idref="content_<%= index %>_<%= content.id %>"/>
<% } %>
<% }) %>
</spine>
<guide>
<reference type="text" title="Table of Content" href="toc.xhtml"/>
</guide>
</package>

View File

@@ -25,6 +25,7 @@ export const wizardSteps = [
{ label: 'Crop', path: '/crop' },
{ label: 'Toplu Crop', path: '/bulk-crop' },
{ label: 'OCR', path: '/ocr' },
{ label: 'Çeviri', path: '/translate' },
{ label: 'EPUB Oluştur', path: '/epub' },
{ label: 'İndir', path: '/download' },
];
@@ -142,11 +143,16 @@ const App = () => {
<Stack direction="row" alignItems="center" justifyContent="space-between" py={1.5}>
<Typography
variant="h4"
onClick={() => {
resetFromStep('upload');
navigate('/');
}}
sx={{
fontFamily: '"Caudex", serif',
color: '#1C1815',
fontWeight: 700,
letterSpacing: 1,
cursor: 'pointer',
}}
>
imagepub

View File

@@ -19,6 +19,7 @@ const BulkCropStep = () => {
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
const setError = useAppStore((state) => state.setError);
const croppedImages = useAppStore((state) => state.croppedImages);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const [processing, setProcessing] = useState(false);
const targetImages = useMemo(
@@ -90,6 +91,12 @@ const BulkCropStep = () => {
if (!targetImages.length) {
return (
<Stack spacing={2}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Alert severity="info">Kapak dışında crop uygulanacak görsel bulunmuyor. Bu adımı geçebilirsin.</Alert>
<Button variant="contained" onClick={() => navigate('/ocr')}>
OCR&apos;ye geç
@@ -100,6 +107,12 @@ const BulkCropStep = () => {
return (
<Stack spacing={4}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Box textAlign="center">
<Typography variant="h5" gutterBottom>
Toplu crop işlemi

View File

@@ -41,6 +41,7 @@ const CropStep = () => {
const coverCropConfig = useAppStore((state) => state.coverCropConfig);
const updateCropConfig = useAppStore((state) => state.updateCropConfig);
const updateCoverCropConfig = useAppStore((state) => state.updateCoverCropConfig);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const setCroppedCoverImage = useAppStore((state) => state.setCroppedCoverImage);
const resetFromStep = useAppStore((state) => state.resetFromStep);
const setError = useAppStore((state) => state.setError);
@@ -290,6 +291,12 @@ const CropStep = () => {
return (
<Stack spacing={4}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Typography variant="h6">Referans görseli seç</Typography>
<Grid container spacing={2}>
{uploadedImages.map((image) => (

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
LinearProgress,
Paper,
Stack,
Typography,
} from '@mui/material';
@@ -22,10 +23,34 @@ const EpubStep = () => {
const setError = useAppStore((state) => state.setError);
const coverImageId = useAppStore((state) => state.coverImageId);
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const bookTitle = useAppStore((state) => state.bookTitle);
const [processing, setProcessing] = useState(false);
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
const translationBlocking = translationStatus === 'running';
const exportText = translatedText?.trim() || ocrText;
const metaForEpub = useMemo(() => {
const fallbackTitle = bookTitle?.trim() || 'imgPub OCR Export';
if (!bookMetadata) {
return { title: fallbackTitle };
}
return {
title: bookMetadata.title || fallbackTitle,
subtitle: bookMetadata.subtitle,
authors: bookMetadata.authors,
publisher: bookMetadata.publisher,
language: bookMetadata.language,
description: bookMetadata.description,
categories: bookMetadata.categories,
publishedDate: bookMetadata.publishedDate,
pageCount: bookMetadata.pageCount,
infoLink: bookMetadata.infoLink,
averageRating: bookMetadata.averageRating,
ratingsCount: bookMetadata.ratingsCount,
identifiers: bookMetadata.identifiers,
filename: bookMetadata.filename,
};
}, [bookMetadata, bookTitle]);
useEffect(() => {
let cancelled = false;
@@ -33,7 +58,7 @@ const EpubStep = () => {
if (!exportText?.trim() || generatedEpub || needsCoverCrop || translationBlocking) return;
setProcessing(true);
try {
const epub = await createEpubFromOcr(exportText, croppedCoverImage);
const epub = await createEpubFromOcr(exportText, croppedCoverImage, metaForEpub);
if (!cancelled) {
setGeneratedEpub(epub);
}
@@ -52,6 +77,7 @@ const EpubStep = () => {
cancelled = true;
};
}, [
metaForEpub,
croppedCoverImage,
exportText,
generatedEpub,
@@ -79,6 +105,73 @@ const EpubStep = () => {
<Typography color="text.secondary">
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
</Typography>
{bookMetadata && (
<Box
sx={{
mt: 3,
mx: 'auto',
maxWidth: 480,
p: 2,
borderRadius: 1.2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
gap: 2,
alignItems: 'center',
}}
>
<Box
sx={{
width: 72,
height: 108,
borderRadius: 0.3,
overflow: 'hidden',
flexShrink: 0,
bgcolor: '#f0ece4',
border: '1px solid',
borderColor: 'divider',
}}
>
{bookMetadata.thumbnail ? (
<Box
component="img"
src={bookMetadata.thumbnail}
alt={`${bookMetadata.title} kapak görseli`}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : croppedCoverImage ? (
<Box
component="img"
src={croppedCoverImage.url}
alt={`${bookMetadata.title} kapak görseli`}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Stack alignItems="center" justifyContent="center" sx={{ height: '100%' }}>
<Typography variant="caption" color="text.secondary">
Kapak yok
</Typography>
</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{bookMetadata.title}
</Typography>
{bookMetadata.authors?.length > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{bookMetadata.authors.join(', ')}
</Typography>
)}
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
{[bookMetadata.publisher, bookMetadata.publishedDate, bookMetadata.pageCount ? `${bookMetadata.pageCount} sayfa` : null]
.filter(Boolean)
.join(' • ')}
</Typography>
</Box>
</Box>
)}
</Box>
{translationBlocking && (
<Alert severity="info">
@@ -108,29 +201,30 @@ const EpubStep = () => {
EPUB hazır: {generatedEpub.filename}
</Alert>
)}
{croppedCoverImage ? (
<Box sx={{ textAlign: 'center' }}>
<Typography variant="subtitle1">Kapak önizlemesi</Typography>
<Box
component="img"
src={croppedCoverImage.url}
alt="Epub kapak görseli"
sx={{
mt: 2,
maxHeight: 260,
width: 'auto',
borderRadius: 2,
boxShadow: 3,
}}
/>
</Box>
) : (
coverImageId && (
<Alert severity="info">
Kapak seçili ancak crop işlemi tamamlanmadı. Crop adımına dönerek kapak kesimini belirle.
</Alert>
)
)}
{!bookMetadata &&
(croppedCoverImage ? (
<Box sx={{ textAlign: 'center' }}>
<Typography variant="subtitle1">Kapak önizlemesi</Typography>
<Box
component="img"
src={croppedCoverImage.url}
alt="Epub kapak görseli"
sx={{
mt: 2,
maxHeight: 260,
width: 'auto',
borderRadius: 2,
boxShadow: 3,
}}
/>
</Box>
) : (
coverImageId && (
<Alert severity="info">
Kapak seçili ancak crop işlemi tamamlanmadı. Crop adımına dönerek kapak kesimini belirle.
</Alert>
)
))}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/ocr')}>

View File

@@ -11,7 +11,6 @@ import { useNavigate } from 'react-router-dom';
import Tesseract from 'tesseract.js';
import { useAppStore } from '../store/useAppStore';
import { correctTurkishCharacters } from '../utils/ocrUtils';
import { translateChunkToTurkish } from '../utils/translationUtils';
const OcrStep = () => {
const navigate = useNavigate();
@@ -20,21 +19,12 @@ const OcrStep = () => {
const ocrText = useAppStore((state) => state.ocrText);
const setOcrText = useAppStore((state) => state.setOcrText);
const setError = useAppStore((state) => state.setError);
const translatedText = useAppStore((state) => state.translatedText);
const translationStatus = useAppStore((state) => state.translationStatus);
const translationError = useAppStore((state) => state.translationError);
const translationProgress = useAppStore((state) => state.translationProgress);
const setTranslatedText = useAppStore((state) => state.setTranslatedText);
const setTranslationStatus = useAppStore((state) => state.setTranslationStatus);
const setTranslationError = useAppStore((state) => state.setTranslationError);
const setTranslationProgress = useAppStore((state) => state.setTranslationProgress);
const clearTranslation = useAppStore((state) => state.clearTranslation);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const [status, setStatus] = useState('idle');
const [translationTrigger, setTranslationTrigger] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0);
const [previewText, setPreviewText] = useState('');
const total = croppedImages.length;
const hasResults = useMemo(() => Boolean(ocrText?.length), [ocrText]);
const abortRef = useRef(false);
const assetBase = useMemo(() => {
@@ -47,7 +37,7 @@ const OcrStep = () => {
const workerRef = useRef(null);
const [workerReady, setWorkerReady] = useState(false);
const previewRef = useRef(null);
const translationPreviewRef = useRef(null);
// removed auto navigation to translation
const orderedImages = useMemo(
() => [...croppedImages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
@@ -140,12 +130,6 @@ const OcrStep = () => {
previewRef.current.scrollTop = previewRef.current.scrollHeight;
}
}, [previewText]);
useEffect(() => {
if (translationPreviewRef.current) {
translationPreviewRef.current.scrollTop = translationPreviewRef.current.scrollHeight;
}
}, [translatedText]);
useEffect(() => {
if (!total || status === 'done' || !workerReady) return;
abortRef.current = false;
@@ -189,58 +173,6 @@ const OcrStep = () => {
};
}, [orderedImages, setError, setOcrText, status, total, workerReady]);
useEffect(() => {
if (status !== 'done') return;
if (!ocrText?.trim()) return;
if (translationStatus === 'running' || translationStatus === 'done') return;
let cancelled = false;
const sections = segmentOcrText(ocrText);
if (!sections.length) return;
const runTranslation = async () => {
setTranslationStatus('running');
setTranslationError(null);
setTranslationProgress(0);
setTranslatedText('');
try {
const translatedChunks = [];
for (let index = 0; index < sections.length; index += 1) {
if (cancelled) return;
const chunk = sections[index];
// eslint-disable-next-line no-await-in-loop
const translated = await translateChunkToTurkish(chunk);
if (cancelled) return;
translatedChunks[index] = translated;
const combined = translatedChunks.filter(Boolean).join('\n\n');
setTranslatedText(combined);
setTranslationProgress(Math.round(((index + 1) / sections.length) * 100));
}
if (!cancelled) {
setTranslationStatus('done');
}
} catch (error) {
if (!cancelled) {
setTranslationStatus('error');
setTranslationError(error.message || 'Çeviri tamamlanamadı.');
}
}
};
runTranslation();
return () => {
cancelled = true;
};
}, [
ocrText,
setTranslatedText,
setTranslationError,
setTranslationProgress,
setTranslationStatus,
status,
translationTrigger,
]);
if (!orderedImages.length) {
return (
@@ -264,6 +196,12 @@ const OcrStep = () => {
return (
<Stack spacing={4}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Box textAlign="center">
<Typography variant="h5">OCR işlemi</Typography>
<Typography color="text.secondary">
@@ -280,99 +218,34 @@ const OcrStep = () => {
{progressText}
</Typography>
</Box>
<Stack spacing={1}>
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle1">Ön izleme</Typography>
<Box
ref={previewRef}
sx={{
mt: 1,
maxHeight: '8.5em',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
fontSize: '0.95rem',
color: 'text.secondary',
pr: 1,
}}
>
{previewText || 'Metin bekleniyor'}
</Box>
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle1">Ön izleme</Typography>
<Box
ref={previewRef}
sx={{
mt: 1,
maxHeight: '8.5em',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
fontSize: '0.95rem',
color: 'text.secondary',
pr: 1,
}}
>
{previewText || 'Metin bekleniyor'}
</Box>
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems="flex-start" justifyContent="space-between">
<Box>
<Typography variant="subtitle1">Türkçe çeviriler</Typography>
<Typography variant="body2" color="text.secondary">
OCR metni parçalara ayrılıp GLM 4.6 ile çevriliyor.
</Typography>
</Box>
{translationStatus === 'error' && (
<Button
size="small"
variant="outlined"
onClick={() => {
clearTranslation();
setTranslationTrigger((prev) => prev + 1);
}}
>
Tekrar dene
</Button>
)}
</Stack>
{translationStatus === 'running' && (
<Box mt={2}>
<LinearProgress
variant="determinate"
value={translationProgress}
sx={{ height: 8, borderRadius: 3 }}
/>
<Typography mt={1} color="text.secondary" variant="caption">
%{translationProgress} tamamlandı
</Typography>
</Box>
)}
{translationStatus === 'done' && translatedText && (
<Alert severity="success" sx={{ mt: 2 }}>
Çeviri tamamlandı. EPUB üretiminde Türkçe içerik kullanılacak.
</Alert>
)}
{translationStatus === 'error' && translationError && (
<Alert severity="error" sx={{ mt: 2 }}>
{translationError}
</Alert>
)}
<Box
ref={translationPreviewRef}
sx={{
mt: 2,
maxHeight: '8.5em',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
fontSize: '0.95rem',
color: 'text.secondary',
pr: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1.5,
p: 1.5,
}}
>
{translatedText || 'Çeviri bekleniyor...'}
</Box>
</Box>
</Stack>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
Geri dön
</Button>
<Button
variant="contained"
onClick={() => navigate('/epub')}
disabled={!hasResults || translationStatus === 'running'}
onClick={() => navigate('/translate')}
disabled={status !== 'done'}
>
EPUB oluştur
Çeviri adımına geç
</Button>
</Stack>
</Stack>
@@ -380,37 +253,3 @@ const OcrStep = () => {
};
export default OcrStep;
const MAX_CHUNK_LENGTH = 800;
const segmentOcrText = (text) => {
if (!text) return [];
const normalized = text.replace(/\r\n/g, '\n');
const paragraphs = normalized.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
const chunks = [];
paragraphs.forEach((paragraph) => {
if (paragraph.length <= MAX_CHUNK_LENGTH) {
chunks.push(paragraph);
return;
}
let remaining = paragraph;
while (remaining.length > MAX_CHUNK_LENGTH) {
let sliceIndex = remaining.lastIndexOf(' ', MAX_CHUNK_LENGTH);
if (sliceIndex === -1 || sliceIndex < MAX_CHUNK_LENGTH * 0.6) {
sliceIndex = MAX_CHUNK_LENGTH;
}
const chunk = remaining.slice(0, sliceIndex).trim();
if (chunk) {
chunks.push(chunk);
}
remaining = remaining.slice(sliceIndex).trim();
}
if (remaining.length) {
chunks.push(remaining);
}
});
return chunks;
};

View File

@@ -0,0 +1,196 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Box, Button, LinearProgress, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { segmentOcrText, translateChunkToTurkish } from '../utils/translationUtils';
const TranslationStep = () => {
const navigate = useNavigate();
const ocrText = useAppStore((state) => state.ocrText);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const translatedText = useAppStore((state) => state.translatedText);
const translationStatus = useAppStore((state) => state.translationStatus);
const translationError = useAppStore((state) => state.translationError);
const translationProgress = useAppStore((state) => state.translationProgress);
const setTranslatedText = useAppStore((state) => state.setTranslatedText);
const setTranslationStatus = useAppStore((state) => state.setTranslationStatus);
const setTranslationError = useAppStore((state) => state.setTranslationError);
const setTranslationProgress = useAppStore((state) => state.setTranslationProgress);
const clearTranslation = useAppStore((state) => state.clearTranslation);
const [trigger, setTrigger] = useState(0);
const previewRef = useRef(null);
useEffect(() => {
if (previewRef.current) {
previewRef.current.scrollTop = previewRef.current.scrollHeight;
}
}, [translatedText]);
useEffect(() => {
if (!ocrText?.trim()) return;
if (!trigger) return;
let cancelled = false;
const sections = segmentOcrText(ocrText);
if (!sections.length) {
setTranslationStatus('error');
setTranslationError('Çevrilecek metin bulunamadı.');
return undefined;
}
const runTranslation = async () => {
setTranslationStatus('running');
setTranslationError(null);
setTranslationProgress(0);
setTranslatedText('');
try {
const translatedChunks = [];
for (let index = 0; index < sections.length; index += 1) {
if (cancelled) return;
const chunk = sections[index];
// eslint-disable-next-line no-await-in-loop
const translated = await translateChunkToTurkish(chunk);
if (cancelled) return;
translatedChunks[index] = translated;
const combined = translatedChunks.filter(Boolean).join('\n\n');
setTranslatedText(combined);
setTranslationProgress(Math.round(((index + 1) / sections.length) * 100));
}
if (!cancelled) {
setTranslationStatus('done');
}
} catch (error) {
if (!cancelled) {
setTranslationStatus('error');
setTranslationError(error.message || 'Çeviri tamamlanamadı.');
}
}
};
runTranslation();
return () => {
cancelled = true;
};
}, [ocrText, setTranslatedText, setTranslationError, setTranslationProgress, setTranslationStatus, trigger]);
const handleStart = () => {
if (!ocrText?.trim()) return;
clearTranslation();
setTrigger((prev) => prev + 1);
};
const handleRetry = () => {
handleStart();
};
const summaryLine = useMemo(() => {
if (!translationStatus || translationStatus === 'idle') {
return 'OCR çıktısı Türkçe\'ye çevrilmek üzere parçalanıyor.';
}
if (translationStatus === 'running') {
return 'GLM 4.6 API ile çeviri devam ediyor.';
}
if (translationStatus === 'done') {
return 'Çeviri tamamlandı. EPUB adımına geçebilirsin.';
}
if (translationStatus === 'error') {
return 'Çeviri sırasında bir sorun oluştu.';
}
return '';
}, [translationStatus]);
if (!ocrText?.trim()) {
return (
<Stack spacing={2}>
<Alert severity="info">Çevrilecek metin bulunamadı. OCR adımını tamamla.</Alert>
<Button variant="contained" onClick={() => navigate('/ocr')}>
OCR adımına dön
</Button>
</Stack>
);
}
return (
<Stack spacing={4}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Box textAlign="center">
<Typography variant="h5">Çeviri</Typography>
<Typography color="text.secondary">{summaryLine}</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} mt={2} justifyContent="center">
<Button
variant="contained"
onClick={handleStart}
disabled={!ocrText?.trim() || translationStatus === 'running'}
>
{translationStatus === 'done' ? 'Çeviriyi Yenile' : 'Çeviriyi Başlat'}
</Button>
</Stack>
</Box>
{translationStatus === 'running' && (
<Box>
<LinearProgress variant="determinate" value={translationProgress} sx={{ height: 10, borderRadius: 5 }} />
<Typography mt={1} align="center" variant="caption" color="text.secondary">
%{translationProgress} tamamlandı
</Typography>
</Box>
)}
{translationStatus === 'error' && translationError && (
<Alert severity="error">
{translationError}
<Button size="small" sx={{ ml: 2 }} variant="outlined" onClick={handleRetry}>
Tekrar dene
</Button>
</Alert>
)}
{translationStatus === 'done' && (
<Alert severity="success">Çeviri tamamlandı. EPUB adımına geçebilirsin.</Alert>
)}
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle1">Çeviri önizlemesi</Typography>
<Box
ref={previewRef}
sx={{
mt: 1,
maxHeight: '8.5em',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
fontSize: '0.95rem',
color: 'text.secondary',
pr: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1.5,
p: 1.5,
}}
>
{translationStatus === 'done'
? translatedText
: translatedText
? translatedText
: 'Çeviri bekleniyor...'}
</Box>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/ocr')}>
OCR&apos;ye dön
</Button>
<Button
variant="contained"
onClick={() => navigate('/epub')}
disabled={!ocrText?.trim() || translationStatus === 'running'}
>
EPUB adımına geç
</Button>
</Stack>
</Stack>
);
};
export default TranslationStep;

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box,
@@ -7,8 +7,12 @@ import {
CardActionArea,
CardContent,
CardMedia,
Divider,
Grid,
LinearProgress,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
@@ -30,6 +34,15 @@ const UploadStep = () => {
const resetFromStep = useAppStore((state) => state.resetFromStep);
const coverImageId = useAppStore((state) => state.coverImageId);
const setCoverImageId = useAppStore((state) => state.setCoverImageId);
const bookTitle = useAppStore((state) => state.bookTitle);
const setBookTitle = useAppStore((state) => state.setBookTitle);
const bookMetadata = useAppStore((state) => state.bookMetadata);
const setBookMetadata = useAppStore((state) => state.setBookMetadata);
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState(null);
const [selectedBookId, setSelectedBookId] = useState(bookMetadata?.id || null);
const skipSearchRef = useRef(false);
const onDrop = useCallback(
(acceptedFiles) => {
@@ -52,6 +65,88 @@ const UploadStep = () => {
setCoverImageId(nextId);
};
useEffect(() => {
setSelectedBookId(bookMetadata?.id || null);
}, [bookMetadata]);
const normalizeVolume = useCallback((volume) => {
const info = volume?.volumeInfo || {};
const identifiers = Array.isArray(info.industryIdentifiers)
? info.industryIdentifiers.map((identifier) => ({
type: identifier?.type,
identifier: identifier?.identifier,
}))
: [];
const thumbnail =
info.imageLinks?.thumbnail?.replace('http://', 'https://') ||
info.imageLinks?.smallThumbnail?.replace('http://', 'https://') ||
null;
return {
id: volume.id,
title: info.title || 'İsimsiz kitap',
subtitle: info.subtitle || '',
authors: info.authors || [],
publisher: info.publisher || '',
publishedDate: info.publishedDate || '',
description: info.description || '',
pageCount: info.pageCount || null,
categories: info.categories || [],
averageRating: info.averageRating || null,
ratingsCount: info.ratingsCount || null,
language: info.language || '',
infoLink: info.infoLink || info.previewLink || '',
identifiers,
thumbnail,
};
}, []);
useEffect(() => {
if (skipSearchRef.current) {
skipSearchRef.current = false;
return;
}
const query = bookTitle?.trim();
if (!query) {
setSearchResults([]);
setSearchError(null);
setSearching(false);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearching(true);
setSearchError(null);
try {
const response = await fetch(
`https://www.googleapis.com/books/v1/volumes?q=intitle:${encodeURIComponent(query)}&maxResults=5&printType=books`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error('Google Books sonuçları alınamadı.');
}
const payload = await response.json();
const items = Array.isArray(payload.items) ? payload.items : [];
const normalized = items.map((item) => normalizeVolume(item));
setSearchResults(normalized);
if (!normalized.length) {
setSearchError('Bu başlıkla eşleşen bir kayıt bulunamadı.');
}
} catch (error) {
if (controller.signal.aborted) return;
setSearchResults([]);
setSearchError(error.message || 'Google Books araması başarısız oldu.');
} finally {
if (!controller.signal.aborted) {
setSearching(false);
}
}
}, 500);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [bookTitle, normalizeVolume]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
@@ -61,8 +156,176 @@ const UploadStep = () => {
multiple: true,
});
const handleTitleChange = (event) => {
const value = event.target.value;
setBookTitle(value);
if (!value?.trim()) {
setBookMetadata(null);
setSelectedBookId(null);
setSearchResults([]);
setSearchError(null);
} else if (bookMetadata && bookMetadata.title !== value) {
setBookMetadata(null);
setSelectedBookId(null);
}
};
const handleSelectBook = (book) => {
skipSearchRef.current = true;
setSelectedBookId(book.id);
setBookMetadata(book);
setBookTitle(book.title || '');
setSearchResults([]);
setSearchError(null);
};
const selectedBookSummary = useMemo(() => {
if (!bookMetadata) return null;
const authorsLine = bookMetadata.authors?.length ? bookMetadata.authors.join(', ') : null;
const details = [
bookMetadata.publisher,
bookMetadata.publishedDate,
bookMetadata.pageCount ? `${bookMetadata.pageCount} sayfa` : null,
]
.filter(Boolean)
.join(' • ');
return { authorsLine, details };
}, [bookMetadata]);
return (
<Stack spacing={4}>
{bookMetadata && (
<Typography variant="body2" color="success.main">
Seçilen kitap: <strong>{bookMetadata.title}</strong>
{bookMetadata.authors?.length ? `${bookMetadata.authors.join(', ')}` : ''}
</Typography>
)}
<Box>
<Typography variant="h6" gutterBottom>
Kitap adı
</Typography>
<TextField
fullWidth
placeholder="Örn. Yapay Zeka İmparatorluğu"
value={bookTitle}
onChange={handleTitleChange}
InputProps={{ sx: { borderRadius: 2 } }}
/>
<Typography variant="body2" color="text.secondary" mt={1}>
Google Books veritabanında arama yapmak için kitap adını yaz. Seçtiğin kaydın tüm meta bilgileri EPUB&apos;a işlenecek.
</Typography>
{searching && <LinearProgress sx={{ mt: 2, borderRadius: 999 }} />}
{searchError && bookTitle.trim() && !searching && (
<Typography variant="body2" color="error.main" mt={1}>
{searchError}
</Typography>
)}
{searchResults.length > 0 && bookTitle?.trim() && (
<Paper variant="outlined" sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ px: 2, pt: 2, pb: 1, color: 'text.secondary' }}>
Google Books sonuçları
</Typography>
<Divider />
<Stack divider={<Divider />} spacing={0}>
{searchResults.map((book) => {
const detailLine = [
book.publisher,
book.publishedDate,
book.pageCount ? `${book.pageCount} sayfa` : null,
]
.filter(Boolean)
.join(' • ');
const ratingLine = [
book.averageRating ? `Puan ${book.averageRating}/5` : null,
book.ratingsCount ? `${book.ratingsCount} oy` : null,
book.language ? book.language.toUpperCase() : null,
]
.filter(Boolean)
.join(' • ');
const isSelected = selectedBookId === book.id;
return (
<Box
key={book.id}
role="button"
onClick={() => handleSelectBook(book)}
sx={{
px: 2,
py: 2,
display: 'flex',
gap: 2,
alignItems: 'flex-start',
cursor: 'pointer',
bgcolor: isSelected ? 'rgba(231,193,121,0.15)' : 'transparent',
transition: 'background-color 0.2s ease',
}}
>
<Box
sx={{
width: 64,
height: 96,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#f0ece4',
flexShrink: 0,
border: '1px solid',
borderColor: 'divider',
}}
>
{book.thumbnail ? (
<Box
component="img"
src={book.thumbnail}
alt={`${book.title} kapak görseli`}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
) : (
<Stack alignItems="center" justifyContent="center" sx={{ height: '100%' }}>
<Typography variant="caption" color="text.secondary">
Kapak yok
</Typography>
</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{book.title}
</Typography>
{book.subtitle && (
<Typography variant="body2" color="text.secondary">
{book.subtitle}
</Typography>
)}
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: 'italic', mt: 0.5 }}
>
{book.authors?.length ? book.authors.join(', ') : 'Yazar bilgisi bulunamadı'}
</Typography>
{detailLine && (
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
{detailLine}
</Typography>
)}
{ratingLine && (
<Typography variant="caption" color="text.secondary" display="block">
{ratingLine}
</Typography>
)}
{book.categories?.length > 0 && (
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
{book.categories.join(', ')}
</Typography>
)}
</Box>
</Box>
);
})}
</Stack>
</Paper>
)}
</Box>
<Box {...getRootProps()} sx={dropzoneStyle}>
<input {...getInputProps()} />
<Typography variant="h5" gutterBottom>

View File

@@ -7,6 +7,7 @@ import UploadStep from './components/UploadStep';
import CropStep from './components/CropStep';
import BulkCropStep from './components/BulkCropStep';
import OcrStep from './components/OcrStep';
import TranslationStep from './components/TranslationStep';
import EpubStep from './components/EpubStep';
import DownloadStep from './components/DownloadStep';
import Login from './pages/auth/Login';
@@ -134,8 +135,9 @@ const router = createBrowserRouter([
{ path: wizardSteps[1].path, element: <CropStep /> },
{ path: wizardSteps[2].path, element: <BulkCropStep /> },
{ path: wizardSteps[3].path, element: <OcrStep /> },
{ path: wizardSteps[4].path, element: <EpubStep /> },
{ path: wizardSteps[5].path, element: <DownloadStep /> },
{ path: wizardSteps[4].path, element: <TranslationStep /> },
{ path: wizardSteps[5].path, element: <EpubStep /> },
{ path: wizardSteps[6].path, element: <DownloadStep /> },
],
},
{ path: '/login', element: <Login /> },

View File

@@ -44,6 +44,8 @@ export const useAppStore = create((set) => ({
coverCropConfig: createEmptyCropConfig(),
croppedCoverImage: null,
ocrText: '',
bookTitle: '',
bookMetadata: null,
translatedText: '',
translationStatus: 'idle',
translationError: null,
@@ -72,6 +74,8 @@ export const useAppStore = create((set) => ({
return { croppedCoverImage: image };
}),
setOcrText: (text) => set({ ocrText: text }),
setBookTitle: (title) => set({ bookTitle: title }),
setBookMetadata: (metadata) => set({ bookMetadata: metadata }),
setTranslatedText: (text) => set({ translatedText: text }),
setTranslationStatus: (status) => set({ translationStatus: status }),
setTranslationError: (message) => set({ translationError: message }),
@@ -147,6 +151,8 @@ export const useAppStore = create((set) => ({
draft.coverCropConfig = createEmptyCropConfig();
draft.croppedCoverImage = null;
draft.ocrText = '';
draft.bookTitle = '';
draft.bookMetadata = null;
draft.translatedText = '';
draft.translationStatus = 'idle';
draft.translationError = null;

View File

@@ -26,7 +26,15 @@ const blobToBase64 = (blob) =>
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
export const createEpubFromOcr = async (text, coverImage) => {
const slugify = (value = '') =>
value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
export const createEpubFromOcr = async (text, coverImage, meta = {}) => {
if (!text?.trim()) {
throw new Error('Önce OCR adımını tamamlamalısın.');
}
@@ -40,15 +48,28 @@ export const createEpubFromOcr = async (text, coverImage) => {
};
}
const resolvedTitle = meta?.title?.trim() || 'imgPub OCR Export';
const authors = Array.isArray(meta?.authors) && meta.authors.length
? meta.authors
: meta?.author
? [meta.author]
: ['imgPub'];
const resolvedSlug = slugify(resolvedTitle) || 'imgpub';
const resolvedFilename = meta?.filename || `${resolvedSlug}-${Date.now()}.epub`;
const metadataPayload = {
...meta,
title: resolvedTitle,
authors,
filename: resolvedFilename,
};
const response = await fetch(`${API_BASE}/generate-epub`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
meta: {
title: 'imgPub OCR Export',
author: 'imgPub',
filename: `imgpub${Date.now()}.epub`,
...metadataPayload,
},
cover: coverPayload,
}),

View File

@@ -23,3 +23,35 @@ export const translateChunkToTurkish = async (text) => {
return payload.text.trim();
};
export const segmentOcrText = (text, maxChunkLength = 800) => {
if (!text) return [];
const normalized = text.replace(/\r\n/g, '\n');
const paragraphs = normalized.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
const chunks = [];
paragraphs.forEach((paragraph) => {
if (paragraph.length <= maxChunkLength) {
chunks.push(paragraph);
return;
}
let remaining = paragraph;
while (remaining.length > maxChunkLength) {
let sliceIndex = remaining.lastIndexOf(' ', maxChunkLength);
if (sliceIndex === -1 || sliceIndex < maxChunkLength * 0.6) {
sliceIndex = maxChunkLength;
}
const chunk = remaining.slice(0, sliceIndex).trim();
if (chunk) {
chunks.push(chunk);
}
remaining = remaining.slice(sliceIndex).trim();
}
if (remaining.length) {
chunks.push(remaining);
}
});
return chunks;
};