9 Commits

Author SHA1 Message Date
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
22 changed files with 1284 additions and 318 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"]

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

911
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,7 @@
import { showSnackbar } from '$lib/util'; import { showSnackbar } from '$lib/util';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { blobToBase64 } from '.'; import { blobToBase64, imageResize } from '.';
import { Settings } from '$lib/settings';
type CropperModal = { type CropperModal = {
open: boolean; open: boolean;
@@ -34,7 +35,7 @@ function getRadianAngle(degreeValue: number) {
export type Pixels = { width: number; height: number; x: number; y: 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 image = await createImage(imageSrc);
const canvas = new OffscreenCanvas(image.width, image.height); const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -71,8 +72,9 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotatio
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) 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) 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 { showSnackbar } from "$lib/util"
import { get } from "svelte/store"; 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 image = await createImageBitmap(source);
const canvas = new OffscreenCanvas(image.width, image.height); const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (context) { if (context) {
context.drawImage(image, 0, 0); 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(); image.close();
return await blobToBase64(blob); 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) { export async function updateLastCard(imageData: string | null | undefined, sentence?: string) {
const { const {
overwriteImage, overwriteImage,

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,10 @@
let pictureField = $settings.ankiConnectSettings.pictureField; let pictureField = $settings.ankiConnectSettings.pictureField;
let sentenceField = $settings.ankiConnectSettings.sentenceField; 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; let triggerMethod = $settings.ankiConnectSettings.triggerMethod;
const triggerOptions = [ const triggerOptions = [
@@ -90,5 +94,40 @@
/> />
</Label> </Label>
</div> </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> </div>
</AccordionItem> </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

@@ -13,6 +13,7 @@
import VolumeSettings from './Volume/VolumeSettings.svelte'; import VolumeSettings from './Volume/VolumeSettings.svelte';
import About from './About.svelte'; import About from './About.svelte';
import QuickAccess from './QuickAccess.svelte'; import QuickAccess from './QuickAccess.svelte';
import CloudSync from './CloudSync.svelte';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
let transitionParams = { let transitionParams = {
@@ -33,7 +34,8 @@
} }
beforeNavigate((nav) => { 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(); nav.cancel();
hidden = true; hidden = true;
} }
@@ -63,6 +65,7 @@
<VolumeDefaults /> <VolumeDefaults />
{/if} {/if}
<Profiles {onClose} /> <Profiles {onClose} />
<CloudSync />
<ReaderSettings /> <ReaderSettings />
<AnkiConnectSettings /> <AnkiConnectSettings />
<CatalogSettings /> <CatalogSettings />

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 './volume-data'
export * from './settings' export * from './settings'
export * from './misc' export * from './misc'
export * from './sync'

View File

@@ -28,6 +28,9 @@ export type AnkiConnectSettings = {
enabled: boolean; enabled: boolean;
pictureField: string; pictureField: string;
sentenceField: string; sentenceField: string;
heightField: number;
widthField: number;
qualityField: number;
cropImage: boolean; cropImage: boolean;
overwriteImage: boolean; overwriteImage: boolean;
grabSentence: boolean; grabSentence: boolean;
@@ -98,6 +101,9 @@ const defaultSettings: Settings = {
overwriteImage: true, overwriteImage: true,
pictureField: 'Picture', pictureField: 'Picture',
sentenceField: 'Sentence', sentenceField: 'Sentence',
heightField: 0,
widthField: 0,
qualityField: 1,
triggerMethod: 'both' 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

@@ -23,12 +23,15 @@
let tokenClient: any; let tokenClient: any;
let accessToken = ''; let accessToken = '';
let gapiLoaded = false;
let googleLoaded = false;
let readerFolderId = ''; let readerFolderId = '';
let volumeDataId = ''; let volumeDataId = '';
let profilesId = ''; let profilesId = '';
let loadingMessage = ''; let loadingMessage = '';
let errorMessage = '';
let completed = 0; let completed = 0;
let totalSize = 0; let totalSize = 0;
@@ -36,6 +39,11 @@
function xhrDownloadFileId(fileId: string) { function xhrDownloadFileId(fileId: string) {
return new Promise<Blob>((resolve, reject) => { return new Promise<Blob>((resolve, reject) => {
if (!gapiLoaded || !gapi.auth.getToken()) {
reject(new Error('Not authenticated'));
return;
}
const { access_token } = gapi.auth.getToken(); const { access_token } = gapi.auth.getToken();
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -88,50 +96,61 @@
accessToken = resp?.access_token; accessToken = resp?.access_token;
loadingMessage = 'Connecting to drive'; loadingMessage = 'Connecting to drive';
const { result: readerFolderRes } = await gapi.client.drive.files.list({ try {
q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`, const { result: readerFolderRes } = await gapi.client.drive.files.list({
fields: 'files(id)' 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'
}); });
readerFolderId = createReaderFolderRes.id || ''; if (readerFolderRes.files?.length === 0) {
} else { const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
const id = readerFolderRes.files?.[0]?.id || ''; 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({ const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`, q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)' fields: 'files(id, name)'
}); });
if (volumeDataRes.files?.length !== 0) { if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || ''; volumeDataId = volumeDataRes.files?.[0].id || '';
} }
const { result: profilesRes } = await gapi.client.drive.files.list({ const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`, q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)' fields: 'files(id, name)'
}); });
if (profilesRes.files?.length !== 0) { if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || ''; profilesId = profilesRes.files?.[0].id || '';
} }
loadingMessage = ''; loadingMessage = '';
errorMessage = '';
if (accessToken) { if (accessToken) {
showSnackbar('Connected to Google Drive'); 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() { function signIn() {
if (!gapiLoaded || !googleLoaded) {
errorMessage = 'Google APIs not loaded yet. Please wait and try again.';
return;
}
if (gapi.client.getToken() === null) { if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' }); tokenClient.requestAccessToken({ prompt: 'consent' });
} else { } else {
@@ -140,23 +159,50 @@
} }
onMount(() => { onMount(() => {
gapi.load('client', async () => { // Check if environment variables are set
await gapi.client.init({ if (!CLIENT_ID || !API_KEY) {
apiKey: API_KEY, errorMessage = 'Google Drive integration not configured. Please set VITE_GDRIVE_CLIENT_ID and VITE_GDRIVE_API_KEY environment variables.';
discoveryDocs: [DISCOVERY_DOC] 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({ if (typeof google !== 'undefined') {
client_id: CLIENT_ID, googleLoaded = true;
scope: SCOPES, tokenClient = google.accounts.oauth2.initTokenClient({
callback: connectDrive client_id: CLIENT_ID,
}); scope: SCOPES,
callback: connectDrive
});
} else {
errorMessage = 'Google OAuth not loaded. Please check your internet connection.';
}
}); });
function createPicker() { function createPicker() {
if (!googleLoaded || !accessToken) {
showSnackbar('Not connected to Google Drive');
return;
}
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS) const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes('application/zip,application/x-zip-compressed') .setMimeTypes('application/zip,application/x-zip-compressed')
.setMode(google.picker.DocsViewMode.LIST) .setMode(google.picker.DocsViewMode.LIST)
@@ -293,6 +339,12 @@
</svelte:head> </svelte:head>
<div class="p-2 h-[90svh]"> <div class="p-2 h-[90svh]">
{#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} {#if loadingMessage || completed > 0}
<Loader> <Loader>
{#if completed > 0} {#if completed > 0}
@@ -352,12 +404,16 @@
<button <button
class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800 max-w-3xl" class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800 max-w-3xl"
on:click={signIn} on:click={signIn}
disabled={!gapiLoaded || !googleLoaded}
> >
<div class="flex sm:flex-row flex-col gap-2 items-center justify-center"> <div class="flex sm:flex-row flex-col gap-2 items-center justify-center">
<GoogleSolid size="lg" /> <GoogleSolid size="lg" />
<h2 class="text-lg">Connect to Google Drive</h2> <h2 class="text-lg">Connect to Google Drive</h2>
</div> </div>
</button> </button>
{#if !gapiLoaded || !googleLoaded}
<p class="text-sm text-gray-400 mt-2">Loading Google APIs...</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -53,7 +53,7 @@
], ],
"start_url": "/", "start_url": "/",
"background_color": "#030712", "background_color": "#030712",
"display": "standalone", "display": "fullscreen",
"scope": "/", "scope": "/",
"theme_color": "#030712", "theme_color": "#030712",
"description": "Reader for Mokuro processed manga" "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'; import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */