Add TXT and MD support with and HTML viewer with react markdown + many refactors to support it

This commit is contained in:
Richard Roberson 2025-04-15 02:28:51 -06:00
parent b0a3e2f632
commit 82bb91a850
18 changed files with 2215 additions and 28 deletions

1508
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,14 +26,17 @@
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"react-pdf": "^9.2.1",
"react-reader": "^2.0.12",
"remark-gfm": "^4.0.1",
"string-similarity": "^4.0.4",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.50.1",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View file

@ -1,6 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "@tailwindcss/typography";
:root,
html.light {

100
src/app/html/[id]/page.tsx Normal file
View file

@ -0,0 +1,100 @@
'use client';
import { useParams } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useHTML } from '@/contexts/HTMLContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer';
import { Button } from '@headlessui/react';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { useTTS } from "@/contexts/TTSContext";
export default function HTMLPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const loadDocument = useCallback(async () => {
if (!isLoading) return;
console.log('Loading new HTML document (from page.tsx)');
stop();
try {
if (!id) {
setError('Document not found');
return;
}
await setCurrentDocument(id as string);
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
} finally {
setIsLoading(false);
}
}, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => {
loadDocument();
}, [loadDocument]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
</Link>
</div>
);
}
return (
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center gap-1">
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
<Button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
aria-label="View Settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>
</div>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<HTMLViewer className="p-4" />
)}
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);
}

View file

@ -8,6 +8,7 @@ import { EPUBProvider } from '@/contexts/EPUBContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext';
export function Providers({ children }: { children: ReactNode }) {
return (
@ -17,7 +18,9 @@ export function Providers({ children }: { children: ReactNode }) {
<TTSProvider>
<PDFProvider>
<EPUBProvider>
{children}
<HTMLProvider>
{children}
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>

View file

@ -11,12 +11,6 @@ import { ProgressPopup } from '@/components/ProgressPopup';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface DocViewSettingsProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
epub?: boolean;
}
const viewTypes = [
{ id: 'single', name: 'Single Page' },
{ id: 'dual', name: 'Two Pages' },
@ -28,7 +22,12 @@ const audioFormats = [
{ id: 'm4b', name: 'M4B' },
];
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
epub?: boolean,
html?: boolean
}) {
const {
viewType,
skipBlank,
@ -203,7 +202,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
</div>}
<div className="space-y-4">
{!epub && <div className="space-y-6">
{!epub && !html && <div className="space-y-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Text extraction margins
@ -347,7 +346,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
</div>}
<div className="space-y-1">
{!html && <div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
@ -360,7 +359,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
<p className="text-sm text-muted pl-6">
Automatically skip pages with no text content
</p>
</div>
</div>}
{epub && (
<div className="space-y-1">
<label className="flex items-center space-x-2">

View file

@ -12,7 +12,11 @@ interface DocumentUploaderProps {
}
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
const {
addPDFDocument: addPDF,
addEPUBDocument: addEPUB,
addHTMLDocument: addHTML
} = useDocuments();
const [isUploading, setIsUploading] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -48,6 +52,8 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
await addPDF(file);
} else if (file.type === 'application/epub+zip') {
await addEPUB(file);
} else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) {
await addHTML(file);
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setIsUploading(false);
setIsConverting(true);
@ -61,13 +67,15 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
setIsUploading(false);
setIsConverting(false);
}
}, [addPDF, addEPUB]);
}, [addHTML, addPDF, addEPUB]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'application/epub+zip': ['.epub'],
'text/plain': ['.txt'],
'text/markdown': ['.md'],
...(isDev ? {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
} : {})
@ -105,7 +113,7 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
</p>
<p className="text-xs sm:text-sm text-muted">
{isDev ? 'PDF, EPUB, and DOCX files are accepted' : 'PDF and EPUB files are accepted'}
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</>

View file

@ -0,0 +1,43 @@
'use client';
import { useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useHTML } from '@/contexts/HTMLContext';
import TTSPlayer from '@/components/player/TTSPlayer';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
interface HTMLViewerProps {
className?: string;
}
export function HTMLViewer({ className = '' }: HTMLViewerProps) {
const { currDocData, currDocName } = useHTML();
const containerRef = useRef<HTMLDivElement>(null);
if (!currDocData) {
return <DocumentSkeleton />;
}
// Check if the file is a txt file
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
return (
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
<div className="z-10">
<TTSPlayer />
</div>
<div className="flex-1 overflow-auto">
<div className={`px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose dark:prose-invert'}`}>
{isTxtFile ? (
currDocData
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currDocData}
</ReactMarkdown>
)}
</div>
</div>
</div>
);
}

View file

@ -31,6 +31,7 @@ const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentLis
if (significant) {
if (significant === 'pdf') return 'PDFs';
if (significant === 'epub') return 'EPUBs';
if (significant === 'txt' || significant === 'md') return 'Documents';
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
}
}
@ -61,6 +62,9 @@ export function DocumentList() {
epubDocs,
removeEPUBDocument: removeEPUB,
isEPUBLoading,
htmlDocs,
removeHTMLDocument: removeHTML,
isHTMLLoading,
} = useDocuments();
useEffect(() => {
@ -112,6 +116,13 @@ export function DocumentList() {
lastModified: doc.lastModified,
type: 'epub' as const,
})),
...htmlDocs.map(doc => ({
id: doc.id,
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
type: 'html' as const,
})),
];
const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
@ -143,8 +154,10 @@ export function DocumentList() {
try {
if (documentToDelete.type === 'pdf') {
await removePDF(documentToDelete.id);
} else {
} else if (documentToDelete.type === 'epub') {
await removeEPUB(documentToDelete.id);
} else if (documentToDelete.type === 'html') {
await removeHTML(documentToDelete.id);
}
// Remove from folders if document is in one
@ -159,7 +172,7 @@ export function DocumentList() {
} catch (err) {
console.error('Failed to remove document:', err);
}
}, [documentToDelete, removePDF, removeEPUB]);
}, [documentToDelete, removePDF, removeEPUB, removeHTML]);
const handleDragStart = useCallback((doc: DocumentListDocument) => {
if (!doc.folderId) {
@ -272,7 +285,7 @@ export function DocumentList() {
}
}, [createFolder]);
if (isPDFLoading || isEPUBLoading) {
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
return <div className="w-full text-center text-muted">Loading documents...</div>;
}

View file

@ -1,7 +1,7 @@
import Link from 'next/link';
import { DragEvent } from 'react';
import { Button } from '@headlessui/react';
import { PDFIcon, EPUBIcon } from '@/components/icons/Icons';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentListDocument } from '@/types/documents';
interface DocumentListItemProps {
@ -52,7 +52,7 @@ export function DocumentListItem({
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
>
<div className="flex-shrink-0">
{doc.type === 'pdf' ? <PDFIcon /> : <EPUBIcon />}
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
</div>
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>

View file

@ -247,3 +247,18 @@ export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={props.className || "w-8 h-8 text-muted"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
);
}

View file

@ -3,7 +3,8 @@
import { createContext, useContext, ReactNode } from 'react';
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
import { useEPUBDocuments } from '@/hooks/epub/useEPUBDocuments';
import { PDFDocument, EPUBDocument } from '@/types/documents';
import { useHTMLDocuments } from '@/hooks/html/useHTMLDocuments';
import { PDFDocument, EPUBDocument, HTMLDocument } from '@/types/documents';
interface DocumentContextType {
// PDF Documents
@ -18,11 +19,19 @@ interface DocumentContextType {
removeEPUBDocument: (id: string) => Promise<void>;
isEPUBLoading: boolean;
// HTML Documents
htmlDocs: HTMLDocument[];
addHTMLDocument: (file: File) => Promise<string>;
removeHTMLDocument: (id: string) => Promise<void>;
isHTMLLoading: boolean;
refreshPDFs: () => Promise<void>;
refreshEPUBs: () => Promise<void>;
refreshHTML: () => Promise<void>;
clearPDFs: () => Promise<void>;
clearEPUBs: () => Promise<void>;
clearHTML: () => Promise<void>;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
@ -46,6 +55,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
clearDocuments: clearEPUBs
} = useEPUBDocuments();
const {
documents: htmlDocs,
addDocument: addHTMLDocument,
removeDocument: removeHTMLDocument,
isLoading: isHTMLLoading,
refresh: refreshHTML,
clearDocuments: clearHTML
} = useHTMLDocuments();
return (
<DocumentContext.Provider value={{
pdfDocs,
@ -56,10 +74,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
addEPUBDocument,
removeEPUBDocument,
isEPUBLoading,
htmlDocs,
addHTMLDocument,
removeHTMLDocument,
isHTMLLoading,
refreshPDFs,
refreshEPUBs,
refreshHTML,
clearPDFs,
clearEPUBs
clearEPUBs,
clearHTML
}}>
{children}
</DocumentContext.Provider>

View file

@ -0,0 +1,187 @@
'use client';
import {
createContext,
useContext,
useState,
ReactNode,
useCallback,
useMemo,
} from 'react';
import { indexedDBService } from '@/utils/indexedDB';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { combineAudioChunks, withRetry } from '@/utils/audio';
interface HTMLContextType {
currDocData: string | undefined;
currDocName: string | undefined;
currDocText: string | undefined;
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
isAudioCombining: boolean;
}
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
/**
* Provider component for HTML/Markdown functionality
* Manages the state and operations for HTML document handling
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function HTMLProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, stop } = useTTS();
const { apiKey, baseUrl, voiceSpeed, voice } = useConfig();
// Current document state
const [currDocData, setCurrDocData] = useState<string>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [isAudioCombining, setIsAudioCombining] = useState(false);
/**
* Clears all current document state and stops any active TTS
*/
const clearCurrDoc = useCallback(() => {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
stop();
}, [stop]);
/**
* Sets the current document based on its ID
* @param {string} id - The unique identifier of the document
* @throws {Error} When document data is empty or retrieval fails
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try {
const doc = await indexedDBService.getHTMLDocument(id);
if (doc) {
setCurrDocName(doc.name);
setCurrDocData(doc.data);
setCurrDocText(doc.data); // Use the same text for TTS
setTTSText(doc.data);
} else {
console.error('Document not found in IndexedDB');
}
} catch (error) {
console.error('Failed to get HTML document:', error);
clearCurrDoc();
}
}, [clearCurrDoc, setTTSText]);
/**
* Creates a complete audiobook from the document text
*/
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
format: 'mp3' | 'm4b' = 'mp3'
): Promise<ArrayBuffer> => {
try {
if (!currDocText) {
throw new Error('No text content found in document');
}
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
const currentTime = 0;
try {
const audioBuffer = await withRetry(
async () => {
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
},
body: JSON.stringify({
text: currDocText,
voice: voice,
speed: voiceSpeed,
format: format === 'm4b' ? 'aac' : 'mp3'
}),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
},
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
}
);
audioChunks.push({
buffer: audioBuffer,
title: currDocName,
startTime: currentTime
});
onProgress(100); // Single chunk, so we're done when it completes
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted');
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
return partialBuffer;
}
throw error;
}
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
} catch (error) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed]);
const contextValue = useMemo(() => ({
currDocData,
currDocName,
currDocText,
setCurrentDocument,
clearCurrDoc,
createFullAudioBook,
isAudioCombining,
}), [
currDocData,
currDocName,
currDocText,
setCurrentDocument,
clearCurrDoc,
createFullAudioBook,
isAudioCombining,
]);
return (
<HTMLContext.Provider value={contextValue}>
{children}
</HTMLContext.Provider>
);
}
/**
* Custom hook to consume the HTML context
* @returns {HTMLContextType} The HTML context value
* @throws {Error} When used outside of HTMLProvider
*/
export function useHTML() {
const context = useContext(HTMLContext);
if (context === undefined) {
throw new Error('useHTML must be used within an HTMLProvider');
}
return context;
}

View file

@ -0,0 +1,96 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { indexedDBService } from '@/utils/indexedDB';
import type { HTMLDocument } from '@/types/documents';
import { useConfig } from '@/contexts/ConfigContext';
export function useHTMLDocuments() {
const { isDBReady } = useConfig();
const [documents, setDocuments] = useState<HTMLDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadDocuments = useCallback(async () => {
if (isDBReady) {
try {
const docs = await indexedDBService.getAllHTMLDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to load HTML documents:', error);
} finally {
setIsLoading(false);
}
}
}, [isDBReady]);
useEffect(() => {
loadDocuments();
}, [loadDocuments]);
const addDocument = useCallback(async (file: File): Promise<string> => {
const id = uuidv4();
const content = await file.text();
const newDoc: HTMLDocument = {
id,
type: 'html',
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: content,
};
try {
await indexedDBService.addHTMLDocument(newDoc);
setDocuments((prev) => [...prev, newDoc]);
return id;
} catch (error) {
console.error('Failed to add HTML document:', error);
throw error;
}
}, []);
const removeDocument = useCallback(async (id: string): Promise<void> => {
try {
await indexedDBService.removeHTMLDocument(id);
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
} catch (error) {
console.error('Failed to remove HTML document:', error);
throw error;
}
}, []);
const refresh = useCallback(async () => {
if (isDBReady) {
setIsLoading(true);
try {
const docs = await indexedDBService.getAllHTMLDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to refresh documents:', error);
} finally {
setIsLoading(false);
}
}
}, [isDBReady]);
const clearDocuments = useCallback(async (): Promise<void> => {
try {
await indexedDBService.clearHTMLDocuments();
setDocuments([]);
} catch (error) {
console.error('Failed to clear HTML documents:', error);
throw error;
}
}, []);
return {
documents,
isLoading,
addDocument,
removeDocument,
refresh,
clearDocuments,
};
}

View file

@ -1,4 +1,4 @@
export type DocumentType = 'pdf' | 'epub' | 'docx';
export type DocumentType = 'pdf' | 'epub' | 'docx' | 'html';
export interface BaseDocument {
id: string;
@ -15,6 +15,11 @@ export interface PDFDocument extends BaseDocument {
data: ArrayBuffer;
}
export interface HTMLDocument extends BaseDocument {
type: 'html';
data: string; // Store as string since it's text content
}
export interface EPUBDocument extends BaseDocument {
type: 'epub';
data: ArrayBuffer;

View file

@ -1,9 +1,10 @@
import { PDFDocument, EPUBDocument, DocumentListState } from '@/types/documents';
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState } from '@/types/documents';
const DB_NAME = 'openreader-db';
const DB_VERSION = 2; // Increased version number
const DB_VERSION = 3; // Increased version for new store
const PDF_STORE_NAME = 'pdf-documents';
const EPUB_STORE_NAME = 'epub-documents';
const HTML_STORE_NAME = 'html-documents';
const CONFIG_STORE_NAME = 'config';
export interface Config {
@ -49,6 +50,11 @@ class IndexedDBService {
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(HTML_STORE_NAME)) {
console.log('Creating HTML documents store...');
db.createObjectStore(HTML_STORE_NAME, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
console.log('Creating EPUB documents store...');
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
@ -206,6 +212,156 @@ class IndexedDBService {
});
}
// Add HTML Document Methods
async addHTMLDocument(document: HTMLDocument): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
console.log('Adding HTML document to IndexedDB:', document.name);
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
const store = transaction.objectStore(HTML_STORE_NAME);
const request = store.put({
...document,
data: document.data
});
request.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('Error adding HTML document:', error);
reject(error);
};
transaction.oncomplete = () => {
console.log('HTML document added successfully:', document.name);
resolve();
};
} catch (error) {
console.error('Error in addHTMLDocument transaction:', error);
reject(error);
}
});
}
async getHTMLDocument(id: string): Promise<HTMLDocument | undefined> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
console.log('Fetching HTML document:', id);
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
const store = transaction.objectStore(HTML_STORE_NAME);
const request = store.get(id);
request.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('Error fetching HTML document:', error);
reject(error);
};
request.onsuccess = () => {
console.log('HTML Document fetch result:', request.result ? 'found' : 'not found');
resolve(request.result);
};
} catch (error) {
console.error('Error in getHTMLDocument transaction:', error);
reject(error);
}
});
}
async getAllHTMLDocuments(): Promise<HTMLDocument[]> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
console.log('Fetching all HTML documents');
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
const store = transaction.objectStore(HTML_STORE_NAME);
const request = store.getAll();
request.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('Error fetching all HTML documents:', error);
reject(error);
};
request.onsuccess = () => {
console.log('Retrieved HTML documents count:', request.result?.length || 0);
resolve(request.result || []);
};
} catch (error) {
console.error('Error in getAllHTMLDocuments transaction:', error);
reject(error);
}
});
}
async removeHTMLDocument(id: string): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
console.log('Removing HTML document:', id);
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
const store = transaction.objectStore(HTML_STORE_NAME);
const request = store.delete(id);
request.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('Error removing HTML document:', error);
reject(error);
};
transaction.oncomplete = () => {
console.log('HTML document removed successfully:', id);
resolve();
};
} catch (error) {
console.error('Error in removeHTMLDocument transaction:', error);
reject(error);
}
});
}
async clearHTMLDocuments(): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
console.log('Clearing all HTML documents');
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
const store = transaction.objectStore(HTML_STORE_NAME);
const request = store.clear();
request.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('Error clearing HTML documents:', error);
reject(error);
};
transaction.oncomplete = () => {
console.log('All HTML documents cleared successfully');
resolve();
};
} catch (error) {
console.error('Error in clearHTMLDocuments transaction:', error);
reject(error);
}
});
}
// Add EPUB Document Methods
async addEPUBDocument(document: EPUBDocument): Promise<void> {
if (!this.db) {

View file

@ -1,4 +1,6 @@
import type { Config } from "tailwindcss";
//@plugin "@tailwindcss/typography";
import typography from "@tailwindcss/typography";
export default {
content: [
@ -44,5 +46,5 @@ export default {
},
},
},
plugins: [],
plugins: [typography],
} satisfies Config;

30
tests/files/sample.txt Normal file
View file

@ -0,0 +1,30 @@
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Phasellus facilisis odio sed mi.
Curabitur suscipit. Nullam vel nisi. Etiam semper ipsum ut lectus. Proin aliquam, erat eget
pharetra commodo, eros mi condimentum quam, sed commodo justo quam ut velit.
Integer a erat. Cras laoreet ligula cursus enim. Aenean scelerisque velit et tellus.
Vestibulum dictum aliquet sem. Nulla facilisi. Vestibulum accumsan ante vitae elit. Nulla
erat dolor, blandit in, rutrum quis, semper pulvinar, enim. Nullam varius congue risus.
Vivamus sollicitudin, metus ut interdum eleifend, nisi tellus pellentesque elit, tristique
accumsan eros quam et risus. Suspendisse libero odio, mattis sit amet, aliquet eget,
hendrerit vel, nulla. Sed vitae augue. Aliquam erat volutpat. Aliquam feugiat vulputate nisl.
Suspendisse quis nulla pretium ante pretium mollis. Proin velit ligula, sagittis at, egestas a,
pulvinar quis, nisl.
Pellentesque sit amet lectus. Praesent pulvinar, nunc quis iaculis sagittis, justo quam
lobortis tortor, sed vestibulum dui metus venenatis est. Nunc cursus ligula. Nulla facilisi.
Phasellus ullamcorper consectetuer ante. Duis tincidunt, urna id condimentum luctus, nibh
ante vulputate sapien, id sagittis massa orci ut enim. Pellentesque vestibulum convallis
sem. Nulla consequat quam ut nisl. Nullam est. Curabitur tincidunt dapibus lorem. Proin
velit turpis, scelerisque sit amet, iaculis nec, rhoncus ac, ipsum. Phasellus lorem arcu,
feugiat eu, gravida eu, consequat molestie, ipsum. Nullam vel est ut ipsum volutpat
feugiat. Aenean pellentesque.
In mauris. Pellentesque dui nisi, iaculis eu, rhoncus in, venenatis ac, ante. Ut odio justo,
scelerisque vel, facilisis non, commodo a, pede. Cras nec massa sit amet tortor volutpat
varius. Donec lacinia, neque a luctus aliquet, pede massa imperdiet ante, at varius lorem
pede sed sapien. Fusce erat nibh, aliquet in, eleifend eget, commodo eget, erat. Fusce
consectetuer. Cras risus tortor, porttitor nec, tristique sed, convallis semper, eros. Fusce
vulputate ipsum a mauris. Phasellus mollis. Curabitur sed urna. Aliquam nec sapien non
nibh pulvinar convallis. Vivamus facilisis augue quis quam. Proin cursus aliquet metus.
Suspendisse lacinia. Nulla at tellus ac turpis eleifend scelerisque. Maecenas a pede vitae
enim commodo interdum. Donec odio. Sed sollicitudin dui vitae justo.