30 Commits

Author SHA1 Message Date
Renovate Bot
79fc921722 Add renovate.json 2025-09-20 07:34:29 +00:00
09f5ca3a99 fix workflow
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m45s
2025-08-28 01:31:03 -07:00
e8a804826a add workflow
Some checks failed
Build and Push Docker Image / docker (push) Has been cancelled
2025-08-28 01:23:33 -07:00
b8cef4d7b3 add postgres integration to save progress across devices 2025-08-28 01:21:28 -07:00
Shaun Tenner
fc88ee9a97 Merge pull request #68 from 4890A/main
set manifest display to fullscreen so PWAs on android can start fullscreen
2025-07-01 09:03:36 +02:00
48
2054f4677c set manifest display to fullscreen 2025-07-01 01:56:47 -04:00
Shaun Tenner
03c7b5e6d9 Merge pull request #64 from Decodatain/image-processing
Enhance image processing settings in AnkiConnect
2025-06-25 20:57:42 +02:00
decodatain
2493555faf Made changes based on ZXY101 code review 2025-06-22 20:16:15 -06:00
decodatain
2fb5d4ae0a Enhance image processing settings in AnkiConnect
- Added height, width, and quality fields to AnkiConnect settings.
- Updated imageToWebp and getCroppedImg functions to accept settings for resizing and quality.
- Integrated settings into Cropper and QuickActions components for improved image handling.

Reason Note:

The recommended settings of 200 and 0.5 make average file sizes of around 5kb. This is about the same filesize as the audio for a given word.

With maximum quality and no file size limit, an image size of 1200x1800 pixels is around 1mb.

This means 1,000 words mined with full sized pictures takes up a 1gb of space on anki.

With the recommended settings, it would take 200,000 words mined to take up a GB of space on anki.

These quality settings can be disabled by settings the max quality to 1 and max width and height to 0. The user is in full control of these changes.
2025-04-26 21:18:55 -06:00
Shaun Tenner
d174113c53 Merge pull request #62 from ZXY101/ZXY101-patch-1
Update +page.svelte
2025-04-15 15:28:26 +02:00
Shaun Tenner
e0b4e3edf0 Update +page.svelte 2025-04-15 15:28:10 +02:00
Shaun Tenner
683a456202 Merge pull request #50 from Gnathonic/improve_compatibility_with_extensions
Improve language extension compatibility
2025-02-07 19:02:32 +02:00
Shaun Tenner
9a4c86870a Merge pull request #49 from v2lmmj04/patch-1
Update underscore prefix for image filenames
2025-02-07 18:38:34 +02:00
Gnathonic
bc5b9ccd16 Add keyed blocks to enhance MangaPage reactivity
Keyed blocks improve DOM update handling. This improves compatibility with some language learning extensions. For example, this prevents Migaku from persisting textboxes from previous pages as one pages through their comics.
2025-01-24 18:19:35 -07:00
v2lmmj04
6c16008aae Change prefix to "mokuro_*"
This was an alternative I was thinking of initially since it better namespaces the files, but wasn't sure about.

I saw this commit ended up going with this approach, so I'm aligning the PR to it in the end:

