feat: Implement KOReader Progress Synchronization (#1770)
* feat(sync): implement KOReader progress synchronization This commit introduces a comprehensive feature to synchronize reading progress with a KOReader sync server. It includes a compatibility layer to handle discrepancies between Readest's CFI-based progress and KOReader's XPointer/page-based progress, primarily using the `percentage` field as a common ground. Key additions include: - A new settings panel under "KOReader Sync" for server configuration, authentication, and sync strategy management (e.g., prompt on conflict, always use latest). - A conflict resolution dialog that appears when remote progress differs significantly from local progress, allowing the user to choose which version to keep. - A client-side `useKOSync` hook to manage the entire synchronization lifecycle, including API calls, state management, and conflict resolution logic. - A new API endpoint `/api/kosync` that acts as a secure proxy to the user-configured KOReader sync server, handling authentication and forwarding requests. - Logic to differentiate between paginated (PDF/CBZ) and reflowable (EPUB) formats, using page numbers for paginated files where possible and falling back to percentage for reliability. - Spanish translations for all UI elements related to the KOReader sync feature. - Addition of `uuid` package to generate a unique `device_id` for sync purposes. Refactor: - The `debounce` utility has been improved to include `flush` and `cancel` methods, allowing for more precise control over debounced function execution, which is now used in the sync hook. * fix(kosync): add support for converting between XPointer and CFI in progress synchronization * fix(kosync): update navigation method to use select instead of goTo for paginated formats * fix(kosync): refactor synchronization settings and improve conflict resolution handling * fix(kosync): add event dispatcher for flushing KOReader synchronization * fix(sync): handle xpointer in a different section, fix styling * i18n: update translations --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { Book } from '@/types/book';
|
||||
import { KoreaderSyncChecksumMethod } from '@/types/settings';
|
||||
import { getAPIBaseUrl } from '../environment';
|
||||
import { KoSyncProxyPayload } from '@/types/kosync';
|
||||
|
||||
/**
|
||||
* Interface for KOSync progress response from the server
|
||||
*/
|
||||
export interface KoSyncProgress {
|
||||
document?: string;
|
||||
progress?: string;
|
||||
percentage?: number;
|
||||
timestamp?: number;
|
||||
device?: string;
|
||||
device_id?: string;
|
||||
}
|
||||
|
||||
export class KOSyncClient {
|
||||
private serverUrl: string;
|
||||
private username: string;
|
||||
private userkey: string;
|
||||
private checksumMethod: KoreaderSyncChecksumMethod;
|
||||
private deviceId: string;
|
||||
private deviceName: string;
|
||||
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
userkey: string,
|
||||
checksumMethod: KoreaderSyncChecksumMethod,
|
||||
deviceId: string,
|
||||
deviceName: string,
|
||||
) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.userkey = userkey;
|
||||
this.checksumMethod = checksumMethod;
|
||||
this.deviceId = deviceId;
|
||||
this.deviceName = deviceName;
|
||||
}
|
||||
|
||||
private async request(
|
||||
endpoint: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT';
|
||||
body?: BodyInit | null;
|
||||
headers?: HeadersInit;
|
||||
useAuth?: boolean;
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const { method = 'GET', body, headers: additionalHeaders, useAuth = true } = options;
|
||||
|
||||
const headers = new Headers(additionalHeaders || {});
|
||||
if (useAuth) {
|
||||
headers.set('X-Auth-User', this.username);
|
||||
headers.set('X-Auth-Key', this.userkey);
|
||||
}
|
||||
|
||||
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
|
||||
|
||||
const proxyBody: KoSyncProxyPayload = {
|
||||
serverUrl: this.serverUrl,
|
||||
endpoint,
|
||||
method,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
body: body ? JSON.parse(body as string) : undefined,
|
||||
};
|
||||
|
||||
return fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(proxyBody),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the KOSync server with authentication
|
||||
* @param username - The username for authentication
|
||||
* @param password - The password for authentication
|
||||
* @returns Promise with success status and optional message
|
||||
*/
|
||||
async connect(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const userkey = md5(password);
|
||||
|
||||
try {
|
||||
const authResponse = await this.request('/users/auth', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Auth-User': username,
|
||||
'X-Auth-Key': userkey,
|
||||
},
|
||||
});
|
||||
|
||||
if (authResponse.ok) {
|
||||
return { success: true, message: 'Login successful.' };
|
||||
}
|
||||
|
||||
if (authResponse.status === 401) {
|
||||
const registerResponse = await this.request('/users/create', {
|
||||
method: 'POST',
|
||||
useAuth: false,
|
||||
body: JSON.stringify({ username, password: userkey }),
|
||||
});
|
||||
|
||||
if (registerResponse.ok) {
|
||||
return { success: true, message: 'Registration successful.' };
|
||||
}
|
||||
|
||||
const regError = await registerResponse.json().catch(() => ({}));
|
||||
if (registerResponse.status === 402) {
|
||||
return { success: false, message: 'Invalid credentials.' };
|
||||
}
|
||||
return { success: false, message: regError.message || 'Registration failed.' };
|
||||
}
|
||||
|
||||
const errorBody = await authResponse.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
message: errorBody.message || `Authorization failed with status: ${authResponse.status}`,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('KOSync connection failed', e);
|
||||
return { success: false, message: (e as Error).message || 'Connection error.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the reading progress for a specific book from the server
|
||||
* @param book - The book to get progress for
|
||||
* @returns Promise with the progress data or null if not found
|
||||
*/
|
||||
async getProgress(book: Book): Promise<KoSyncProgress | null> {
|
||||
if (!this.userkey) return null;
|
||||
|
||||
const documentHash = this.getDocumentDigest(book);
|
||||
if (!documentHash) return null;
|
||||
|
||||
try {
|
||||
const response = await this.request(`/syncs/progress/${documentHash}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`KOSync: Failed to get progress for ${book.title}. Status: ${response.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.document ? data : null;
|
||||
} catch (e) {
|
||||
console.error('KOSync getProgress failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the reading progress for a specific book on the server
|
||||
* @param book - The book to update progress for
|
||||
* @param progress - The current reading progress position
|
||||
* @param percentage - The reading completion percentage
|
||||
* @returns Promise with boolean indicating success
|
||||
*/
|
||||
async updateProgress(book: Book, progress: string, percentage: number): Promise<boolean> {
|
||||
if (!this.userkey) return false;
|
||||
|
||||
const documentHash = this.getDocumentDigest(book);
|
||||
if (!documentHash) return false;
|
||||
|
||||
const payload = {
|
||||
document: documentHash,
|
||||
progress: progress.toString(),
|
||||
percentage,
|
||||
device: this.deviceName,
|
||||
device_id: this.deviceId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.request('/syncs/progress', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`KOSync: Failed to update progress for ${book.title}. Status: ${response.status}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('KOSync updateProgress failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getDocumentDigest(book: Book): string | undefined {
|
||||
if (this.checksumMethod === 'filename') {
|
||||
const filename = this.getBaseFilename(book.sourceTitle || book.title);
|
||||
return md5(filename);
|
||||
}
|
||||
return book.hash;
|
||||
}
|
||||
|
||||
private getBaseFilename(fullPath: string): string {
|
||||
// Normalize path by replacing backslashes with forward slashes for cross-platform consistency
|
||||
const normalizedPath = fullPath.replace(/\\/g, '/');
|
||||
// Get the last part of the path and remove the extension
|
||||
const baseName =
|
||||
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath;
|
||||
return baseName;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user