Merge pull request #90 from jcreek/89-add-backup-functionality-for-google-drive-and-dropbox

feat(#89): Add backup functionality for google drive and dropbox
This commit is contained in:
Josh Creek
2026-01-24 17:44:00 +00:00
committed by GitHub
22 changed files with 1643 additions and 7 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
PUBLIC_SUPABASE_URL="your-supabase-project-url"
PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key"
SUPABASE_SERVICE_ROLE_KEY="your-supabase-service-role-key"
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER="false"
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER="false"
GOOGLE_OAUTH_CLIENT_ID="your-google-client-id"
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
+1 -1
View File
@@ -21,7 +21,7 @@ A web app to track completion of a living Pokédex.
## Reference Data Updates
The seed data lives in `supabase/migrations/20260118001000_seed_reference_data.sql`. Update that migration directly when new data is added.
The seed data lives in `supabase/migrations/20260118001000_seed_reference_data.sql`. Update that migration directly when new data is added. You can access a local copy of [Supabase](http://localhost:54323/) to check it.
## Building
@@ -0,0 +1,35 @@
export type ExportProvider = 'google_drive' | 'dropbox';
export interface PokedexExportIntegration {
_id: string;
userId: string;
pokedexId: string | null;
provider: ExportProvider;
enabled: boolean;
fileName: string | null;
folderId: string | null;
path: string | null;
accessToken: string;
refreshToken: string | null;
accessTokenExpiresAt: string | null;
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
}
export interface PokedexExportIntegrationDB {
id: string;
userId: string;
pokedexId: string | null;
provider: ExportProvider;
enabled: boolean;
fileName: string | null;
folderId: string | null;
path: string | null;
accessToken: string;
refreshToken: string | null;
accessTokenExpiresAt: string | null;
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
}
@@ -0,0 +1,158 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type {
PokedexExportIntegration,
PokedexExportIntegrationDB
} from '$lib/models/PokedexExportIntegration';
class PokedexExportIntegrationRepository {
constructor(
private supabase: SupabaseClient,
private userId: string,
private pokedexId: string | null
) {}
private transform(db: PokedexExportIntegrationDB): PokedexExportIntegration {
return {
_id: db.id,
userId: db.userId,
pokedexId: db.pokedexId,
provider: db.provider,
enabled: db.enabled,
fileName: db.fileName,
folderId: db.folderId,
path: db.path,
accessToken: db.accessToken,
refreshToken: db.refreshToken,
accessTokenExpiresAt: db.accessTokenExpiresAt,
metadata: db.metadata,
lastExportedAt: db.lastExportedAt,
lastError: db.lastError
};
}
private baseQuery() {
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
}
private addPokedexScope(query: ReturnType<SupabaseClient['from']>) {
if (this.pokedexId) {
return query.eq('pokedexId', this.pokedexId);
}
return query.is('pokedexId', null);
}
async listEnabled(): Promise<PokedexExportIntegration[]> {
const { data, error } = await this.addPokedexScope(this.baseQuery()).eq('enabled', true);
if (error) {
console.error('Failed to load export integrations:', error);
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
}
async listAll(): Promise<PokedexExportIntegration[]> {
const { data, error } = await this.addPokedexScope(this.baseQuery());
if (error) {
console.error('Failed to load export integrations:', error);
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
}
async upsert(
data: Partial<PokedexExportIntegrationDB> &
Pick<PokedexExportIntegrationDB, 'provider'>
): Promise<PokedexExportIntegration> {
const payload: Partial<PokedexExportIntegrationDB> = {
userId: this.userId,
pokedexId: null,
...data
};
const { data: result, error } = await this.supabase
.from('pokedex_export_integrations')
.upsert(payload, {
onConflict: 'userId,provider'
})
.select()
.single();
if (error) {
console.error('Failed to upsert export integration:', error);
throw new Error(`Failed to save export integration: ${error.message}`);
}
if (!result) {
throw new Error('Failed to save export integration: No result returned');
}
return this.transform(result);
}
async listEnabledForPokedexOrUser(): Promise<PokedexExportIntegration[]> {
const base = this.supabase
.from('pokedex_export_integrations')
.select('*')
.eq('userId', this.userId)
.eq('enabled', true);
const query = this.pokedexId
? base.or(`pokedexId.eq.${this.pokedexId},pokedexId.is.null`)
: base.is('pokedexId', null);
const { data, error } = await query;
if (error) {
console.error('Failed to load export integrations:', error);
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
}
async updateTokens(
id: string,
patch: {
accessToken?: string;
refreshToken?: string | null;
accessTokenExpiresAt?: string | null;
}
): Promise<void> {
const query = this.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('id', id)
.eq('userId', this.userId);
const { error } = this.pokedexId
? await query.eq('pokedexId', this.pokedexId)
: await query.is('pokedexId', null);
if (error) {
console.error('Failed to update export integration tokens:', error);
}
}
async updateExportStatus(
id: string,
patch: {
lastExportedAt?: string | null;
lastError?: string | null;
metadata?: Record<string, unknown> | null;
folderId?: string | null;
path?: string | null;
}
): Promise<void> {
const query = this.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('id', id)
.eq('userId', this.userId);
const { error } = this.pokedexId
? await query.eq('pokedexId', this.pokedexId)
: await query.is('pokedexId', null);
if (error) {
console.error('Failed to update export integration status:', error);
}
}
}
export default PokedexExportIntegrationRepository;
+493
View File
@@ -0,0 +1,493 @@
import { randomUUID } from 'node:crypto';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CombinedData } from '$lib/models/CombinedData';
import type { Pokedex } from '$lib/models/Pokedex';
import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { getEnv } from '$lib/utils/env';
type ExportFailure = {
integrationId: string;
provider: ExportProvider;
error: string;
};
export type PokedexExportResult = {
attempted: number;
succeeded: number;
failed: ExportFailure[];
};
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function sanitizeFileName(name: string, fallback: string): string {
const trimmed = name.trim();
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
if (!safe) return fallback;
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
}
function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
const headers = [
'pokemonId',
'pokedexNumber',
'pokemon',
'form',
'caught',
'haveToEvolve',
'inHome',
'personalNotes'
];
const lines = [headers.map(csvEscape).join(',')];
for (const row of combinedData) {
const entry = row.pokedexEntry;
const catchRecord = row.catchRecord ?? {
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: ''
};
const values = [
entry._id,
entry.pokedexNumber,
entry.pokemon,
entry.form || '',
catchRecord.caught,
catchRecord.haveToEvolve,
catchRecord.inHome,
catchRecord.personalNotes || ''
];
lines.push(values.map(csvEscape).join(','));
}
return lines.join('\r\n');
}
function shouldRefreshToken(expiresAt: string | null): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (!Number.isFinite(expiry)) return false;
// Refresh if within 60 seconds of expiry.
return expiry - Date.now() < 60_000;
}
async function refreshGoogleToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Google refresh token');
}
const env = getEnv();
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error('Missing Google OAuth client credentials');
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: integration.refreshToken,
grant_type: 'refresh_token'
});
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
}
const data = (await response.json()) as {
access_token: string;
expires_in?: number;
};
const expiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000).toISOString()
: null;
await repo.updateTokens(integration._id, {
accessToken: data.access_token,
accessTokenExpiresAt: expiresAt
});
return {
...integration,
accessToken: data.access_token,
accessTokenExpiresAt: expiresAt
};
}
async function refreshDropboxToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Dropbox refresh token');
}
const env = getEnv();
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error('Missing Dropbox OAuth client credentials');
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: integration.refreshToken,
grant_type: 'refresh_token'
});
const response = await fetch('https://api.dropbox.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
}
const data = (await response.json()) as {
access_token: string;
expires_in?: number;
};
const expiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000).toISOString()
: null;
await repo.updateTokens(integration._id, {
accessToken: data.access_token,
accessTokenExpiresAt: expiresAt
});
return {
...integration,
accessToken: data.access_token,
accessTokenExpiresAt: expiresAt
};
}
async function ensureAccessToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!shouldRefreshToken(integration.accessTokenExpiresAt)) {
return integration;
}
if (integration.provider === 'google_drive') {
return await refreshGoogleToken(integration, repo);
}
if (integration.provider === 'dropbox') {
return await refreshDropboxToken(integration, repo);
}
return integration;
}
type GoogleDriveMetadata = {
files?: Record<string, string>;
};
function getGoogleFileId(metadata: Record<string, unknown> | null, pokedexId: string): string | null {
const data = metadata as GoogleDriveMetadata | null;
const fileId = data?.files?.[pokedexId];
return typeof fileId === 'string' && fileId ? fileId : null;
}
function withGoogleFileId(
metadata: Record<string, unknown> | null,
pokedexId: string,
fileId: string
): Record<string, unknown> {
const data = (metadata as GoogleDriveMetadata | null) ?? {};
const files = { ...(data.files ?? {}) };
files[pokedexId] = fileId;
return { ...data, files };
}
function withoutGoogleFileId(
metadata: Record<string, unknown> | null,
pokedexId: string
): Record<string, unknown> | null {
const data = (metadata as GoogleDriveMetadata | null) ?? {};
if (!data.files || !data.files[pokedexId]) return metadata;
const files = { ...data.files };
delete files[pokedexId];
const next = { ...data, files };
if (Object.keys(files).length === 0) {
delete (next as GoogleDriveMetadata).files;
}
return next;
}
async function uploadToGoogleDrive(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository,
pokedex: Pokedex,
csv: string
): Promise<void> {
const refreshed = await ensureAccessToken(integration, repo);
const fileName = sanitizeFileName(pokedex.name, 'pokedex.csv');
let folderId = refreshed.folderId?.trim() ?? '';
if (!folderId) {
try {
const folderResponse = await fetch(
'https://www.googleapis.com/drive/v3/files?' +
new URLSearchParams({
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
fields: 'files(id,name)',
pageSize: '1'
}).toString(),
{
headers: {
Authorization: `Bearer ${refreshed.accessToken}`
}
}
);
if (folderResponse.ok) {
const folderData = (await folderResponse.json()) as { files?: Array<{ id: string }> };
folderId = folderData.files?.[0]?.id ?? '';
}
} catch (error) {
console.warn('Failed to lookup Google Drive folder, using root:', error);
}
}
if (!folderId) {
try {
const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
method: 'POST',
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Living Dex Tracker',
mimeType: 'application/vnd.google-apps.folder'
})
});
if (createResponse.ok) {
const created = (await createResponse.json()) as { id?: string };
folderId = created.id ?? '';
}
} catch (error) {
console.warn('Failed to create Google Drive folder, using root:', error);
}
}
const existingFileId = getGoogleFileId(refreshed.metadata, pokedex._id);
let currentFileId = existingFileId;
for (let attempt = 0; attempt < 2; attempt++) {
const metadata: Record<string, unknown> = {
name: fileName,
mimeType: 'text/csv'
};
if (folderId && !currentFileId) {
metadata.parents = [folderId];
}
const boundary = `boundary_${randomUUID()}`;
const body = [
`--${boundary}`,
'Content-Type: application/json; charset=UTF-8',
'',
JSON.stringify(metadata),
`--${boundary}`,
'Content-Type: text/csv',
'',
csv,
`--${boundary}--`
].join('\r\n');
const url = currentFileId
? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart`
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
const method = currentFileId ? 'PATCH' : 'POST';
const uploadUrl = currentFileId && folderId
? `${url}&addParents=${encodeURIComponent(folderId)}`
: url;
const response = await fetch(uploadUrl, {
method,
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`
},
body
});
if (!response.ok) {
const text = await response.text();
if (currentFileId && response.status === 404 && attempt === 0) {
const nextMetadata = withoutGoogleFileId(refreshed.metadata, pokedex._id);
await repo.updateExportStatus(refreshed._id, { metadata: nextMetadata });
currentFileId = null;
continue;
}
throw new Error(`Google Drive upload failed: ${response.status} ${text}`);
}
const data = (await response.json()) as { id?: string };
if (data.id && folderId) {
await repo.updateExportStatus(refreshed._id, {
folderId
});
}
if (!currentFileId && data.id) {
const nextMetadata = withGoogleFileId(refreshed.metadata, pokedex._id, data.id);
await repo.updateExportStatus(refreshed._id, { metadata: nextMetadata });
}
return;
}
}
async function uploadToDropbox(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository,
pokedex: Pokedex,
csv: string
): Promise<void> {
const refreshed = await ensureAccessToken(integration, repo);
const fileName = sanitizeFileName(pokedex.name, 'pokedex.csv');
const trimmedPath = refreshed.path?.trim() ?? '';
let targetPath = trimmedPath;
if (!targetPath) {
targetPath = `/${fileName}`;
} else if (targetPath.endsWith('/')) {
targetPath = `${targetPath}${fileName}`;
} else if (!targetPath.toLowerCase().endsWith('.csv')) {
targetPath = `${targetPath}/${fileName}`;
}
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({
path: targetPath,
mode: 'overwrite',
mute: true
})
},
body: csv
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Dropbox upload failed: ${response.status} ${text}`);
}
}
async function exportToIntegration(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository,
pokedex: Pokedex,
csv: string
): Promise<void> {
if (integration.provider === 'google_drive') {
await uploadToGoogleDrive(integration, repo, pokedex, csv);
return;
}
if (integration.provider === 'dropbox') {
await uploadToDropbox(integration, repo, pokedex, csv);
}
}
export async function exportPokedexIfConfigured(
supabase: SupabaseClient,
userId: string,
pokedexId: string
): Promise<PokedexExportResult> {
const pokedexRepo = new PokedexRepository(supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
return { attempted: 0, succeeded: 0, failed: [] };
}
const integrationRepo = new PokedexExportIntegrationRepository(supabase, userId, pokedexId);
const integrations = await integrationRepo.listEnabledForPokedexOrUser();
if (integrations.length === 0) {
return { attempted: 0, succeeded: 0, failed: [] };
}
const dexScopes = await resolveDexScopes(supabase, pokedex);
const combinedRepo = new CombinedDataRepository(supabase, userId, pokedexId);
const combinedData = await combinedRepo.findAllCombinedData(
userId,
!!pokedex.isFormDex,
'',
pokedex.gameScope || '',
dexScopes
);
const csv = buildCsv(pokedex, combinedData);
const failures: ExportFailure[] = [];
let successes = 0;
for (const integration of integrations) {
const scopedRepo = new PokedexExportIntegrationRepository(
supabase,
userId,
integration.pokedexId ?? null
);
try {
await exportToIntegration(integration, scopedRepo, pokedex, csv);
await scopedRepo.updateExportStatus(integration._id, {
lastExportedAt: new Date().toISOString(),
lastError: null
});
successes++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({
integrationId: integration._id,
provider: integration.provider,
error: message
});
await scopedRepo.updateExportStatus(integration._id, {
lastError: message
});
console.error('Failed to export pokedex:', integration.provider, message);
}
}
return {
attempted: integrations.length,
succeeded: successes,
failed: failures
};
}
+17 -3
View File
@@ -14,6 +14,7 @@ type QueueItem = {
attempts: number;
notBefore: number; // unix ms
debounceTimer: ReturnType<typeof setTimeout> | null;
version: number;
};
export type CreateCatchRecordWriteQueueOptions = {
@@ -157,14 +158,25 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
}
// Success: drop the flushed keys.
for (const [k] of eligible) {
items.delete(k);
for (const [k, sentItem] of eligible) {
const current = items.get(k);
if (!current) {
items.delete(k);
continue;
}
// Only clear if nothing newer was enqueued after this batch started.
if (current.version === sentItem.version) {
items.delete(k);
}
}
updateStatus({ lastError: null, lastSuccessfulFlushAt: Date.now() });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
// Backoff all eligible items and keep them.
for (const [k, item] of eligible) {
const current = items.get(k);
if (!current) continue;
if (current.version !== item.version) continue;
const nextAttempts = item.attempts + 1;
items.set(k, {
...item,
@@ -199,6 +211,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
const now = Date.now();
const existing = items.get(k);
const debounceMs = opts?.debounceMs ?? 0;
const nextVersion = (existing?.version ?? 0) + 1;
// Clear any pending debounce timer: latest state always wins.
if (existing?.debounceTimer) {
@@ -222,7 +235,8 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
record,
attempts: existing?.attempts ?? 0,
notBefore,
debounceTimer
debounceTimer,
version: nextVersion
});
updateStatus({});
if (opts?.flushSoon !== false) scheduleFlush();
+3 -1
View File
@@ -1,3 +1,5 @@
import { env } from '$env/dynamic/private';
export function getEnv() {
return process.env;
return env;
}
+56
View File
@@ -0,0 +1,56 @@
import { randomUUID } from 'node:crypto';
import type { RequestEvent } from '@sveltejs/kit';
export type OAuthStatePayload = {
state: string;
userId: string;
pokedexId?: string | null;
provider: string;
fileName?: string | null;
folderId?: string | null;
path?: string | null;
returnTo?: string | null;
};
const STATE_MAX_AGE = 10 * 60; // 10 minutes
export function createOAuthState(): string {
return randomUUID();
}
function cookieName(provider: string): string {
return `oauth_state_${provider}`;
}
export function setOAuthStateCookie(
event: RequestEvent,
provider: string,
payload: OAuthStatePayload
) {
event.cookies.set(cookieName(provider), JSON.stringify(payload), {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: event.url.protocol === 'https:',
maxAge: STATE_MAX_AGE
});
}
export function readOAuthStateCookie(
event: RequestEvent,
provider: string
): OAuthStatePayload | null {
const raw = event.cookies.get(cookieName(provider));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as OAuthStatePayload;
if (!parsed?.state || !parsed?.userId) return null;
return parsed;
} catch {
return null;
}
}
export function clearOAuthStateCookie(event: RequestEvent, provider: string) {
event.cookies.delete(cookieName(provider), { path: '/' });
}
+3
View File
@@ -159,6 +159,9 @@
<li>
<a href="/my-pokedexes"> My Pokédexes </a>
</li>
<li>
<a href="/backup-settings"> Backup Settings </a>
</li>
<li><SignOut {supabase} on:signedOut={getUser} /></li>
{:else}
<li><SignIn {supabase} on:signedIn={getUser} /></li>
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const integrations = await repo.listAll();
const safe = integrations.map((integration) => ({
id: integration._id,
provider: integration.provider,
enabled: integration.enabled,
fileName: integration.fileName,
folderId: integration.folderId,
path: integration.path,
metadata: integration.metadata,
lastExportedAt: integration.lastExportedAt,
lastError: integration.lastError
}));
return json(safe);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { requireAuth } from '$lib/utils/auth';
const ALLOWED_PROVIDERS = new Set(['google_drive', 'dropbox']);
export const PUT = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { provider } = event.params;
if (!provider || !ALLOWED_PROVIDERS.has(provider)) {
return json({ error: 'Invalid provider' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
let body: {
folderId?: string | null;
path?: string | null;
};
try {
body = (await event.request.json()) as {
folderId?: string | null;
path?: string | null;
};
} catch (err) {
if (err instanceof SyntaxError) {
return new Response('Malformed JSON', { status: 400 });
}
throw err;
}
const patch: Record<string, unknown> = {};
if (body.folderId !== undefined) patch.folderId = body.folderId;
if (body.path !== undefined) patch.path = body.path;
if (Object.keys(patch).length === 0) {
return json({ error: 'No fields to update' }, { status: 400 });
}
const { data, error } = await event.locals.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('userId', userId)
.is('pokedexId', null)
.eq('provider', provider)
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
.maybeSingle();
if (error) {
console.error(error);
return json({ error: 'Internal Server Error' }, { status: 500 });
}
if (data === null) {
return json({ error: 'Integration not found' }, { status: 404 });
}
return json(data);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,115 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const oauthState = readOAuthStateCookie(event, 'dropbox');
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
clearOAuthStateCookie(event, 'dropbox');
return json({ error: 'Invalid OAuth state' }, { status: 400 });
}
clearOAuthStateCookie(event, 'dropbox');
const fallbackReturnTo = oauthState.pokedexId
? `/pokedex/${oauthState.pokedexId}`
: '/backup-settings';
const baseReturnTo =
oauthState.returnTo && oauthState.returnTo.startsWith('/')
? oauthState.returnTo
: fallbackReturnTo;
const returnTo = baseReturnTo.includes('?')
? `${baseReturnTo}&export=dropbox`
: `${baseReturnTo}?export=dropbox`;
if (errorParam) {
throw redirect(302, `${returnTo}-denied`);
}
if (!code) {
return json({ error: 'Missing OAuth code' }, { status: 400 });
}
const env = getEnv();
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return json({ error: 'Missing Dropbox OAuth credentials' }, { status: 500 });
}
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
const tokenParams = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
return json({ error: `Dropbox token exchange failed: ${text}` }, { status: 500 });
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
};
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
: null;
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
const path = oauthState.path?.trim() ? oauthState.path : null;
try {
await repo.upsert({
provider: 'dropbox',
enabled: true,
fileName,
path,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
});
} catch (saveError) {
console.error('Dropbox integration save failed:', saveError);
throw redirect(302, `${returnTo}-error`);
}
throw redirect(302, `${returnTo}-connected`);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,64 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const pokedexId = url.searchParams.get('pokedexId');
const fileName = url.searchParams.get('fileName');
const path = url.searchParams.get('path');
const returnToRaw = url.searchParams.get('returnTo');
const returnTo =
returnToRaw && returnToRaw.startsWith('/') && !returnToRaw.startsWith('//')
? returnToRaw
: '/backup-settings';
if (pokedexId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
throw redirect(302, '/my-pokedexes');
}
}
const env = getEnv();
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
if (!clientId) {
const missingClientRedirect = pokedexId
? `/pokedex/${pokedexId}?export=dropbox-missing-client`
: '/pokedex?export=dropbox-missing-client';
throw redirect(302, missingClientRedirect);
}
const state = createOAuthState();
setOAuthStateCookie(event, 'dropbox', {
state,
userId,
pokedexId,
provider: 'dropbox',
fileName,
path,
returnTo
});
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
state,
token_access_type: 'offline',
scope: 'files.content.write'
});
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
};
@@ -0,0 +1,116 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const oauthState = readOAuthStateCookie(event, 'google_drive');
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
clearOAuthStateCookie(event, 'google_drive');
return json({ error: 'Invalid OAuth state' }, { status: 400 });
}
clearOAuthStateCookie(event, 'google_drive');
const fallbackReturnTo = oauthState.pokedexId
? `/pokedex/${oauthState.pokedexId}`
: '/backup-settings';
const baseReturnTo =
oauthState.returnTo && oauthState.returnTo.startsWith('/')
? oauthState.returnTo
: fallbackReturnTo;
const returnTo = baseReturnTo.includes('?')
? `${baseReturnTo}&export=google`
: `${baseReturnTo}?export=google`;
if (errorParam) {
throw redirect(302, `${returnTo}-denied`);
}
if (!code) {
return json({ error: 'Missing OAuth code' }, { status: 400 });
}
const env = getEnv();
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return json({ error: 'Missing Google OAuth credentials' }, { status: 500 });
}
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
const tokenParams = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
return json({ error: `Google token exchange failed: ${text}` }, { status: 500 });
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
};
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
: null;
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
const folderId = oauthState.folderId?.trim() ? oauthState.folderId : null;
try {
await repo.upsert({
provider: 'google_drive',
enabled: true,
fileName,
folderId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
});
} catch (saveError) {
console.error('Google Drive integration save failed:', saveError);
throw redirect(302, `${returnTo}-error`);
}
throw redirect(302, `${returnTo}-connected`);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,69 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const pokedexId = url.searchParams.get('pokedexId');
const fileName = url.searchParams.get('fileName');
const folderId = url.searchParams.get('folderId');
const returnToRaw = url.searchParams.get('returnTo');
const returnTo =
returnToRaw && returnToRaw.startsWith('/') && !returnToRaw.startsWith('//')
? returnToRaw
: '/backup-settings';
if (pokedexId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
throw redirect(302, '/my-pokedexes');
}
}
const env = getEnv();
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
if (!clientId) {
const missingClientRedirect = pokedexId
? `/pokedex/${pokedexId}?export=google-missing-client`
: '/backup-settings?export=google-missing-client';
throw redirect(302, missingClientRedirect);
}
const state = createOAuthState();
setOAuthStateCookie(event, 'google_drive', {
state,
userId,
pokedexId,
provider: 'google_drive',
fileName,
folderId,
returnTo
});
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.metadata.readonly'
].join(' '),
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state
});
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
};
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { type CatchRecord } from '$lib/models/CatchRecord';
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
import { requireAuth } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
@@ -73,6 +74,11 @@ export const PUT = async (event: RequestEvent) => {
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const upsertedRecord = await repo.upsert(data);
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error('Failed to export pokedex after catch record update:', exportError);
}
return json(upsertedRecord);
} catch (err) {
console.error(err);
@@ -137,6 +143,11 @@ export const POST = async (event: RequestEvent) => {
// Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts.
try {
const upserted = await repo.bulkUpsert(scoped);
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error('Failed to export pokedex after bulk catch record update:', exportError);
}
return json(upserted);
} catch (bulkErr) {
console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr);
@@ -145,6 +156,14 @@ export const POST = async (event: RequestEvent) => {
const upsertedRecord = await repo.upsert(record);
insertedRecords.push(upsertedRecord);
}
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error(
'Failed to export pokedex after per-record catch updates:',
exportError
);
}
return json(insertedRecords);
}
} catch (err) {
@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
import { requireAuth } from '$lib/utils/auth';
export const POST = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
const result = await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
return json(result);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { safeGetSession } = locals;
const { session, user } = await safeGetSession();
if (!session || !user) {
throw redirect(303, '/signin');
}
return {};
};
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
type ExportIntegrationSummary = {
id: string;
provider: 'google_drive' | 'dropbox';
enabled: boolean;
fileName: string | null;
folderId: string | null;
path: string | null;
metadata?: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
};
let exportIntegrations: ExportIntegrationSummary[] = [];
let exportLoading = false;
let exportError: string | null = null;
let googleIntegration: ExportIntegrationSummary | undefined;
let dropboxIntegration: ExportIntegrationSummary | undefined;
function formatTimestamp(value: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
async function loadExportIntegrations() {
exportLoading = true;
exportError = null;
try {
const response = await fetch('/api/export-integrations', {
credentials: 'include'
});
if (!response.ok) {
throw new Error(await response.text());
}
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exportError = message || 'Failed to load export settings';
} finally {
exportLoading = false;
}
}
function getGoogleFolderUrl(folderId: string): string {
return `https://drive.google.com/drive/folders/${folderId}`;
}
function getDropboxFolderUrl(path?: string | null): string {
const fallback = '/Apps/Living Dex Tracker';
const trimmed = (path ?? '').trim() || fallback;
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return `https://www.dropbox.com/home${normalized}`;
}
function connectGoogleDrive() {
const params = new URLSearchParams({
returnTo: '/backup-settings'
});
window.location.href = `/api/integrations/google-drive/connect?${params.toString()}`;
}
function connectDropbox() {
const params = new URLSearchParams({
returnTo: '/backup-settings'
});
window.location.href = `/api/integrations/dropbox/connect?${params.toString()}`;
}
onMount(() => {
if (!browser) return;
void loadExportIntegrations();
});
</script>
<svelte:head>
<title>Backup Settings - Living Dex Tracker</title>
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-lg">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Backup Settings</h1>
<a href="/my-pokedexes" class="btn btn-outline btn-sm">Back to My Pokédexes</a>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<p class="text-sm text-base-content/70">
Backups automatically export every time you update a pokédex. Each export is named after the
pokédex, so you only need to connect once.
</p>
{#if exportLoading}
<p class="text-sm text-base-content/70 mt-4">Loading export settings…</p>
{:else if exportError}
<p class="text-sm text-error mt-4">{exportError}</p>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Google Drive</h2>
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
{googleIntegration ? 'Connected' : 'Not Connected'}
</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
{googleIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
{#if googleIntegration?.folderId}
<div class="text-xs">
<a
href={getGoogleFolderUrl(googleIntegration.folderId)}
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
Open Drive folder
</a>
</div>
{/if}
{#if googleIntegration?.lastExportedAt}
<p class="text-xs text-base-content/60">
Last export: {formatTimestamp(googleIntegration.lastExportedAt)}
</p>
{/if}
{#if googleIntegration?.lastError}
<p class="text-xs text-error">{googleIntegration.lastError}</p>
{/if}
</div>
</div>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Dropbox</h2>
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
{dropboxIntegration ? 'Connected' : 'Not Connected'}
</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
{dropboxIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
{#if dropboxIntegration}
<div class="text-xs">
<a
href={getDropboxFolderUrl(dropboxIntegration.path)}
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
Open Dropbox folder
</a>
</div>
{/if}
{#if dropboxIntegration?.lastExportedAt}
<p class="text-xs text-base-content/60">
Last export: {formatTimestamp(dropboxIntegration.lastExportedAt)}
</p>
{/if}
{#if dropboxIntegration?.lastError}
<p class="text-xs text-error">{dropboxIntegration.lastError}</p>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
+4 -1
View File
@@ -167,7 +167,10 @@
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">My Pokédexes</h1>
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
<div class="flex items-center gap-2">
<a class="btn btn-outline" href="/backup-settings">Backup Settings</a>
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
</div>
</div>
{#if pokedexes.length === 0}
+104
View File
@@ -17,6 +17,7 @@
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
export let data: PageData;
// Get pokédex from server load (guarded for transient undefined during navigation/HMR)
@@ -24,6 +25,13 @@
let pokedexId = '';
$: pokedex = data?.pokedex;
$: pokedexId = pokedex?._id ?? '';
$: if (browser && pokedexId) {
try {
localStorage.setItem('activePokedexId', pokedexId);
} catch {
// Ignore storage errors (private mode, etc.)
}
}
let combinedData = null as CombinedData[] | null;
let currentPage = 1 as number;
@@ -49,6 +57,77 @@
lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null
};
let exportAfterFlush = false;
let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null;
let exportGeneration = 0;
let exportInFlightGeneration = 0;
let toggleFlushTimer: ReturnType<typeof setTimeout> | null = null;
function resetExportState() {
if (exportTimer) {
clearTimeout(exportTimer);
exportTimer = null;
}
if (toggleFlushTimer) {
clearTimeout(toggleFlushTimer);
toggleFlushTimer = null;
}
exportAfterFlush = false;
exportInFlight = false;
exportGeneration = 0;
exportInFlightGeneration = 0;
}
function scheduleExportIfIdle() {
if (!browser) return;
if (!pokedexId) return;
const isIdle = catchWriteStatus.pending === 0 && catchWriteStatus.inFlight === 0;
if (!isIdle) return;
if (!exportAfterFlush || exportInFlight) return;
if (exportTimer) return;
exportTimer = setTimeout(async () => {
exportTimer = null;
if (!exportAfterFlush || exportInFlight) return;
exportInFlight = true;
exportInFlightGeneration = exportGeneration;
try {
const response = await fetch(`/api/pokedexes/${pokedexId}/export`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error('Auto-export failed:', response.status, body);
return;
}
if (exportGeneration === exportInFlightGeneration) {
exportAfterFlush = false;
}
} catch (error) {
console.error('Failed to auto-export pokedex:', error);
} finally {
exportInFlight = false;
scheduleExportIfIdle();
}
}, 400);
}
function scheduleToggleFlush() {
if (!catchWriteQueue) return;
if (toggleFlushTimer) {
clearTimeout(toggleFlushTimer);
}
toggleFlushTimer = setTimeout(async () => {
toggleFlushTimer = null;
try {
await catchWriteQueue?.flushNow();
} catch (error) {
console.error('Failed to flush catch record updates:', error);
}
}, 250);
}
// Derive from pokedex config
$: showOrigins = !!pokedex?.isOriginDex;
@@ -62,6 +141,9 @@
catchWriteQueueUnsubscribe?.();
catchWriteQueueUnsubscribe = null;
});
onDestroy(() => {
resetExportState();
});
function openPokemonModal(pokemon: CombinedData) {
selectedPokemon = pokemon;
@@ -80,6 +162,8 @@
const desiredKey = `${localUser.id}:${pokedexId}`;
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
resetExportState();
// Unsubscribe from the previous queue's status store before replacing the queue.
catchWriteQueueUnsubscribe?.();
catchWriteQueueUnsubscribe = null;
@@ -94,9 +178,20 @@
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
catchWriteStatus = s;
if (s.pending > 0 || s.inFlight > 0) {
if (exportTimer) {
clearTimeout(exportTimer);
exportTimer = null;
}
return;
}
scheduleExportIfIdle();
});
}
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
if (!combinedData) return;
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
@@ -184,6 +279,11 @@
debounceMs,
flushSoon: true
});
exportGeneration++;
exportAfterFlush = true;
if (source === 'toggle') {
scheduleToggleFlush();
}
if (source === 'notes-blur') {
// Force a flush attempt when the user leaves the textarea.
await catchWriteQueue?.flushNow();
@@ -239,6 +339,8 @@
if (record.haveToEvolve) record.caught = false;
applyOptimisticCatchRecordUpdate(record);
catchWriteQueue?.enqueue(record, { flushSoon: true });
exportGeneration++;
exportAfterFlush = true;
}
// Kick a flush attempt (batching will occur inside the queue).
await catchWriteQueue?.flushNow();
@@ -362,6 +464,7 @@
window.clearInterval(reconcileInterval);
};
});
</script>
<svelte:head>
@@ -496,6 +599,7 @@
{#if pokedex.description}
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
{/if}
</div>
<!-- Right side: Actions -->
@@ -0,0 +1,42 @@
-- Enable exports to external providers (Google Drive / Dropbox)
CREATE TABLE pokedex_export_integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
"pokedexId" UUID REFERENCES pokedexes(id) ON DELETE CASCADE,
provider TEXT NOT NULL CHECK (provider IN ('google_drive', 'dropbox')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
"fileName" TEXT,
"folderId" TEXT,
path TEXT,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT,
"accessTokenExpiresAt" TIMESTAMPTZ,
metadata JSONB,
"lastExportedAt" TIMESTAMPTZ,
"lastError" TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW(),
UNIQUE ("userId", provider)
);
CREATE INDEX idx_pokedex_export_integrations_user_id ON pokedex_export_integrations("userId");
CREATE INDEX idx_pokedex_export_integrations_pokedex_id ON pokedex_export_integrations("pokedexId");
CREATE INDEX idx_pokedex_export_integrations_provider ON pokedex_export_integrations(provider);
CREATE TRIGGER update_pokedex_export_integrations_updated_at
BEFORE UPDATE ON pokedex_export_integrations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE pokedex_export_integrations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own export integrations" ON pokedex_export_integrations
FOR SELECT USING ("userId" = auth.uid());
CREATE POLICY "Users can create own export integrations" ON pokedex_export_integrations
FOR INSERT WITH CHECK ("userId" = auth.uid());
CREATE POLICY "Users can update own export integrations" ON pokedex_export_integrations
FOR UPDATE USING ("userId" = auth.uid()) WITH CHECK ("userId" = auth.uid());
CREATE POLICY "Users can delete own export integrations" ON pokedex_export_integrations
FOR DELETE USING ("userId" = auth.uid());