6d550cbe8a
2025-01-09 19:51:49 -08:00
v2lmmj04
b281052908 Remove underscore prefix for image filenames 2025-01-05 14:51:37 -08:00
Shaun Tenner
52619b1a9b Merge pull request #42 from nyanSpruk/patch-1
Update README.md - typo
2024-11-16 10:18:42 +02:00
Nik Jan Špruk
a30c526a63 Update README.md - typo
Fixed typo from useage to usage.
2024-11-15 20:55:48 +01:00
Shaun Tenner
7fb55f5399 Merge pull request #41 from AnonMiraj/main
Add way to import from other sources
2024-11-14 19:34:43 +02:00
AnonMiraj
6220b6172e Add way to import from other sources 2024-11-13 20:54:10 +02:00
ZXY101
a9f7bca27c Fix title text 2024-08-19 22:02:24 +02:00
Shaun Tenner
0ebeb46171 Merge pull request #23 from kenrick95/kenrick/invert-colors-settings
Settings to invert colors of the images
2024-07-10 23:46:54 +02:00
Kenrick
1fdbdf5185 Settings to invert colors of the images
Useful for novels, for reading in dark mode
2024-07-08 23:15:17 +08:00
ZXY101
109241e584 Add default fullscreen setting 2024-05-25 22:30:16 +02:00
ZXY101
ec632b534b Fix progress 2024-05-18 19:25:19 +02:00
ZXY101
bc7cb721d3 Update loading 2024-05-18 17:26:12 +02:00
ZXY101
5a5dc189fb Improve download 2024-05-18 17:08:40 +02:00
ZXY101
7baf586d3b Merge branch 'main' of https://github.com/ZXY101/z-reader 2024-05-18 16:02:36 +02:00
ZXY101
3e487caad9 Update version 2024-05-18 16:02:19 +02:00
Shaun Tenner
44b8989f2e Merge pull request #17 from ZXY101/gdrive-integration
Add google drive intergration
2024-05-18 15:47:06 +02:00
29 changed files with 1432 additions and 378 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
.coverage
coverage.json
*.lcov
.cache
.parcel-cache
.next
.nuxt
dist
build
.DS_Store
*.tgz
*.tar.gz
.vscode
.idea
*.swp
*.swo
*~

View File

@@ -0,0 +1,64 @@
name: Build and Push Docker Image
on:
push:
branches:
- "main"
tags:
- "v*"
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Ensure full history and all tags are available
fetch-depth: 0
- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.suda.codes/${{ github.repository }}
tags: |
type=ref,event=tag
- name: Determine latest Git tag
id: latest
shell: bash
run: |
set -euo pipefail
# Fetch tags in case the runner's mirror is stale
git fetch --tags --force --quiet || true
if tag=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
else
# Fallback when no tags exist
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: gitea.suda.codes
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: |
${{ steps.meta.outputs.tags }}
gitea.suda.codes/${{ github.repository }}:${{ steps.latest.outputs.tag }}

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 svelte
COPY --from=builder --chown=svelte:nodejs /app/build ./build
COPY --from=builder --chown=svelte:nodejs /app/package.json ./package.json
COPY --from=builder --chown=svelte:nodejs /app/node_modules ./node_modules
USER svelte
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "start"]

View File

