Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d9aaee5
fix(ui): simplify update banner version text
Gnathonic Feb 21, 2026
624e503
fix(cloud): add WebDAV download retry with resume and compact cloud s…
Gnathonic Feb 21, 2026
87cfc87
Merge pull request #168 from Gnathonic/fix/webdav-retry-compact-cloud
Gnathonic Feb 22, 2026
53c25e0
fix(anki): preserve raw HTML in {existing} template placeholder
Gnathonic Feb 27, 2026
eee3809
fix(import): filter non-image files during archive extraction
Gnathonic Feb 27, 2026
fd8ffc2
fix(sidecars): robust mokuro_version check in sidecar loading
Gnathonic Feb 27, 2026
e9fd579
feat(backup): support separate mokuro sidecar and default to external…
Gnathonic Feb 27, 2026
c160463
feat(covers): improve cover picker with rotation, page ordering, and …
Gnathonic Feb 27, 2026
51b520c
refactor(cloud): extract provider core abstraction for shared upload/…
Gnathonic Feb 28, 2026
a63fd6d
refactor(providers): use core abstraction for uploads and downloads
Gnathonic Feb 28, 2026
f7fdc19
refactor(backup): move sidecar generation into worker and simplify co…
Gnathonic Feb 28, 2026
716d709
feat(covers): add page stitching for multi-page cover crops
Gnathonic Feb 28, 2026
99cb34a
fix(catalog): group series by normalized title instead of UUID
Gnathonic Feb 28, 2026
f7446f5
fix(download): preserve cloud placeholder identity and improve sideca…
Gnathonic Feb 28, 2026
e59c1b4
feat(libraries): auto-upgrade image-only volumes with remote mokuro s…
Gnathonic Feb 28, 2026
a1f19cd
fix(reader): skip only one page from cover in dual page mode
Gnathonic Feb 28, 2026
5ad128d
fix(anki): correct off-by-one in {page_num} template
Gnathonic Feb 28, 2026
5976ab6
fix(anki): use correct page number for dual-page text captures
Gnathonic Feb 28, 2026
28ed886
fix(anki): disable tags in update mode on mobile
Gnathonic Mar 1, 2026
f6ef70a
feat(catalog): add drop shadow toggle and per-volume offset adjustment
Gnathonic Mar 1, 2026
d98b389
feat(import): support spine_width metadata from mokuro files
Gnathonic Mar 1, 2026
251438b
feat: add keyboard shortcuts for volumes on series page
Gnathonic Mar 1, 2026
ec054ae
feat: capture Escape in volume editor modals
Gnathonic Mar 1, 2026
fdcd0cf
chore: bump version to 1.5.0 and add changelog
Gnathonic Mar 1, 2026
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## [1.5.0] - 2026-02-28

### Added

- **WebDAV download retry** - Failed downloads now retry automatically with resume support
- **Easier OCR with mokuro-bunko** - Image-only volumes uploaded to a [mokuro-bunko](https://github.com/Gnathonic/mokuro-bunko) for OCR processing will automatically have their text data added locally on the next refresh.
- **Catalog drop shadow toggle** - Drop shadows on catalog thumbnails can now be disabled, improving the appearance of spine showcase mode
- **Delete cloud-only series** - Remove series from cloud storage directly without needing to download them first
- **Volume keyboard shortcuts** - Hover over a volume on the series page and press E to edit, C to change cover, Delete to remove, or Shift+Delete to delete from cloud
- **Cover page stitching** - Combine two pages side-by-side when cropping a cover, great for spread artwork split across two pages
- **Cover picker improvements** - Rotate images, pages in reading order, and custom covers sync to the cloud automatically
- **Escape closes modals** - Pressing Escape in the volume editor or cover picker closes the modal instead of leaving the series page

### Changed

- **Smarter series grouping** - Local and cloud volumes now always appear together in the catalog even if they were imported separately or have slight differences in naming

### Fixed

- **Dual page cover handling** - Fixed cover page consuming two pages instead of one when dual page mode is set explicitly
- **AnkiConnect page numbers** - Correct page numbers in templates, including dual-page captures
- **AnkiConnect tags on mobile** - Fixed tags appearing in update mode on Android when they should be disabled
- **AnkiConnect card updates** - Updating existing cards now preserves their formatting

## [1.4.0] - 2026-02-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mokuro-reader",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"scripts": {
"dev": "vite dev",
Expand Down
25 changes: 20 additions & 5 deletions src/lib/anki-connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
} from '$lib/settings/settings';
import { settings, DEFAULT_MODEL_CONFIGS } from '$lib/settings';
import { showSnackbar } from '$lib/util';
import { isMobilePlatform } from '$lib/util/platform';
import { get } from 'svelte/store';

export * from './cropper';
Expand Down Expand Up @@ -136,6 +137,7 @@ export function resolveTemplate(
}

let resolved = template;
const existingPlaceholders: string[] = [];

// Replace {selection} with selected text
if (selectedText) {
Expand Down Expand Up @@ -165,9 +167,9 @@ export function resolveTemplate(
resolved = resolved.replace(/\{volume\}/g, '');
}

// Replace {page_num} with page number (1-indexed for display)
// Replace {page_num} with page number (already 1-indexed from callers)
if (options?.pageNumber !== undefined) {
resolved = resolved.replace(/\{page_num\}/g, String(options.pageNumber + 1));
resolved = resolved.replace(/\{page_num\}/g, String(options.pageNumber));
} else {
resolved = resolved.replace(/\{page_num\}/g, '');
}
Expand All @@ -182,7 +184,11 @@ export function resolveTemplate(
// Replace {existing} with existing value of the current field (for update mode)
if (options?.previousValues && options?.fieldName) {
const existingValue = options.previousValues[options.fieldName] || '';
resolved = resolved.replace(/\{existing\}/g, existingValue);
resolved = resolved.replace(/\{existing\}/g, () => {
const token = `__MOKURO_EXISTING_${existingPlaceholders.length}__`;
existingPlaceholders.push(existingValue);
return token;
});
} else {
resolved = resolved.replace(/\{existing\}/g, '');
}
Expand All @@ -196,6 +202,15 @@ export function resolveTemplate(
// Convert newlines to <br> for Anki
resolved = resolved.replace(/\n/g, '<br>');

// Restore raw existing HTML after normalization.
// This prevents converting newlines inside <style> blocks to <br>, which breaks CSS.
if (existingPlaceholders.length > 0) {
resolved = resolved.replace(/__MOKURO_EXISTING_(\d+)__/g, (_match, idx) => {
const index = Number(idx);
return existingPlaceholders[index] ?? '';
});
}

return resolved || null;
}

Expand Down Expand Up @@ -981,8 +996,8 @@ export async function updateLastCard(
return;
}

// Add tags if provided
if (resolvedTags && resolvedTags.length > 0) {
// Add tags if provided (AnkiConnect Android doesn't support addTags, so skip on mobile)
if (resolvedTags && resolvedTags.length > 0 && !isMobilePlatform()) {
await ankiConnect('addTags', { notes: [id], tags: resolvedTags }, { silent: true });
}

Expand Down
11 changes: 8 additions & 3 deletions src/lib/catalog/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ function sortTitles(a: Series, b: Series) {
return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
}

function normalizeSeriesTitle(title: string): string {
return title.trim().replace(/\s+/g, ' ').toLowerCase();
}

export function deriveSeriesFromVolumes(volumeEntries: Array<VolumeMetadata>) {
// Group volumes by series_uuid
// Group volumes by normalized series title (user-visible identity)
const titleMap = new Map<string, Series>();

for (const entry of volumeEntries) {
let volumes = titleMap.get(entry.series_uuid);
const key = normalizeSeriesTitle(entry.series_title);
let volumes = titleMap.get(key);
if (volumes === undefined) {
volumes = {
title: entry.series_title,
series_uuid: entry.series_uuid,
volumes: []
};
titleMap.set(entry.series_uuid, volumes);
titleMap.set(key, volumes);
}
volumes.volumes.push(entry);
}
Expand Down
10 changes: 6 additions & 4 deletions src/lib/catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { deriveSeriesFromVolumes } from '$lib/catalog/catalog';
import { unifiedCloudManager } from '$lib/util/sync/unified-cloud-manager';
import { generatePlaceholders } from '$lib/catalog/placeholders';
import { routeParams } from '$lib/util/hash-router';
import { libraryFilesStore, generateLibraryPlaceholders } from '$lib/util/libraries';
import { libraryFilesStore, libraryMokuroFilesStore, generateLibraryPlaceholders } from '$lib/util/libraries';
import { selectedLibraryId } from '$lib/settings/libraries';

// Single source of truth from the database
Expand All @@ -31,8 +31,8 @@ export const volumes = readable<Record<string, VolumeMetadata>>({}, (set) => {

// Merge local volumes with cloud placeholders and library placeholders
export const volumesWithPlaceholders = derived(
[volumes, unifiedCloudManager.cloudFiles, libraryFilesStore, selectedLibraryId],
([$volumes, $cloudFiles, $libraryFiles, $selectedLibraryId]) => {
[volumes, unifiedCloudManager.cloudFiles, libraryFilesStore, libraryMokuroFilesStore, selectedLibraryId],
([$volumes, $cloudFiles, $libraryFiles, $libraryMokuroFiles, $selectedLibraryId]) => {
const combined = { ...$volumes };
const localVolumes = Object.values($volumes);

Expand All @@ -50,6 +50,7 @@ export const volumesWithPlaceholders = derived(
const allVolumes = Object.values(combined);
const libraryPlaceholders = generateLibraryPlaceholders(
$libraryFiles,
$libraryMokuroFiles,
allVolumes,
$selectedLibraryId
);
Expand All @@ -75,8 +76,9 @@ export const catalog = derived([volumesWithPlaceholders], ([$volumesWithPlacehol
export const currentSeries = derived([routeParams, catalog], ([$routeParams, $catalog]) => {
if (!$catalog || !$routeParams.manga) return [];

const routeKey = $routeParams.manga.trim().replace(/\s+/g, ' ').toLowerCase();
// Primary: match by title (folder name) - handles placeholder→local transition
let series = $catalog.find((s) => s.title === $routeParams.manga);
let series = $catalog.find((s) => s.title.trim().replace(/\s+/g, ' ').toLowerCase() === routeKey);

// Fallback: match by UUID (for legacy URLs)
if (!series) {
Expand Down
100 changes: 93 additions & 7 deletions src/lib/catalog/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { VolumeMetadata } from '$lib/types';
import type { CloudVolumeWithProvider } from '$lib/util/sync/unified-cloud-manager';
import { browser } from '$app/environment';
import { generateDeterministicUUID } from '$lib/util/series-extraction';
import { enqueueCloudOcrUpgrade } from '$lib/util/libraries/library-ocr-upgrade-queue';

/**
* Extract series title from description field
Expand Down Expand Up @@ -99,26 +100,53 @@ export function generatePlaceholders(
}

// Create a set of local volume paths for fast lookup
const localPaths = new Set(
localVolumes.map((vol) => `${vol.series_title}/${vol.volume_title}.cbz`)
);
const localPaths = new Set(localVolumes.map((vol) => `${vol.series_title}/${vol.volume_title}.cbz`));
const localVolumeByPath = new Map<string, VolumeMetadata>();
const localImageOnlyByVolumeTitle = new Map<string, VolumeMetadata[]>();
for (const vol of localVolumes) {
const key = `${vol.series_title}/${vol.volume_title}.cbz`.toLowerCase();
if (!vol.isPlaceholder && !localVolumeByPath.has(key)) {
localVolumeByPath.set(key, vol);
}

const currentMokuroVersion =
typeof vol.mokuro_version === 'string' ? vol.mokuro_version.trim() : '';
if (!vol.isPlaceholder && currentMokuroVersion === '') {
const titleKey = vol.volume_title.toLowerCase();
const existing = localImageOnlyByVolumeTitle.get(titleKey) || [];
existing.push(vol);
localImageOnlyByVolumeTitle.set(titleKey, existing);
}
}

// Create a map of series titles to their UUIDs from local volumes
const seriesTitleToUuid = new Map<string, string>();
for (const vol of localVolumes) {
if (!seriesTitleToUuid.has(vol.series_title)) {
seriesTitleToUuid.set(vol.series_title, vol.series_uuid);
const lowerTitle = vol.series_title.toLowerCase();
if (!seriesTitleToUuid.has(lowerTitle)) {
seriesTitleToUuid.set(lowerTitle, vol.series_uuid);
}
}

// Flatten Map values into a single array and split out .webp sidecars
const cloudFiles: CloudVolumeWithProvider[] = [];
const thumbnailMap = new Map<string, string>(); // basePath -> fileId
const mokuroMap = new Map<string, CloudVolumeWithProvider>(); // basePath -> sidecar metadata
for (const files of cloudFilesMap.values()) {
for (const file of files) {
if (file.path.toLowerCase().endsWith('.webp')) {
const lowerPath = file.path.toLowerCase();
if (lowerPath.endsWith('.webp')) {
const basePath = file.path.replace(/\.webp$/i, '');
thumbnailMap.set(basePath, file.fileId);
} else if (lowerPath.endsWith('.mokuro.gz')) {
const basePath = file.path.replace(/\.mokuro\.gz$/i, '');
// Prefer plain .mokuro over .mokuro.gz when both exist.
if (!mokuroMap.has(basePath)) {
mokuroMap.set(basePath, file);
}
} else if (lowerPath.endsWith('.mokuro')) {
const basePath = file.path.replace(/\.mokuro$/i, '');
mokuroMap.set(basePath, file);
} else {
cloudFiles.push(file);
}
Expand All @@ -137,7 +165,8 @@ export function generatePlaceholders(
// Use existing series UUID if we have local volumes with this series title
// Otherwise generate a deterministic UUID for a new series
const seriesUuid =
seriesTitleToUuid.get(parsed.seriesTitle) || generateDeterministicUUID(parsed.seriesTitle);
seriesTitleToUuid.get(parsed.seriesTitle.toLowerCase()) ||
generateDeterministicUUID(parsed.seriesTitle);

const placeholder = createPlaceholder(cloudFile, seriesUuid);
if (placeholder) {
Expand All @@ -150,6 +179,63 @@ export function generatePlaceholders(
}
}

console.log(
'[Cloud OCR Upgrade] Placeholder matcher scan:',
`cbz=${cloudFiles.length}`,
`mokuro=${mokuroMap.size}`,
`locals=${localPaths.size}`
);

// Auto-upgrade local image-only volumes when matching remote .mokuro sidecar exists.
for (const cloudFile of cloudFiles) {
const parsed = parseCloudPath(cloudFile.path, cloudFile.description);
if (!parsed) continue;

const cloudPathKey = cloudFile.path.toLowerCase();
let localVolume = localVolumeByPath.get(cloudPathKey);
if (!localVolume) {
// Fallback only when series title also matches; never pair by volume title alone.
const candidates = localImageOnlyByVolumeTitle.get(parsed.volumeTitle.toLowerCase()) || [];
const seriesMatches = candidates.filter(
(candidate) => candidate.series_title.toLowerCase() === parsed.seriesTitle.toLowerCase()
);
if (seriesMatches.length === 1) {
localVolume = seriesMatches[0];
} else if (seriesMatches.length > 1 || candidates.length > 0) {
console.log(
'[Cloud OCR Upgrade] Ambiguous local image-only match; skipping fallback:',
parsed.seriesTitle,
parsed.volumeTitle,
`seriesMatches=${seriesMatches.length}`,
`candidates=${candidates.length}`
);
}
}

if (!localVolume) continue;

const currentMokuroVersion =
typeof localVolume.mokuro_version === 'string' ? localVolume.mokuro_version.trim() : '';
if (currentMokuroVersion !== '') continue;

const basePath = cloudFile.path.replace(/\.cbz$/i, '');
const remoteMokuro = mokuroMap.get(basePath);
if (remoteMokuro) {
console.log(
'[Cloud OCR Upgrade] Match found, enqueueing upgrade:',
`${localVolume.series_title}/${localVolume.volume_title}`,
'using',
remoteMokuro.path
);
enqueueCloudOcrUpgrade(localVolume, remoteMokuro);
} else {
console.log(
'[Cloud OCR Upgrade] Local image-only match has no remote mokuro sidecar:',
`${localVolume.series_title}/${localVolume.volume_title}`
);
}
}

return placeholders;
}

Expand Down
16 changes: 8 additions & 8 deletions src/lib/components/Catalog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,13 @@
<!-- Local series -->
<div class="flex flex-col flex-wrap justify-center gap-[3px] sm:flex-row sm:justify-start">
{#if $miscSettings.galleryLayout === 'grid'}
{#each localSeries as { series_uuid, volumes } (series_uuid)}
<CatalogItem {series_uuid} {volumes} providerName={providerDisplayName} />
{#each localSeries as { title, volumes } (title)}
<CatalogItem {volumes} providerName={providerDisplayName} />
{/each}
{:else}
<Listgroup active class="w-full">
{#each localSeries as { series_uuid, volumes } (series_uuid)}
<CatalogListItem {series_uuid} {volumes} providerName={providerDisplayName} />
{#each localSeries as { title, volumes } (title)}
<CatalogListItem {volumes} providerName={providerDisplayName} />
{/each}
</Listgroup>
{/if}
Expand Down Expand Up @@ -352,13 +352,13 @@
class="flex flex-col flex-wrap justify-center gap-[3px] sm:flex-row sm:justify-start"
>
{#if $miscSettings.galleryLayout === 'grid'}
{#each placeholderSeries as { series_uuid, volumes } (series_uuid)}
<CatalogItem {series_uuid} {volumes} providerName={providerDisplayName} />
{#each placeholderSeries as { title, volumes } (title)}
<CatalogItem {volumes} providerName={providerDisplayName} />
{/each}
{:else}
<Listgroup active class="w-full">
{#each placeholderSeries as { series_uuid, volumes } (series_uuid)}
<CatalogListItem {series_uuid} {volumes} providerName={providerDisplayName} />
{#each placeholderSeries as { title, volumes } (title)}
<CatalogListItem {volumes} providerName={providerDisplayName} />
{/each}
</Listgroup>
{/if}
Expand Down
Loading
Loading