Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ data
# Large local TXT fixtures for resume E2E (not shipped)
samples/TestFile.txt
samples/*.local.txt
# Local comic/book fixtures for manual / optional nav tests
samples/*.zip
samples/*.cbz
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Built with Electron, TypeScript, React, pdf.js, and epub.js.
### Opening & library
- Open books via **File → Open**, toolbar **Open**, or **drag and drop**
- **Comics:** `.zip` / `.cbz` (images inside) or an **image folder** (File → Open Folder)
- **Recent books** (up to 20) with Missing badge and Remove
- **Recent books** (up to 20) with Missing badge, Remove, and **Clear All History**
- **Home (file list):** options toolbar is **hidden automatically**; it returns when a book is open (respects Pin)

### Reading modes
Expand Down Expand Up @@ -51,7 +51,7 @@ Built with Electron, TypeScript, React, pdf.js, and epub.js.
| Larger / smaller font | **Ctrl+Plus** / **Ctrl+-** |
| Next / previous page | Arrows **→/←** (LTR) or **←/→** (RTL comics) |
| First / last page | **Home** / **End** |
| Next / previous book | **PageDown** / **PageUp** (same-folder volume: filename number ±1; tries `.zip`/`.cbz`/`.pdf`/`.epub`/`.txt`) |
| Next / previous book | **PageDown** / **PageUp** (same folder, natural name order among `.zip`/`.cbz`/`.pdf`/`.epub`/`.txt`/images) |
| Go to page | Digits in **Page**, then **Enter** or **Go** (focus returns to reader) |
| Pan zoomed image/PDF | **Left-click drag** |
| Single / two pages | **Ctrl+1** / **Ctrl+2** |
Expand Down
147 changes: 126 additions & 21 deletions electron/comicSession.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,129 @@
import fs from 'node:fs';
import path from 'node:path';
import JSZip from 'jszip';
import yauzl from 'yauzl';
import type { Entry, ZipFile } from 'yauzl';
import { filterAndSortImagePaths, isImageFile } from '../src/shared/comic';

export interface ComicSession {
sourcePath: string;
entries: string[];
kind: 'archive' | 'folder';
zip?: JSZip;
zipfile?: ZipFile;
zipEntryByName?: Map<string, Entry>;
}

let session: ComicSession | null = null;
/** yauzl allows only one openReadStream at a time per zipfile. */
let zipReadChain: Promise<unknown> = Promise.resolve();

export function clearComicSession(): void {
const zipfile = session?.zipfile;
session = null;
zipReadChain = Promise.resolve();
if (zipfile) {
try {
zipfile.close();
} catch {
/* already closed */
}
}
}

export function getComicSession(): ComicSession | null {
return session;
}

export async function openComicArchive(filePath: string): Promise<ComicSession> {
const data = fs.readFileSync(filePath);
const zip = await JSZip.loadAsync(data);
const names: string[] = [];
zip.forEach((relativePath, file) => {
if (file.dir) return;
const base = relativePath.replace(/\\/g, '/');
if (base.includes('__MACOSX/')) return;
if (isImageFile(base)) names.push(base);
function openZipFile(filePath: string): Promise<ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(filePath, { lazyEntries: true, autoClose: false }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error('Failed to open zip archive.'));
return;
}
resolve(zipfile);
});
});
const entries = filterAndSortImagePaths(names);
if (entries.length === 0) {
}

function listImageEntries(zipfile: ZipFile): Promise<Entry[]> {
return new Promise((resolve, reject) => {
const found: Entry[] = [];
zipfile.on('error', reject);
zipfile.on('entry', (entry: Entry) => {
const base = entry.fileName.replace(/\\/g, '/');
if (!base.endsWith('/') && !base.includes('__MACOSX/') && isImageFile(base)) {
found.push(entry);
}
zipfile.readEntry();
});
zipfile.on('end', () => resolve(found));
zipfile.readEntry();
});
}

function readZipEntryBuffer(zipfile: ZipFile, entry: Entry): Promise<Buffer> {
return new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (error, stream) => {
if (error || !stream) {
reject(error ?? new Error('Failed to read zip entry.'));
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
});
}

/**
* Open a ZIP/CBZ without loading the whole file into memory.
* Only the central directory is scanned for image entry names; page bytes are read on demand.
*/
export async function openComicArchive(filePath: string): Promise<ComicSession> {
const zipfile = await openZipFile(filePath);
let imageEntries: Entry[];
try {
imageEntries = await listImageEntries(zipfile);
} catch (error) {
try {
zipfile.close();
} catch {
/* ignore */
}
throw error;
}

const names = filterAndSortImagePaths(imageEntries.map((entry) => entry.fileName.replace(/\\/g, '/')));
if (names.length === 0) {
try {
zipfile.close();
} catch {
/* ignore */
}
throw new Error('No image files found in the archive.');
}
session = { sourcePath: filePath, entries, kind: 'archive', zip };

const byNorm = new Map<string, Entry>();
for (const entry of imageEntries) {
byNorm.set(entry.fileName.replace(/\\/g, '/'), entry);
}
const zipEntryByName = new Map<string, Entry>();
for (const name of names) {
const entry = byNorm.get(name);
if (entry) zipEntryByName.set(name, entry);
}

session = {
sourcePath: filePath,
entries: names,
kind: 'archive',
zipfile,
zipEntryByName,
};
zipReadChain = Promise.resolve();
return session;
}

Expand All @@ -54,20 +143,36 @@ export function openComicFolder(folderPath: string): ComicSession {
return session;
}

/** Open a single image file as a one-page comic (folder-order nav sibling). */
export function openComicImageFile(filePath: string): ComicSession {
if (!isImageFile(filePath)) {
throw new Error('Not an image file.');
}
session = { sourcePath: filePath, entries: [filePath], kind: 'folder' };
return session;
}

export async function readComicPage(index: number): Promise<ArrayBuffer> {
if (!session) throw new Error('No comic is open.');
const entry = session.entries[index];
if (!entry) throw new Error('Page out of range.');
const entryName = session.entries[index];
if (!entryName) throw new Error('Page out of range.');

if (session.kind === 'folder') {
const buf = fs.readFileSync(entry);
const buf = fs.readFileSync(entryName);
return toArrayBuffer(buf);
}

const file = session.zip?.file(entry);
if (!file) throw new Error('Missing page in archive.');
const u8 = await file.async('uint8array');
return toArrayBuffer(u8);
const zipfile = session.zipfile;
const entry = session.zipEntryByName?.get(entryName);
if (!zipfile || !entry) throw new Error('Missing page in archive.');

const read = zipReadChain.then(() => readZipEntryBuffer(zipfile, entry));
zipReadChain = read.then(
() => undefined,
() => undefined,
);
const buf = await read;
return toArrayBuffer(buf);
}

function toArrayBuffer(view: Uint8Array): ArrayBuffer {
Expand Down
67 changes: 54 additions & 13 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { buildBookId } from '../src/shared/bookId';
import { detectFormat, getBookTitle, isSupportedBookFile } from '../src/shared/format';
import { seriesSiblingBasenames } from '../src/shared/seriesSibling';
import { isImageFile } from '../src/shared/comic';
import {
isNavigableBookFileName,
resolveFolderSiblingBasename,
} from '../src/shared/seriesSibling';
import {
GITHUB_LATEST_RELEASE_API,
GITHUB_RELEASES_URL,
Expand All @@ -22,6 +26,7 @@ import {
clearComicSession,
openComicArchive,
openComicFolder,
openComicImageFile,
readComicPage,
} from './comicSession';
import { clearTxtSession, openTxtSession, readTxtPage } from './txtSession';
Expand Down Expand Up @@ -181,16 +186,18 @@ async function openBookFromPath(filePath: string): Promise<OpenBookResult | null
return null;
}

if (!isDirectory && !isSupportedBookFile(filePath)) {
const isImage = !isDirectory && isImageFile(filePath);
if (!isDirectory && !isSupportedBookFile(filePath) && !isImage) {
await dialog.showMessageBox(mainWindow!, {
type: 'warning',
title: 'Unsupported file',
message: 'Only .txt, .pdf, .epub, .zip, and .cbz files (or an image folder) are supported.',
message:
'Only .txt, .pdf, .epub, .zip, .cbz, and image files (or an image folder) are supported.',
});
return null;
}

const format = isDirectory ? 'comic' : detectFormat(filePath);
const format = isDirectory || isImage ? 'comic' : detectFormat(filePath);
if (!format) return null;

const stat = fs.statSync(filePath);
Expand All @@ -210,7 +217,9 @@ async function openBookFromPath(filePath: string): Promise<OpenBookResult | null
try {
const comic = isDirectory
? openComicFolder(filePath)
: await openComicArchive(filePath);
: isImage
? openComicImageFile(filePath)
: await openComicArchive(filePath);
const totalPages = comic.entries.length;
store.upsertRecent({
id,
Expand Down Expand Up @@ -338,7 +347,10 @@ function registerIpc(): void {
title: 'Open Book',
properties: ['openFile'],
filters: [
{ name: 'Books', extensions: ['txt', 'pdf', 'epub', 'zip', 'cbz'] },
{
name: 'Books',
extensions: ['txt', 'pdf', 'epub', 'zip', 'cbz', 'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'],
},
{ name: 'All Files', extensions: ['*'] },
],
});
Expand Down Expand Up @@ -366,16 +378,41 @@ function registerIpc(): void {
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) {
return null;
}
const names = seriesSiblingBasenames(filePath, delta);
if (!names) return null;

let currentIsDirectory = false;
try {
currentIsDirectory = fs.statSync(filePath).isDirectory();
} catch {
return null;
}

const dir = path.dirname(filePath);
for (const name of names) {
const candidate = path.join(dir, name);
if (fs.existsSync(candidate) && isSupportedBookFile(candidate)) {
return candidate;
const base = path.basename(filePath);
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
return null;
}

const candidates: string[] = [];
for (const name of entries) {
const full = path.join(dir, name);
let entryIsDirectory = false;
try {
entryIsDirectory = fs.statSync(full).isDirectory();
} catch {
continue;
}
if (currentIsDirectory) {
if (entryIsDirectory) candidates.push(name);
} else if (!entryIsDirectory && isNavigableBookFileName(name)) {
candidates.push(name);
}
}
return null;

const sibling = resolveFolderSiblingBasename(base, candidates, delta);
return sibling ? path.join(dir, sibling) : null;
},
);

Expand Down Expand Up @@ -416,6 +453,10 @@ function registerIpc(): void {
return store.removeRecent(idOrPath);
});

ipcMain.handle('books:clearRecent', () => {
return store.clearRecent();
});

ipcMain.handle('books:saveSettings', (_event, partial: Partial<AppSettings>) => {
return store.saveSettings(partial);
});
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ElectronApi {
lastByteOffset?: number,
) => Promise<AppState['recentBooks']>;
removeRecent: (idOrPath: string) => Promise<AppState['recentBooks']>;
clearRecent: () => Promise<AppState['recentBooks']>;
saveSettings: (partial: Partial<AppSettings>) => Promise<AppSettings>;
checkForUpdates: () => Promise<UpdateCheckResult>;
openReleasesPage: (url?: string) => Promise<void>;
Expand Down Expand Up @@ -63,6 +64,7 @@ const api: ElectronApi = {
lastByteOffset,
),
removeRecent: (idOrPath) => ipcRenderer.invoke('books:removeRecent', idOrPath),
clearRecent: () => ipcRenderer.invoke('books:clearRecent'),
saveSettings: (partial) => ipcRenderer.invoke('books:saveSettings', partial),
checkForUpdates: () => ipcRenderer.invoke('updates:check'),
openReleasesPage: (url?: string) => ipcRenderer.invoke('updates:openReleases', url),
Expand Down
7 changes: 7 additions & 0 deletions electron/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mergeSettings } from '../src/shared/settings';
import {
markMissingBooks,
removeRecentBook,
clearRecentBooks,
updateRecentProgress,
upsertRecentBook,
} from '../src/shared/recent';
Expand Down Expand Up @@ -80,6 +81,12 @@ export class AppStore {
return [...this.state.recentBooks];
}

clearRecent(): RecentBook[] {
this.state.recentBooks = clearRecentBooks();
this.persist();
return [...this.state.recentBooks];
}

private load(): AppState {
try {
if (!fs.existsSync(this.filePath)) {
Expand Down
Loading
Loading