@@ -10,7 +10,7 @@ https://github.com/ZXY101/mokuro-reader/assets/39561296/45a214a8-3f69-461c-87d7-
- Anki connect integration & image cropping
- Installation and offline support
## Useage:
## Usage:
You can find the reader hosted [here](https://reader.mokuro.app/).
To import your manga, process it with mokuro and then upload your manga along with the generated `.mokuro` file.

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mokuro:password@postgres:5432/mokuro
- HOSTNAME=0.0.0.0
- PORT=3000
depends_on:
- postgres
restart: unless-stopped
networks:
- mokuro-network
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=mokuro
- POSTGRES_USER=mokuro
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
networks:
- mokuro-network
volumes:
postgres_data:
networks:
mokuro-network:
driver: bridge

969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{
"name": "z-reader",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
@@ -37,6 +38,7 @@
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@types/gapi": "^0.0.47",
"@types/google.accounts": "^0.0.14",
"@types/google.picker": "^0.0.42",
@@ -44,6 +46,7 @@
"@zip.js/zip.js": "^2.7.20",
"dexie": "^4.0.1-alpha.25",
"panzoom": "^9.4.3",
"pg": "^8.16.3",
"svelte-easy-crop": "^2.0.1"
}
}

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -1,6 +1,7 @@
import { showSnackbar } from '$lib/util';
import { writable } from 'svelte/store';
import { blobToBase64 } from '.';
import { blobToBase64, imageResize } from '.';
import { Settings } from '$lib/settings';
type CropperModal = {
open: boolean;
@@ -34,7 +35,7 @@ function getRadianAngle(degreeValue: number) {
export type Pixels = { width: number; height: number; x: number; y: number }
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotation = 0) {
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, settings: Settings, rotation = 0 ) {
const image = await createImage(imageSrc);
const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext('2d');
@@ -72,7 +73,8 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotatio
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
await imageResize(canvas, ctx, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
return await blobToBase64(blob)
}

View File

@@ -1,4 +1,4 @@
import { settings } from "$lib/settings";
import { Settings, settings } from "$lib/settings";
import { showSnackbar } from "$lib/util"
import { get } from "svelte/store";
@@ -50,20 +50,44 @@ export async function blobToBase64(blob: Blob) {
});
}
export async function imageToWebp(source: File) {
export async function imageToWebp(source: File, settings: Settings) {
const image = await createImageBitmap(source);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d");
if (context) {
context.drawImage(image, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
await imageResize(canvas, context, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
image.close();
return await blobToBase64(blob);
}
}
export async function imageResize(canvas: OffscreenCanvas, ctx: OffscreenCanvasRenderingContext2D, maxWidth: number, maxHeight: number): Promise<OffscreenCanvas> {
return new Promise((resolve, reject) => {
const widthRatio = maxWidth <= 0 ? 1 : maxWidth / canvas.width;
const heightRatio = maxHeight <= 0 ? 1 : maxHeight / canvas.height;
const ratio = Math.min(1, Math.min(widthRatio, heightRatio));
if (ratio < 1) {
const newWidth = canvas.width * ratio;
const newHeight = canvas.height * ratio;
createImageBitmap(canvas, { resizeWidth: newWidth, resizeHeight: newHeight, resizeQuality: 'high' })
.then((sprite) => {
canvas.width = newWidth;
canvas.height = newHeight;
ctx.drawImage(sprite, 0, 0);
resolve(canvas);
})
.catch((e) => reject(e));
} else {
resolve(canvas);
}
});
}
export async function updateLastCard(imageData: string | null | undefined, sentence?: string) {
const {
overwriteImage,
@@ -102,7 +126,7 @@ export async function updateLastCard(imageData: string | null | undefined, sente
id,
fields,
picture: {
filename: `_${id}.webp`,
filename: `mokuro_${id}.webp`,
data: imageData.split(';base64,')[1],
fields: [pictureField],
},

View File

@@ -18,7 +18,9 @@
class="object-contain sm:w-[250px] sm:h-[350px] bg-black border-gray-900 border"
/>
{/if}
<p class="font-semibold">{manga.mokuroData.title}</p>
<p class="font-semibold sm:w-[250px] line-clamp-1">
{manga.mokuroData.title}
</p>
</div>
</a>
{/if}

View File

@@ -37,7 +37,7 @@
async function onCrop() {
if ($cropperStore?.image && pixels) {
loading = true;
const imageData = await getCroppedImg($cropperStore.image, pixels);
const imageData = await getCroppedImg($cropperStore.image, pixels, $settings);
updateLastCard(imageData, $cropperStore.sentence);
close();
}

View File

@@ -40,7 +40,7 @@
showCropper(URL.createObjectURL(src));
} else {
promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src);
const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData);
});
}

View File

@@ -23,6 +23,7 @@
import { getCharCount } from '$lib/util/count-chars';
import QuickActions from './QuickActions.svelte';
import { beforeNavigate } from '$app/navigation';
import { onMount } from 'svelte';
// TODO: Refactor this whole mess
export let volumeSettings: VolumeSettings;
@@ -220,7 +221,17 @@
}
}
onMount(() => {
if ($settings.defaultFullscreen) {
document.documentElement.requestFullscreen();
}
});
beforeNavigate(() => {
if (document.exitFullscreen) {
document.exitFullscreen();
}
if (volume) {
const { charCount, lineCount } = getCharCount(pages, page);
@@ -329,14 +340,17 @@
<div
class="flex flex-row"
class:flex-row-reverse={!volumeSettings.rightToLeft}
style:filter={`invert(${$settings.invertColors ? 1 : 0})`}
on:dblclick={onDoubleTap}
role="none"
id="manga-panel"
>
{#if showSecondPage()}
<MangaPage page={pages[index + 1]} src={Object.values(volume?.files)[index + 1]} />
{/if}
<MangaPage page={pages[index]} src={Object.values(volume?.files)[index]} />
{#key page}
{#if showSecondPage()}
<MangaPage page={pages[index + 1]} src={Object.values(volume?.files)[index + 1]} />
{/if}
<MangaPage page={pages[index]} src={Object.values(volume?.files)[index]} />
{/key}
</div>
</Panzoom>
</div>

View File

@@ -54,7 +54,7 @@
showCropper(URL.createObjectURL(src), sentence);
} else {
promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src);
const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData, sentence);
});
}

View File

@@ -13,6 +13,10 @@
let pictureField = $settings.ankiConnectSettings.pictureField;
let sentenceField = $settings.ankiConnectSettings.sentenceField;
let heightField = $settings.ankiConnectSettings.heightField;
let widthField = $settings.ankiConnectSettings.widthField;
let qualityField = $settings.ankiConnectSettings.qualityField;
let triggerMethod = $settings.ankiConnectSettings.triggerMethod;
const triggerOptions = [
@@ -90,5 +94,40 @@
/>
</Label>
</div>
<hr>
<h4>Quality Settings</h4>
<Helper>Allows you to customize the file size stored on your devices</Helper>
<div>
<Label>Max Height (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={heightField}
on:change={() => {updateAnkiSetting('heightField', heightField); if (heightField < 0) heightField = 0;}}
min={0}
/>
</div>
<div>
<Label>Max Width (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={widthField}
on:change={() => {updateAnkiSetting('widthField', widthField); if (widthField < 0) widthField = 0;}}
min={0}
/>
</div>
<div>
<Label>Quality (Between 0 and 1; 0.5 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={qualityField}
on:change={() => updateAnkiSetting('qualityField', qualityField)}
min={0}
max={1}
step="0.1"
/>
</div>
</div>
</AccordionItem>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { AccordionItem, Button, Label, Input, Toggle } from 'flowbite-svelte';
import { syncConfig, pullAll, pushAll } from '$lib/settings/sync';
import { get } from 'svelte/store';
let keyLocal: string = get(syncConfig).key || '';
function saveKey() {
syncConfig.update((cfg) => ({ ...cfg, key: keyLocal || null }));
}
function toggleAuto() {
syncConfig.update((cfg) => ({ ...cfg, auto: !cfg.auto }));
}
function toggleNotifications() {
syncConfig.update((cfg) => ({ ...cfg, notifications: !cfg.notifications }));
}
async function onPull() {
try {
await pullAll();
} catch (e) {}
}
async function onPush() {
try {
await pushAll();
} catch (e) {}
}
</script>
<AccordionItem>
<span slot="header">Cloud Sync</span>
<div class="flex flex-col gap-4">
<div>
<Label>Sync Key</Label>
<div class="flex gap-2">
<Input class="flex-1" bind:value={keyLocal} placeholder="Enter a shared key" />
<Button on:click={saveKey}>Save</Button>
</div>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).auto} on:change={toggleAuto} />
<Label>Auto Sync</Label>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).notifications} on:change={toggleNotifications} />
<Label>Show Sync Notifications</Label>
</div>
<div class="flex gap-2">
<Button on:click={onPull} outline>Pull from cloud</Button>
<Button on:click={onPush} outline>Push to cloud</Button>
</div>
<p class="text-sm opacity-80">Use the same sync key across devices to share progress and settings. Manga files remain local or in Google Drive.</p>
</div>
</AccordionItem>

View File

@@ -3,6 +3,7 @@
import { Toggle } from 'flowbite-svelte';
$: toggles = [
{ key: 'defaultFullscreen', text: 'Open reader in fullscreen', value: $settings.defaultFullscreen },
{ key: 'textEditable', text: 'Editable text', value: $settings.textEditable },
{ key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders },
{ key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR },
@@ -12,7 +13,8 @@
{ key: 'bounds', text: 'Bounds', value: $settings.bounds },
{ key: 'mobile', text: 'Mobile', value: $settings.mobile },
{ key: 'showTimer', text: 'Show timer', value: $settings.showTimer },
{ key: 'quickActions', text: 'Show quick actions', value: $settings.quickActions }
{ key: 'quickActions', text: 'Show quick actions', value: $settings.quickActions },
{ key: 'invertColors', text: 'Invert colors of the images', value: $settings.invertColors }
] as { key: SettingsKey; text: string; value: any }[];
</script>

View File

@@ -13,6 +13,7 @@
import VolumeSettings from './Volume/VolumeSettings.svelte';
import About from './About.svelte';
import QuickAccess from './QuickAccess.svelte';
import CloudSync from './CloudSync.svelte';
import { beforeNavigate } from '$app/navigation';
let transitionParams = {
@@ -33,7 +34,8 @@
}
beforeNavigate((nav) => {
if (!hidden) {
// Only block navigation if settings drawer is open and we're trying to navigate away
if (!hidden && nav.type !== 'popstate') {
nav.cancel();
hidden = true;
}
@@ -63,6 +65,7 @@
<VolumeDefaults />
{/if}
<Profiles {onClose} />
<CloudSync />
<ReaderSettings />
<AnkiConnectSettings />
<CatalogSettings />

View File

@@ -1 +1 @@
export const READER_VERSION = '0.9.0'
export const READER_VERSION = '0.9.1'

77
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Pool } from 'pg';
import { env } from '$env/dynamic/private';
let pool: Pool | undefined;
function getPool(): Pool {
if (!pool) {
const connectionString = env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
pool = new Pool({ connectionString, max: 5 });
}
return pool;
}
export async function ensureSchema(): Promise<void> {
const client = await getPool().connect();
try {
await client.query(
`create table if not exists user_sync (
sync_key text primary key,
profiles jsonb,
current_profile text,
misc_settings jsonb,
volumes jsonb,
updated_at timestamptz default now()
)`
);
} finally {
client.release();
}
}
export type SyncRow = {
sync_key: string;
profiles: any | null;
current_profile: string | null;
misc_settings: any | null;
volumes: any | null;
updated_at: string;
};
export async function getSyncRow(syncKey: string): Promise<SyncRow | null> {
await ensureSchema();
const { rows } = await getPool().query('select * from user_sync where sync_key = $1', [syncKey]);
return rows[0] ?? null;
}
export async function upsertSyncRow(
syncKey: string,
data: Partial<Omit<SyncRow, 'sync_key' | 'updated_at'>>
): Promise<SyncRow> {
await ensureSchema();
const { rows } = await getPool().query(
`insert into user_sync (sync_key, profiles, current_profile, misc_settings, volumes)
values ($1, $2, $3, $4, $5)
on conflict (sync_key) do update set
profiles = coalesce(excluded.profiles, user_sync.profiles),
current_profile = coalesce(excluded.current_profile, user_sync.current_profile),
misc_settings = coalesce(excluded.misc_settings, user_sync.misc_settings),
volumes = coalesce(excluded.volumes, user_sync.volumes),
updated_at = now()
returning *`,
[
syncKey,
data.profiles ?? null,
data.current_profile ?? null,
data.misc_settings ?? null,
data.volumes ?? null
]
);
return rows[0];
}

View File

@@ -1,3 +1,4 @@
export * from './volume-data'
export * from './settings'
export * from './misc'
export * from './sync'

View File

@@ -28,6 +28,9 @@ export type AnkiConnectSettings = {
enabled: boolean;
pictureField: string;
sentenceField: string;
heightField: number;
widthField: number;
qualityField: number;
cropImage: boolean;
overwriteImage: boolean;
grabSentence: boolean;
@@ -41,6 +44,7 @@ export type VolumeDefaults = {
}
export type Settings = {
defaultFullscreen: boolean;
textEditable: boolean;
textBoxBorders: boolean;
displayOCR: boolean;
@@ -56,6 +60,7 @@ export type Settings = {
quickActions: boolean;
fontSize: FontSize;
zoomDefault: ZoomModes;
invertColors: boolean;
volumeDefaults: VolumeDefaults;
ankiConnectSettings: AnkiConnectSettings;
};
@@ -67,6 +72,7 @@ export type AnkiSettingsKey = keyof AnkiConnectSettings;
export type VolumeDefaultsKey = keyof VolumeDefaults;
const defaultSettings: Settings = {
defaultFullscreen: false,
displayOCR: true,
textEditable: false,
textBoxBorders: false,
@@ -82,6 +88,7 @@ const defaultSettings: Settings = {
quickActions: true,
fontSize: 'auto',
zoomDefault: 'zoomFitToScreen',
invertColors: false,
volumeDefaults: {
singlePageView: false,
rightToLeft: true,
@@ -94,6 +101,9 @@ const defaultSettings: Settings = {
overwriteImage: true,
pictureField: 'Picture',
sentenceField: 'Sentence',
heightField: 0,
widthField: 0,
qualityField: 1,
triggerMethod: 'both'
}
};

109
src/lib/settings/sync.ts Normal file
View File

@@ -0,0 +1,109 @@
import { browser } from '$app/environment';
import { derived, get, writable } from 'svelte/store';
import { debounce, showSnackbar } from '$lib/util';
import { currentProfile, profiles } from './settings';
import { miscSettings } from './misc';
import { volumes } from './volume-data';
export type SyncConfig = {
key: string | null;
auto: boolean;
notifications: boolean;
};
const stored = browser ? window.localStorage.getItem('syncConfig') : undefined;
const initial: SyncConfig = stored ? JSON.parse(stored) : { key: null, auto: false, notifications: true };
export const syncConfig = writable<SyncConfig>(initial);
syncConfig.subscribe((value) => {
if (browser) {
window.localStorage.setItem('syncConfig', JSON.stringify(value));
}
});
export const isSyncEnabled = derived(syncConfig, ($cfg) => Boolean($cfg.key));
async function callApi(method: 'GET' | 'POST', payload?: any) {
if (!browser) return null;
const url = method === 'GET' ? `/api/sync?key=${encodeURIComponent(get(syncConfig).key || '')}` : '/api/sync';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function pullAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const data = await callApi('GET');
if (!data) return;
if (data.profiles) {
profiles.set(data.profiles);
}
if (data.current_profile) {
currentProfile.set(data.current_profile);
}
if (data.misc_settings) {
miscSettings.set(data.misc_settings);
}
if (data.volumes) {
volumes.set(data.volumes);
}
if (cfg.notifications) {
showSnackbar('Pulled from server');
}
}
export async function pushAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const payload = {
key: cfg.key,
profiles: get(profiles),
current_profile: get(currentProfile),
misc_settings: get(miscSettings),
volumes: get(volumes)
};
await callApi('POST', payload);
if (cfg.notifications) {
showSnackbar('Pushed to server');
}
}
function setupAutoSync() {
if (!browser) return;
let unsubscribeFns: Array<() => void> = [];
function resubscribe() {
unsubscribeFns.forEach((fn) => fn());
unsubscribeFns = [];
if (!get(syncConfig).auto || !get(syncConfig).key) return;
const schedulePush = () => debounce(() => {
pushAll().catch((error) => {
console.error('Auto-sync failed:', error);
});
}, 300);
unsubscribeFns.push(profiles.subscribe(() => schedulePush()));
unsubscribeFns.push(currentProfile.subscribe(() => schedulePush()));
unsubscribeFns.push(miscSettings.subscribe(() => schedulePush()));
unsubscribeFns.push(volumes.subscribe(() => schedulePush()));
}
resubscribe();
unsubscribeFns.push(syncConfig.subscribe(() => resubscribe()));
}
if (browser) {
setupAutoSync();
}

View File

@@ -0,0 +1,29 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { getSyncRow, upsertSyncRow } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const syncKey = url.searchParams.get('key');
if (!syncKey) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await getSyncRow(syncKey);
if (!row) {
return json({ profiles: null, current_profile: null, misc_settings: null, volumes: null, updated_at: null });
}
return json(row);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { key, profiles, current_profile, misc_settings, volumes } = body ?? {};
if (!key) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await upsertSyncRow(key, { profiles, current_profile, misc_settings, volumes });
return json(row);
};

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { processFiles } from '$lib/upload';
import Loader from '$lib/components/Loader.svelte';
import { showSnackbar, uploadFile } from '$lib/util';
import { Button } from 'flowbite-svelte';
import { formatBytes, showSnackbar, uploadFile } from '$lib/util';
import { Button, P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte';
import { promptConfirmation } from '$lib/util';
import { GoogleSolid } from 'flowbite-svelte-icons';
@@ -22,11 +22,71 @@
const type = 'application/json';
let tokenClient: any;
let loadingMessage = '';
let accessToken = '';
let gapiLoaded = false;
let googleLoaded = false;
let readerFolderId = '';
let volumeDataId = '';
let profilesId = '';
let accessToken = '';
let loadingMessage = '';
let errorMessage = '';
let completed = 0;
let totalSize = 0;
$: progress = Math.floor((completed / totalSize) * 100).toString();
function xhrDownloadFileId(fileId: string) {
return new Promise<Blob>((resolve, reject) => {
if (!gapiLoaded || !gapi.auth.getToken()) {
reject(new Error('Not authenticated'));
return;
}
const { access_token } = gapi.auth.getToken();
const xhr = new XMLHttpRequest();
completed = 0;
totalSize = 0;
xhr.open('GET', `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`);
xhr.setRequestHeader('Authorization', `Bearer ${access_token}`);
xhr.responseType = 'blob';
xhr.onprogress = ({ loaded, total }) => {
loadingMessage = '';
completed = loaded;
totalSize = total;
};
xhr.onabort = (event) => {
console.warn(`xhr ${fileId}: download aborted at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Download aborted'));
};
xhr.onerror = (event) => {
console.error(`xhr ${fileId}: download error at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Error downloading file'));
};
xhr.onload = () => {
completed = 0;
totalSize = 0;
resolve(xhr.response);
};
xhr.ontimeout = (event) => {
console.warn(`xhr ${fileId}: download timeout after ${event.loaded} of ${event.total}`);
showSnackbar('Download timed out');
reject(new Error('Timout downloading file'));
};
xhr.send();
});
}
async function connectDrive(resp?: any) {
if (resp?.error !== undefined) {
@@ -36,50 +96,61 @@
accessToken = resp?.access_token;
loadingMessage = 'Connecting to drive';
const { result: readerFolderRes } = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`,
fields: 'files(id)'
});
if (readerFolderRes.files?.length === 0) {
const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
resource: { mimeType: FOLDER_MIME_TYPE, name: READER_FOLDER },
fields: 'id'
try {
const { result: readerFolderRes } = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`,
fields: 'files(id)'
});
readerFolderId = createReaderFolderRes.id || '';
} else {
const id = readerFolderRes.files?.[0]?.id || '';
if (readerFolderRes.files?.length === 0) {
const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
resource: { mimeType: FOLDER_MIME_TYPE, name: READER_FOLDER },
fields: 'id'
});
readerFolderId = id || '';
}
readerFolderId = createReaderFolderRes.id || '';
} else {
const id = readerFolderRes.files?.[0]?.id || '';
readerFolderId = id || '';
}
const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)'
});
const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)'
});
if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || '';
}
if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || '';
}
const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)'
});
const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)'
});
if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || '';
}
if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || '';
}
loadingMessage = '';
loadingMessage = '';
errorMessage = '';
if (accessToken) {
showSnackbar('Connected to Google Drive');
if (accessToken) {
showSnackbar('Connected to Google Drive');
}
} catch (error) {
console.error('Error connecting to Drive:', error);
errorMessage = 'Failed to connect to Google Drive. Please check your credentials.';
loadingMessage = '';
}
}
function signIn() {
if (!gapiLoaded || !googleLoaded) {
errorMessage = 'Google APIs not loaded yet. Please wait and try again.';
return;
}
if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' });
} else {
@@ -88,23 +159,50 @@
}
onMount(() => {
gapi.load('client', async () => {
await gapi.client.init({
apiKey: API_KEY,
discoveryDocs: [DISCOVERY_DOC]
// Check if environment variables are set
if (!CLIENT_ID || !API_KEY) {
errorMessage = 'Google Drive integration not configured. Please set VITE_GDRIVE_CLIENT_ID and VITE_GDRIVE_API_KEY environment variables.';
return;
}
// Load Google APIs
if (typeof gapi !== 'undefined') {
gapiLoaded = true;
gapi.load('client', async () => {
try {
await gapi.client.init({
apiKey: API_KEY,
discoveryDocs: [DISCOVERY_DOC]
});
} catch (error) {
console.error('Error initializing gapi client:', error);
errorMessage = 'Failed to initialize Google Drive client.';
}
});
});
gapi.load('picker', () => {});
gapi.load('picker', () => {});
} else {
errorMessage = 'Google APIs not loaded. Please check your internet connection.';
}
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: CLIENT_ID,
scope: SCOPES,
callback: connectDrive
});
if (typeof google !== 'undefined') {
googleLoaded = true;
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: CLIENT_ID,
scope: SCOPES,
callback: connectDrive
});
} else {
errorMessage = 'Google OAuth not loaded. Please check your internet connection.';
}
});
function createPicker() {
if (!googleLoaded || !accessToken) {
showSnackbar('Not connected to Google Drive');
return;
}
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes('application/zip,application/x-zip-compressed')
.setMode(google.picker.DocsViewMode.LIST)
@@ -123,24 +221,23 @@
}
async function pickerCallback(data: google.picker.ResponseObject) {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
loadingMessage = 'Downloading from drive';
const docs = data[google.picker.Response.DOCUMENTS];
const { body, headers } = await gapi.client.drive.files.get({
fileId: docs[0].id,
alt: 'media'
});
try {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
loadingMessage = 'Downloading from drive...';
const docs = data[google.picker.Response.DOCUMENTS];
const blob = await xhrDownloadFileId(docs[0].id);
const type = headers?.['Content-Type'] || '';
loadingMessage = 'Adding to catalog...';
const blob = new Blob([new Uint8Array(body.length).map((_, i) => body.charCodeAt(i))], {
type
});
const file = new File([blob], docs[0].name);
const file = new File([blob], docs[0].name);
await processFiles([file]);
await processFiles([file]);
loadingMessage = '';
}
} catch (error) {
showSnackbar('Something went wrong');
loadingMessage = '';
console.error(error);
}
}
@@ -242,9 +339,20 @@
</svelte:head>
<div class="p-2 h-[90svh]">
{#if loadingMessage}
{#if errorMessage}
<div class="bg-red-900/20 border border-red-700 rounded-lg p-4 mb-4">
<p class="text-red-400">{errorMessage}</p>
</div>
{/if}
{#if loadingMessage || completed > 0}
<Loader>
{loadingMessage}
{#if completed > 0}
<P>{formatBytes(completed)} / {formatBytes(totalSize)}</P>
<Progressbar {progress} />
{:else}
{loadingMessage}
{/if}
</Loader>
{:else if accessToken}
<div class="flex justify-between items-center gap-6 flex-col">
@@ -296,12 +404,16 @@
<button
class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800 max-w-3xl"
on:click={signIn}
disabled={!gapiLoaded || !googleLoaded}
>
<div class="flex sm:flex-row flex-col gap-2 items-center justify-center">
<GoogleSolid size="lg" />
<h2 class="text-lg">Connect to Google Drive</h2>
</div>
</button>
{#if !gapiLoaded || !googleLoaded}
<p class="text-sm text-gray-400 mt-2">Loading Google APIs...</p>
{/if}
</div>
{/if}
</div>

View File

@@ -6,7 +6,7 @@
import { promptConfirmation, showSnackbar } from '$lib/util';
import { P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte';
export const BASE_URL = 'https://www.mokuro.moe/manga';
export const BASE_URL = $page.url.searchParams.get('source') || 'https://mokuro.moe/manga';
const manga = $page.url.searchParams.get('manga');
const volume = $page.url.searchParams.get('volume');

View File

@@ -53,7 +53,7 @@
],
"start_url": "/",
"background_color": "#030712",
"display": "standalone",
"display": "fullscreen",
"scope": "/",
"theme_color": "#030712",
"description": "Reader for Mokuro processed manga"

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */