Transition document upload flow to use presigned token-based temporary uploads. Add finalize endpoint to register uploaded documents after blob transfer, decoupling document ID assignment from upload initiation. Update client and server logic to operate on tokens, improve security, and enable canonical ID reuse for identical uploads. Update tests to verify deduplication behavior.
1285 lines
40 KiB
TypeScript
1285 lines
40 KiB
TypeScript
import Dexie, { type EntityTable } from 'dexie';
|
|
import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
|
import {
|
|
isTtsProviderType,
|
|
type TtsProviderType,
|
|
} from '@/lib/shared/tts-provider-catalog';
|
|
import { defaultModelForProviderType, normalizeLegacyProviderRef, resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
|
import {
|
|
PDFDocument,
|
|
EPUBDocument,
|
|
HTMLDocument,
|
|
DocumentListState,
|
|
BaseDocument,
|
|
DocumentListDocument,
|
|
} from '@/types/documents';
|
|
import { sha256HexFromBytes, sha256HexFromString } from '@/lib/client/sha256';
|
|
import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client/api/documents';
|
|
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
|
|
|
const DB_NAME = 'openreader-db';
|
|
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
|
const DB_VERSION = 9;
|
|
|
|
const PDF_TABLE = 'pdf-documents' as const;
|
|
const EPUB_TABLE = 'epub-documents' as const;
|
|
const HTML_TABLE = 'html-documents' as const;
|
|
const CONFIG_TABLE = 'config' as const;
|
|
const APP_CONFIG_TABLE = 'app-config' as const;
|
|
const LAST_LOCATION_TABLE = 'last-locations' as const;
|
|
const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const;
|
|
const PREVIEW_CACHE_TABLE = 'document-preview-cache' as const;
|
|
const MIB = 1024 * 1024;
|
|
const DOCUMENT_CACHE_MAX_BYTES = 1024 * MIB; // 1 GiB
|
|
const PREVIEW_CACHE_MAX_BYTES = 128 * MIB; // 128 MiB
|
|
|
|
interface DocumentCacheMeta {
|
|
cacheCreatedAt?: number;
|
|
cacheAccessedAt?: number;
|
|
cacheByteSize?: number;
|
|
}
|
|
|
|
type PDFCacheRow = PDFDocument & DocumentCacheMeta;
|
|
type EPUBCacheRow = EPUBDocument & DocumentCacheMeta;
|
|
type HTMLCacheRow = HTMLDocument & DocumentCacheMeta;
|
|
|
|
export interface LastLocationRow {
|
|
docId: string;
|
|
location: string;
|
|
}
|
|
|
|
export interface DocumentIdMapRow {
|
|
oldId: string;
|
|
id: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
export interface DocumentPreviewCacheRow {
|
|
docId: string;
|
|
lastModified: number;
|
|
contentType: string;
|
|
data: ArrayBuffer;
|
|
cachedAt: number;
|
|
byteSize?: number;
|
|
previewVersion?: number;
|
|
}
|
|
|
|
export interface ConfigRow {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
type OpenReaderDB = Dexie & {
|
|
[PDF_TABLE]: EntityTable<PDFCacheRow, 'id'>;
|
|
[EPUB_TABLE]: EntityTable<EPUBCacheRow, 'id'>;
|
|
[HTML_TABLE]: EntityTable<HTMLCacheRow, 'id'>;
|
|
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
|
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
|
|
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
|
|
[DOCUMENT_ID_MAP_TABLE]: EntityTable<DocumentIdMapRow, 'oldId'>;
|
|
[PREVIEW_CACHE_TABLE]: EntityTable<DocumentPreviewCacheRow, 'docId'>;
|
|
};
|
|
|
|
export const db = new Dexie(DB_NAME) as OpenReaderDB;
|
|
|
|
|
|
|
|
type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error';
|
|
|
|
function emitDexieStatus(status: DexieOpenStatus, detail?: Record<string, unknown>): void {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent('openreader:dexie', {
|
|
detail: { status, ...detail },
|
|
}),
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// Fired when this tab's open/upgrade is blocked by another tab holding the DB open.
|
|
db.on('blocked', () => {
|
|
emitDexieStatus('blocked');
|
|
});
|
|
}
|
|
|
|
const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|
openai: 'https://api.openai.com/v1',
|
|
deepinfra: 'https://api.deepinfra.com/v1/openai',
|
|
'custom-openai': '',
|
|
};
|
|
|
|
type RawConfigMap = Record<string, string | undefined>;
|
|
|
|
function inferProviderRefAndBaseUrl(raw: RawConfigMap): { providerRef: string; baseUrl: string } {
|
|
const cachedApiKey = raw.apiKey;
|
|
const cachedBaseUrl = raw.baseUrl;
|
|
let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || '';
|
|
inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef);
|
|
|
|
if (!raw.providerRef && !raw.ttsProvider && !raw.provider) {
|
|
inferredProviderRef = getAppConfigDefaults().providerRef;
|
|
} else if (!inferredProviderRef) {
|
|
if (cachedBaseUrl) {
|
|
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
|
if (baseUrlLower.includes('deepinfra.com')) {
|
|
inferredProviderRef = 'deepinfra';
|
|
} else if (baseUrlLower.includes('openai.com')) {
|
|
inferredProviderRef = 'openai';
|
|
} else if (
|
|
baseUrlLower.includes('localhost') ||
|
|
baseUrlLower.includes('127.0.0.1') ||
|
|
baseUrlLower.includes('internal')
|
|
) {
|
|
inferredProviderRef = 'custom-openai';
|
|
} else {
|
|
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
|
}
|
|
} else {
|
|
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
|
}
|
|
}
|
|
|
|
let baseUrl = cachedBaseUrl || '';
|
|
if (!baseUrl) {
|
|
if (inferredProviderRef === 'openai') {
|
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
|
|
} else if (inferredProviderRef === 'deepinfra') {
|
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
|
|
} else {
|
|
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
|
|
}
|
|
}
|
|
|
|
return { providerRef: inferredProviderRef, baseUrl };
|
|
}
|
|
|
|
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
|
const { providerRef, baseUrl } = inferProviderRefAndBaseUrl(raw);
|
|
const providerTypeRaw = raw.providerType;
|
|
const providerType: TtsProviderType =
|
|
isTtsProviderType(providerTypeRaw)
|
|
? providerTypeRaw
|
|
: resolveEffectiveProviderType({ providerRef });
|
|
|
|
let savedVoices: SavedVoices = {};
|
|
if (raw.savedVoices) {
|
|
try {
|
|
savedVoices = JSON.parse(raw.savedVoices) as SavedVoices;
|
|
} catch (error) {
|
|
console.error('Error parsing savedVoices during migration:', error);
|
|
}
|
|
}
|
|
|
|
let documentListState: DocumentListState = APP_CONFIG_DEFAULTS.documentListState;
|
|
if (raw.documentListState) {
|
|
try {
|
|
documentListState = JSON.parse(raw.documentListState) as DocumentListState;
|
|
} catch (error) {
|
|
console.error('Error parsing documentListState during migration:', error);
|
|
}
|
|
}
|
|
|
|
const config: AppConfigRow = {
|
|
id: 'singleton',
|
|
...APP_CONFIG_DEFAULTS,
|
|
apiKey: raw.apiKey ?? APP_CONFIG_DEFAULTS.apiKey,
|
|
baseUrl,
|
|
viewType: (raw.viewType as ViewType) || APP_CONFIG_DEFAULTS.viewType,
|
|
voiceSpeed: raw.voiceSpeed ? parseFloat(raw.voiceSpeed) : APP_CONFIG_DEFAULTS.voiceSpeed,
|
|
audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
|
|
voice: '',
|
|
skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
|
|
epubTheme: raw.epubTheme === 'true',
|
|
headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin,
|
|
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
|
|
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
|
|
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
|
|
providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef,
|
|
providerType,
|
|
ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)),
|
|
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
|
|
savedVoices,
|
|
pdfHighlightEnabled:
|
|
raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled,
|
|
pdfWordHighlightEnabled:
|
|
raw.pdfWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfWordHighlightEnabled,
|
|
epubHighlightEnabled:
|
|
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
|
|
epubWordHighlightEnabled:
|
|
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
|
|
htmlHighlightEnabled:
|
|
raw.htmlHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlHighlightEnabled,
|
|
htmlWordHighlightEnabled:
|
|
raw.htmlWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlWordHighlightEnabled,
|
|
firstVisit: raw.firstVisit === 'true',
|
|
documentListState,
|
|
};
|
|
|
|
const voiceKey = `${config.providerRef}:${config.ttsModel}`;
|
|
config.voice = config.savedVoices[voiceKey] || '';
|
|
|
|
return config;
|
|
}
|
|
|
|
// Version 9: normalize app-config provider fields to providerRef/providerType.
|
|
db.version(DB_VERSION).stores({
|
|
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
|
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
|
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
|
[APP_CONFIG_TABLE]: 'id',
|
|
[LAST_LOCATION_TABLE]: 'docId',
|
|
[DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt',
|
|
[PREVIEW_CACHE_TABLE]: 'docId, lastModified, cachedAt, byteSize',
|
|
// `null` here means: drop the old 'config' table after upgrade runs,
|
|
// but Dexie still lets us read it inside the upgrade transaction.
|
|
[CONFIG_TABLE]: null,
|
|
}).upgrade(async (trans) => {
|
|
const appConfigTable = trans.table<AppConfigRow, string>(APP_CONFIG_TABLE);
|
|
const appConfig = await appConfigTable.get('singleton');
|
|
if (appConfig) {
|
|
const legacyProviderRef = (() => {
|
|
const providerRefRaw = (appConfig as unknown as { providerRef?: unknown }).providerRef;
|
|
if (typeof providerRefRaw === 'string' && providerRefRaw.trim()) return providerRefRaw;
|
|
const ttsProviderRaw = (appConfig as unknown as { ttsProvider?: unknown }).ttsProvider;
|
|
if (typeof ttsProviderRaw === 'string' && ttsProviderRaw.trim()) return ttsProviderRaw;
|
|
const providerRaw = (appConfig as unknown as { provider?: unknown }).provider;
|
|
if (typeof providerRaw === 'string' && providerRaw.trim()) return providerRaw;
|
|
return '';
|
|
})();
|
|
const providerRef = normalizeLegacyProviderRef(legacyProviderRef, APP_CONFIG_DEFAULTS.providerRef) || APP_CONFIG_DEFAULTS.providerRef;
|
|
const providerType = resolveEffectiveProviderType({ providerRef });
|
|
const savedVoices = appConfig.savedVoices && typeof appConfig.savedVoices === 'object'
|
|
? appConfig.savedVoices
|
|
: {};
|
|
const resolvedVoiceKey = `${providerRef}:${appConfig.ttsModel || APP_CONFIG_DEFAULTS.ttsModel}`;
|
|
const resolvedVoice = savedVoices[resolvedVoiceKey] || '';
|
|
await appConfigTable.put({
|
|
...appConfig,
|
|
providerRef,
|
|
providerType,
|
|
voice: appConfig.voice || resolvedVoice,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const configRows = await trans.table<ConfigRow, string>(CONFIG_TABLE).toArray();
|
|
const raw: RawConfigMap = {};
|
|
|
|
for (const row of configRows) {
|
|
raw[row.key] = row.value;
|
|
}
|
|
|
|
const built = buildAppConfigFromRaw(raw);
|
|
await appConfigTable.put(built);
|
|
|
|
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
|
|
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
|
|
for (const row of configRows) {
|
|
if (row.key.startsWith('lastLocation_')) {
|
|
const docId = row.key.substring('lastLocation_'.length);
|
|
await locationTable.put({ docId, location: row.value });
|
|
}
|
|
}
|
|
});
|
|
|
|
let dbOpenPromise: Promise<void> | null = null;
|
|
const cacheTextEncoder = new TextEncoder();
|
|
|
|
export async function initDB(): Promise<void> {
|
|
if (dbOpenPromise) {
|
|
return dbOpenPromise;
|
|
}
|
|
|
|
dbOpenPromise = (async () => {
|
|
try {
|
|
console.log('Opening Dexie database...');
|
|
emitDexieStatus('opening');
|
|
const startedAt = Date.now();
|
|
const stallTimer = setTimeout(() => {
|
|
emitDexieStatus('stalled', { ms: Date.now() - startedAt });
|
|
}, 4000);
|
|
await db.open();
|
|
await Promise.all([pruneDocumentCacheIfNeededInternal(), prunePreviewCacheIfNeededInternal()]).catch((error) => {
|
|
console.warn('Dexie cache prune on open failed:', error);
|
|
});
|
|
clearTimeout(stallTimer);
|
|
console.log('Dexie database opened successfully');
|
|
emitDexieStatus('opened');
|
|
} catch (error) {
|
|
console.error('Dexie initialization error:', error);
|
|
emitDexieStatus('error', { message: error instanceof Error ? error.message : String(error) });
|
|
dbOpenPromise = null;
|
|
throw error;
|
|
}
|
|
})();
|
|
|
|
return dbOpenPromise;
|
|
}
|
|
|
|
async function withDB<T>(operation: () => Promise<T>): Promise<T> {
|
|
await initDB();
|
|
return operation();
|
|
}
|
|
|
|
type DocumentCacheTableName = typeof PDF_TABLE | typeof EPUB_TABLE | typeof HTML_TABLE;
|
|
|
|
type DocumentCacheEntryRef = {
|
|
table: DocumentCacheTableName;
|
|
id: string;
|
|
byteSize: number;
|
|
accessedAt: number;
|
|
};
|
|
|
|
function toPositiveInt(value: unknown, fallback: number = 0): number {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
return Math.max(0, Math.floor(n));
|
|
}
|
|
|
|
function byteSizeForPdfRow(row: PDFCacheRow): number {
|
|
const cached = toPositiveInt(row.cacheByteSize, 0);
|
|
if (cached > 0) return cached;
|
|
return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0);
|
|
}
|
|
|
|
function byteSizeForEpubRow(row: EPUBCacheRow): number {
|
|
const cached = toPositiveInt(row.cacheByteSize, 0);
|
|
if (cached > 0) return cached;
|
|
return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0);
|
|
}
|
|
|
|
function byteSizeForHtmlRow(row: HTMLCacheRow): number {
|
|
const cached = toPositiveInt(row.cacheByteSize, 0);
|
|
if (cached > 0) return cached;
|
|
if (typeof row.data === 'string') return cacheTextEncoder.encode(row.data).byteLength;
|
|
return toPositiveInt(row.size, 0);
|
|
}
|
|
|
|
function accessedAtForRow(row: DocumentCacheMeta & { lastModified?: number }): number {
|
|
return toPositiveInt(row.cacheAccessedAt, toPositiveInt(row.lastModified, Date.now()));
|
|
}
|
|
|
|
async function deleteDocumentCacheEntryInternal(table: DocumentCacheTableName, id: string): Promise<void> {
|
|
if (table === PDF_TABLE) {
|
|
await db[PDF_TABLE].delete(id);
|
|
return;
|
|
}
|
|
if (table === EPUB_TABLE) {
|
|
await db[EPUB_TABLE].delete(id);
|
|
return;
|
|
}
|
|
await db[HTML_TABLE].delete(id);
|
|
}
|
|
|
|
async function pruneDocumentCacheIfNeededInternal(): Promise<void> {
|
|
const budget = DOCUMENT_CACHE_MAX_BYTES;
|
|
if (!Number.isFinite(budget) || budget <= 0) return;
|
|
|
|
const [pdfRows, epubRows, htmlRows] = await Promise.all([
|
|
db[PDF_TABLE].toArray(),
|
|
db[EPUB_TABLE].toArray(),
|
|
db[HTML_TABLE].toArray(),
|
|
]);
|
|
|
|
const entries: DocumentCacheEntryRef[] = [];
|
|
let totalBytes = 0;
|
|
|
|
for (const row of pdfRows) {
|
|
const byteSize = byteSizeForPdfRow(row);
|
|
totalBytes += byteSize;
|
|
entries.push({
|
|
table: PDF_TABLE,
|
|
id: row.id,
|
|
byteSize,
|
|
accessedAt: accessedAtForRow(row),
|
|
});
|
|
}
|
|
for (const row of epubRows) {
|
|
const byteSize = byteSizeForEpubRow(row);
|
|
totalBytes += byteSize;
|
|
entries.push({
|
|
table: EPUB_TABLE,
|
|
id: row.id,
|
|
byteSize,
|
|
accessedAt: accessedAtForRow(row),
|
|
});
|
|
}
|
|
for (const row of htmlRows) {
|
|
const byteSize = byteSizeForHtmlRow(row);
|
|
totalBytes += byteSize;
|
|
entries.push({
|
|
table: HTML_TABLE,
|
|
id: row.id,
|
|
byteSize,
|
|
accessedAt: accessedAtForRow(row),
|
|
});
|
|
}
|
|
|
|
if (totalBytes <= budget) return;
|
|
|
|
entries.sort((a, b) => a.accessedAt - b.accessedAt);
|
|
for (const entry of entries) {
|
|
if (totalBytes <= budget) break;
|
|
await deleteDocumentCacheEntryInternal(entry.table, entry.id);
|
|
totalBytes -= entry.byteSize;
|
|
}
|
|
}
|
|
|
|
async function prunePreviewCacheIfNeededInternal(): Promise<void> {
|
|
const budget = PREVIEW_CACHE_MAX_BYTES;
|
|
if (!Number.isFinite(budget) || budget <= 0) return;
|
|
|
|
const rows = await db[PREVIEW_CACHE_TABLE].toArray();
|
|
let totalBytes = 0;
|
|
const entries = rows.map((row) => {
|
|
const byteSize = toPositiveInt(
|
|
row.byteSize,
|
|
row.data instanceof ArrayBuffer ? row.data.byteLength : 0,
|
|
);
|
|
totalBytes += byteSize;
|
|
return {
|
|
docId: row.docId,
|
|
byteSize,
|
|
accessedAt: toPositiveInt(row.cachedAt, 0),
|
|
};
|
|
});
|
|
|
|
if (totalBytes <= budget) return;
|
|
|
|
entries.sort((a, b) => a.accessedAt - b.accessedAt);
|
|
for (const entry of entries) {
|
|
if (totalBytes <= budget) break;
|
|
await db[PREVIEW_CACHE_TABLE].delete(entry.docId);
|
|
totalBytes -= entry.byteSize;
|
|
}
|
|
}
|
|
|
|
function isSha256HexId(value: string): boolean {
|
|
return /^[a-f0-9]{64}$/i.test(value);
|
|
}
|
|
|
|
async function getMappedDocumentId(docId: string): Promise<string> {
|
|
if (isSha256HexId(docId)) return docId.toLowerCase();
|
|
const row = await db[DOCUMENT_ID_MAP_TABLE].get(docId);
|
|
return row?.id ?? docId;
|
|
}
|
|
|
|
export async function resolveDocumentId(docId: string): Promise<string> {
|
|
return withDB(async () => getMappedDocumentId(docId));
|
|
}
|
|
|
|
async function recordDocumentIdMapping(oldId: string, id: string): Promise<void> {
|
|
if (oldId === id) return;
|
|
await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() });
|
|
}
|
|
|
|
function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map<string, string>): DocumentListState {
|
|
let didChange = false;
|
|
|
|
const folders = state.folders.map((folder) => {
|
|
let folderChanged = false;
|
|
const seen = new Set<string>();
|
|
const documents: DocumentListDocument[] = [];
|
|
|
|
for (const doc of folder.documents) {
|
|
const mappedId = mapping.get(doc.id) ?? doc.id;
|
|
if (mappedId !== doc.id) folderChanged = true;
|
|
if (seen.has(mappedId)) {
|
|
folderChanged = true;
|
|
continue;
|
|
}
|
|
seen.add(mappedId);
|
|
documents.push(mappedId === doc.id ? doc : { ...doc, id: mappedId });
|
|
}
|
|
|
|
if (!folderChanged) return folder;
|
|
didChange = true;
|
|
return { ...folder, documents };
|
|
});
|
|
|
|
return didChange ? { ...state, folders } : state;
|
|
}
|
|
|
|
async function applyDocumentIdMapping(oldId: string, newId: string): Promise<void> {
|
|
if (!oldId || !newId || oldId === newId) return;
|
|
const nextId = newId.toLowerCase();
|
|
|
|
await withDB(async () => {
|
|
await db.transaction(
|
|
'readwrite',
|
|
[
|
|
db[PDF_TABLE],
|
|
db[EPUB_TABLE],
|
|
db[HTML_TABLE],
|
|
db[LAST_LOCATION_TABLE],
|
|
db[APP_CONFIG_TABLE],
|
|
db[DOCUMENT_ID_MAP_TABLE],
|
|
db[PREVIEW_CACHE_TABLE],
|
|
],
|
|
async () => {
|
|
await recordDocumentIdMapping(oldId, nextId);
|
|
|
|
const pdf = await db[PDF_TABLE].get(oldId);
|
|
if (pdf) {
|
|
const existing = await db[PDF_TABLE].get(nextId);
|
|
if (existing) {
|
|
const merged: PDFDocument = {
|
|
...pdf,
|
|
...existing,
|
|
id: nextId,
|
|
folderId: existing.folderId ?? pdf.folderId,
|
|
name: existing.name || pdf.name,
|
|
};
|
|
await db[PDF_TABLE].put(merged);
|
|
await db[PDF_TABLE].delete(oldId);
|
|
} else {
|
|
await db[PDF_TABLE].put({ ...pdf, id: nextId });
|
|
await db[PDF_TABLE].delete(oldId);
|
|
}
|
|
}
|
|
|
|
const epub = await db[EPUB_TABLE].get(oldId);
|
|
if (epub) {
|
|
const existing = await db[EPUB_TABLE].get(nextId);
|
|
if (existing) {
|
|
const merged: EPUBDocument = {
|
|
...epub,
|
|
...existing,
|
|
id: nextId,
|
|
folderId: existing.folderId ?? epub.folderId,
|
|
name: existing.name || epub.name,
|
|
};
|
|
await db[EPUB_TABLE].put(merged);
|
|
await db[EPUB_TABLE].delete(oldId);
|
|
} else {
|
|
await db[EPUB_TABLE].put({ ...epub, id: nextId });
|
|
await db[EPUB_TABLE].delete(oldId);
|
|
}
|
|
}
|
|
|
|
const html = await db[HTML_TABLE].get(oldId);
|
|
if (html) {
|
|
const existing = await db[HTML_TABLE].get(nextId);
|
|
if (existing) {
|
|
const merged: HTMLDocument = {
|
|
...html,
|
|
...existing,
|
|
id: nextId,
|
|
folderId: existing.folderId ?? html.folderId,
|
|
name: existing.name || html.name,
|
|
};
|
|
await db[HTML_TABLE].put(merged);
|
|
await db[HTML_TABLE].delete(oldId);
|
|
} else {
|
|
await db[HTML_TABLE].put({ ...html, id: nextId });
|
|
await db[HTML_TABLE].delete(oldId);
|
|
}
|
|
}
|
|
|
|
const oldLocation = await db[LAST_LOCATION_TABLE].get(oldId);
|
|
if (oldLocation) {
|
|
const newLocation = await db[LAST_LOCATION_TABLE].get(nextId);
|
|
if (!newLocation) {
|
|
await db[LAST_LOCATION_TABLE].put({ docId: nextId, location: oldLocation.location });
|
|
}
|
|
await db[LAST_LOCATION_TABLE].delete(oldId);
|
|
}
|
|
|
|
const preview = await db[PREVIEW_CACHE_TABLE].get(oldId);
|
|
if (preview) {
|
|
const existing = await db[PREVIEW_CACHE_TABLE].get(nextId);
|
|
if (!existing || Number(existing.cachedAt ?? 0) < Number(preview.cachedAt ?? 0)) {
|
|
await db[PREVIEW_CACHE_TABLE].put({ ...preview, docId: nextId });
|
|
}
|
|
await db[PREVIEW_CACHE_TABLE].delete(oldId);
|
|
}
|
|
|
|
const appConfig = await db[APP_CONFIG_TABLE].get('singleton');
|
|
if (appConfig?.documentListState) {
|
|
const mapped = rewriteDocumentListStateDocIds(appConfig.documentListState, new Map([[oldId, nextId]]));
|
|
if (mapped !== appConfig.documentListState) {
|
|
await db[APP_CONFIG_TABLE].update('singleton', { documentListState: mapped });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function migrateLegacyDexieDocumentIdsToSha(): Promise<Array<{ oldId: string; id: string }>> {
|
|
return withDB(async () => {
|
|
const mappings: Array<{ oldId: string; id: string }> = [];
|
|
|
|
const pdfDocs = await db[PDF_TABLE].toArray();
|
|
for (const doc of pdfDocs) {
|
|
if (isSha256HexId(doc.id)) continue;
|
|
const id = await sha256HexFromBytes(new Uint8Array(doc.data));
|
|
if (id !== doc.id) {
|
|
mappings.push({ oldId: doc.id, id });
|
|
await applyDocumentIdMapping(doc.id, id);
|
|
}
|
|
}
|
|
|
|
const epubDocs = await db[EPUB_TABLE].toArray();
|
|
for (const doc of epubDocs) {
|
|
if (isSha256HexId(doc.id)) continue;
|
|
const id = await sha256HexFromBytes(new Uint8Array(doc.data));
|
|
if (id !== doc.id) {
|
|
mappings.push({ oldId: doc.id, id });
|
|
await applyDocumentIdMapping(doc.id, id);
|
|
}
|
|
}
|
|
|
|
const htmlDocs = await db[HTML_TABLE].toArray();
|
|
for (const doc of htmlDocs) {
|
|
if (isSha256HexId(doc.id)) continue;
|
|
const id = await sha256HexFromString(doc.data);
|
|
if (id !== doc.id) {
|
|
mappings.push({ oldId: doc.id, id });
|
|
await applyDocumentIdMapping(doc.id, id);
|
|
}
|
|
}
|
|
|
|
return mappings;
|
|
});
|
|
}
|
|
|
|
export async function getDocumentIdMappings(): Promise<Array<{ oldId: string; id: string }>> {
|
|
return withDB(async () => {
|
|
const rows = await db[DOCUMENT_ID_MAP_TABLE].toArray();
|
|
return rows.map((row) => ({ oldId: row.oldId, id: row.id }));
|
|
});
|
|
}
|
|
|
|
// PDF document helpers
|
|
|
|
export async function addPdfDocument(document: PDFDocument): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Adding PDF document via Dexie:', document.name);
|
|
const now = Date.now();
|
|
await db[PDF_TABLE].put({
|
|
...document,
|
|
cacheCreatedAt: now,
|
|
cacheAccessedAt: now,
|
|
cacheByteSize: document.data.byteLength,
|
|
});
|
|
await pruneDocumentCacheIfNeededInternal();
|
|
});
|
|
}
|
|
|
|
export async function getPdfDocument(id: string): Promise<PDFDocument | undefined> {
|
|
return withDB(async () => {
|
|
console.log('Fetching PDF document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
const row = await db[PDF_TABLE].get(resolved);
|
|
if (row) {
|
|
await db[PDF_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
export async function getAllPdfDocuments(): Promise<PDFDocument[]> {
|
|
return withDB(async () => {
|
|
console.log('Fetching all PDF documents via Dexie');
|
|
return db[PDF_TABLE].toArray();
|
|
});
|
|
}
|
|
|
|
export async function removePdfDocument(id: string): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Removing PDF document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
|
|
await db[PDF_TABLE].delete(resolved);
|
|
await db[LAST_LOCATION_TABLE].delete(resolved);
|
|
await db[PREVIEW_CACHE_TABLE].delete(resolved);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function clearPdfDocuments(): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Clearing all PDF documents via Dexie');
|
|
await db[PDF_TABLE].clear();
|
|
});
|
|
}
|
|
|
|
// EPUB document helpers
|
|
|
|
export async function addEpubDocument(document: EPUBDocument): Promise<void> {
|
|
await withDB(async () => {
|
|
if (document.data.byteLength === 0) {
|
|
throw new Error('Cannot store empty ArrayBuffer');
|
|
}
|
|
|
|
console.log('Adding EPUB document via Dexie:', {
|
|
name: document.name,
|
|
size: document.size,
|
|
actualSize: document.data.byteLength,
|
|
});
|
|
|
|
const now = Date.now();
|
|
await db[EPUB_TABLE].put({
|
|
...document,
|
|
cacheCreatedAt: now,
|
|
cacheAccessedAt: now,
|
|
cacheByteSize: document.data.byteLength,
|
|
});
|
|
await pruneDocumentCacheIfNeededInternal();
|
|
});
|
|
}
|
|
|
|
export async function getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
|
|
return withDB(async () => {
|
|
console.log('Fetching EPUB document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
const row = await db[EPUB_TABLE].get(resolved);
|
|
if (row) {
|
|
await db[EPUB_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
export async function getAllEpubDocuments(): Promise<EPUBDocument[]> {
|
|
return withDB(async () => {
|
|
console.log('Fetching all EPUB documents via Dexie');
|
|
return db[EPUB_TABLE].toArray();
|
|
});
|
|
}
|
|
|
|
export async function removeEpubDocument(id: string): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Removing EPUB document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
|
|
await db[EPUB_TABLE].delete(resolved);
|
|
await db[LAST_LOCATION_TABLE].delete(resolved);
|
|
await db[PREVIEW_CACHE_TABLE].delete(resolved);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function clearEpubDocuments(): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Clearing all EPUB documents via Dexie');
|
|
await db[EPUB_TABLE].clear();
|
|
});
|
|
}
|
|
|
|
// HTML / text document helpers
|
|
|
|
export async function addHtmlDocument(document: HTMLDocument): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Adding HTML document via Dexie:', document.name);
|
|
const now = Date.now();
|
|
await db[HTML_TABLE].put({
|
|
...document,
|
|
cacheCreatedAt: now,
|
|
cacheAccessedAt: now,
|
|
cacheByteSize: cacheTextEncoder.encode(document.data).byteLength,
|
|
});
|
|
await pruneDocumentCacheIfNeededInternal();
|
|
});
|
|
}
|
|
|
|
export async function getHtmlDocument(id: string): Promise<HTMLDocument | undefined> {
|
|
return withDB(async () => {
|
|
console.log('Fetching HTML document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
const row = await db[HTML_TABLE].get(resolved);
|
|
if (row) {
|
|
await db[HTML_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> {
|
|
return withDB(async () => {
|
|
console.log('Fetching all HTML documents via Dexie');
|
|
return db[HTML_TABLE].toArray();
|
|
});
|
|
}
|
|
|
|
export async function removeHtmlDocument(id: string): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Removing HTML document via Dexie:', id);
|
|
const resolved = await getMappedDocumentId(id);
|
|
await db.transaction('readwrite', db[HTML_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
|
|
await db[HTML_TABLE].delete(resolved);
|
|
await db[PREVIEW_CACHE_TABLE].delete(resolved);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function clearHtmlDocuments(): Promise<void> {
|
|
await withDB(async () => {
|
|
console.log('Clearing all HTML documents via Dexie');
|
|
await db[HTML_TABLE].clear();
|
|
});
|
|
}
|
|
|
|
export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> {
|
|
return withDB(async () => {
|
|
const byId: Record<string, number> = {};
|
|
const write = (identity: string, ts: unknown) => {
|
|
const value = toPositiveInt(ts, 0);
|
|
if (value <= 0) return;
|
|
if (!byId[identity] || value > byId[identity]) byId[identity] = value;
|
|
};
|
|
|
|
await Promise.all([
|
|
db[PDF_TABLE]
|
|
.orderBy('cacheAccessedAt')
|
|
.eachKey((cacheAccessedAt, cursor) => write(`pdf|${String(cursor.primaryKey)}`, cacheAccessedAt)),
|
|
db[EPUB_TABLE]
|
|
.orderBy('cacheAccessedAt')
|
|
.eachKey((cacheAccessedAt, cursor) => write(`epub|${String(cursor.primaryKey)}`, cacheAccessedAt)),
|
|
db[HTML_TABLE]
|
|
.orderBy('cacheAccessedAt')
|
|
.eachKey((cacheAccessedAt, cursor) => write(`html|${String(cursor.primaryKey)}`, cacheAccessedAt)),
|
|
]);
|
|
|
|
return byId;
|
|
});
|
|
}
|
|
|
|
export async function getAppConfig(): Promise<AppConfigRow | null> {
|
|
return withDB(async () => {
|
|
const row = await db[APP_CONFIG_TABLE].get('singleton');
|
|
return row ?? null;
|
|
});
|
|
}
|
|
|
|
export async function updateAppConfig(partial: Partial<AppConfigRow>): Promise<void> {
|
|
await withDB(async () => {
|
|
const table = db[APP_CONFIG_TABLE];
|
|
const existing = await table.get('singleton');
|
|
|
|
if (!existing) {
|
|
await table.put({
|
|
id: 'singleton',
|
|
...APP_CONFIG_DEFAULTS,
|
|
...partial,
|
|
});
|
|
} else {
|
|
await table.update('singleton', partial);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Document list state helpers
|
|
|
|
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
|
|
await updateAppConfig({ documentListState: state });
|
|
}
|
|
|
|
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
|
const config = await getAppConfig();
|
|
if (!config || !config.documentListState) return null;
|
|
return config.documentListState;
|
|
}
|
|
|
|
// Last-location helpers (used by TTS and readers)
|
|
|
|
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
|
return withDB(async () => {
|
|
const resolved = await getMappedDocumentId(docId);
|
|
const row = await db[LAST_LOCATION_TABLE].get(resolved);
|
|
return row ? row.location : null;
|
|
});
|
|
}
|
|
|
|
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
|
await withDB(async () => {
|
|
const resolved = await getMappedDocumentId(docId);
|
|
await db[LAST_LOCATION_TABLE].put({ docId: resolved, location });
|
|
});
|
|
}
|
|
|
|
// First-visit helpers (used for onboarding/Settings modal)
|
|
|
|
export async function getFirstVisit(): Promise<boolean> {
|
|
const config = await getAppConfig();
|
|
return config?.firstVisit ?? false;
|
|
}
|
|
|
|
export async function setFirstVisit(value: boolean): Promise<void> {
|
|
await updateAppConfig({ firstVisit: value });
|
|
}
|
|
|
|
// Document preview cache helpers
|
|
|
|
export async function getDocumentPreviewCache(docId: string): Promise<DocumentPreviewCacheRow | undefined> {
|
|
return withDB(async () => {
|
|
const resolved = await getMappedDocumentId(docId);
|
|
const row = await db[PREVIEW_CACHE_TABLE].get(resolved);
|
|
if (row) {
|
|
await db[PREVIEW_CACHE_TABLE].update(resolved, { cachedAt: Date.now() });
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
export async function putDocumentPreviewCache(row: DocumentPreviewCacheRow): Promise<void> {
|
|
await withDB(async () => {
|
|
const resolved = await getMappedDocumentId(row.docId);
|
|
await db[PREVIEW_CACHE_TABLE].put({
|
|
...row,
|
|
docId: resolved,
|
|
cachedAt: Date.now(),
|
|
byteSize: toPositiveInt(row.byteSize, row.data.byteLength),
|
|
});
|
|
await prunePreviewCacheIfNeededInternal();
|
|
});
|
|
}
|
|
|
|
export async function removeDocumentPreviewCache(docId: string): Promise<void> {
|
|
await withDB(async () => {
|
|
const resolved = await getMappedDocumentId(docId);
|
|
await db[PREVIEW_CACHE_TABLE].delete(resolved);
|
|
});
|
|
}
|
|
|
|
export async function clearDocumentPreviewCache(): Promise<void> {
|
|
await withDB(async () => {
|
|
await db[PREVIEW_CACHE_TABLE].clear();
|
|
});
|
|
}
|
|
|
|
// Sync helpers (server round-trip)
|
|
|
|
export async function syncDocumentsToServer(
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<{ lastSync: number }> {
|
|
const pdfDocs = await getAllPdfDocuments();
|
|
const epubDocs = await getAllEpubDocuments();
|
|
const htmlDocs = await getAllHtmlDocuments();
|
|
|
|
const uploads: Array<{ oldId: string; source: UploadSource }> = [];
|
|
const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length;
|
|
let processedDocs = 0;
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
for (const doc of pdfDocs) {
|
|
const bytes = new Uint8Array(doc.data);
|
|
uploads.push({
|
|
oldId: doc.id,
|
|
source: {
|
|
name: doc.name,
|
|
type: 'pdf',
|
|
size: bytes.byteLength,
|
|
lastModified: doc.lastModified,
|
|
contentType: 'application/pdf',
|
|
body: bytes,
|
|
},
|
|
});
|
|
processedDocs++;
|
|
if (onProgress) {
|
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
|
}
|
|
}
|
|
|
|
for (const doc of epubDocs) {
|
|
const bytes = new Uint8Array(doc.data);
|
|
uploads.push({
|
|
oldId: doc.id,
|
|
source: {
|
|
name: doc.name,
|
|
type: 'epub',
|
|
size: bytes.byteLength,
|
|
lastModified: doc.lastModified,
|
|
contentType: 'application/epub+zip',
|
|
body: bytes,
|
|
},
|
|
});
|
|
processedDocs++;
|
|
if (onProgress) {
|
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
|
}
|
|
}
|
|
|
|
for (const doc of htmlDocs) {
|
|
const encoded = textEncoder.encode(doc.data);
|
|
uploads.push({
|
|
oldId: doc.id,
|
|
source: {
|
|
name: doc.name,
|
|
type: 'html',
|
|
size: encoded.byteLength,
|
|
lastModified: doc.lastModified,
|
|
contentType: 'text/plain; charset=utf-8',
|
|
body: encoded,
|
|
},
|
|
});
|
|
processedDocs++;
|
|
if (onProgress) {
|
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
|
}
|
|
}
|
|
|
|
if (onProgress) {
|
|
onProgress(50, 'Uploading to server...');
|
|
}
|
|
|
|
const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal });
|
|
|
|
for (let index = 0; index < uploads.length; index += 1) {
|
|
const entry = uploads[index];
|
|
const nextId = stored[index]?.id;
|
|
if (!nextId || entry.oldId === nextId) continue;
|
|
await applyDocumentIdMapping(entry.oldId, nextId);
|
|
}
|
|
|
|
if (onProgress) {
|
|
onProgress(100, 'Upload complete!');
|
|
}
|
|
|
|
return { lastSync: Date.now() };
|
|
}
|
|
|
|
export async function syncSelectedDocumentsToServer(
|
|
documents: BaseDocument[],
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<{ lastSync: number }> {
|
|
const uploads: Array<{ oldId: string; source: UploadSource }> = [];
|
|
const textEncoder = new TextEncoder();
|
|
let processed = 0;
|
|
|
|
for (const doc of documents) {
|
|
if (doc.type === 'pdf') {
|
|
const data = await getPdfDocument(doc.id);
|
|
if (data) {
|
|
const bytes = new Uint8Array(data.data);
|
|
uploads.push({
|
|
oldId: data.id,
|
|
source: {
|
|
name: data.name,
|
|
type: 'pdf',
|
|
size: bytes.byteLength,
|
|
lastModified: data.lastModified,
|
|
contentType: 'application/pdf',
|
|
body: bytes,
|
|
},
|
|
});
|
|
}
|
|
} else if (doc.type === 'epub') {
|
|
const data = await getEpubDocument(doc.id);
|
|
if (data) {
|
|
const bytes = new Uint8Array(data.data);
|
|
uploads.push({
|
|
oldId: data.id,
|
|
source: {
|
|
name: data.name,
|
|
type: 'epub',
|
|
size: bytes.byteLength,
|
|
lastModified: data.lastModified,
|
|
contentType: 'application/epub+zip',
|
|
body: bytes,
|
|
},
|
|
});
|
|
}
|
|
} else {
|
|
const data = await getHtmlDocument(doc.id);
|
|
if (data) {
|
|
const bytes = textEncoder.encode(data.data);
|
|
uploads.push({
|
|
oldId: data.id,
|
|
source: {
|
|
name: data.name,
|
|
type: 'html',
|
|
size: bytes.byteLength,
|
|
lastModified: data.lastModified,
|
|
contentType: 'text/plain; charset=utf-8',
|
|
body: bytes,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
processed++;
|
|
if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`);
|
|
}
|
|
|
|
if (onProgress) onProgress(50, 'Uploading to server...');
|
|
const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal });
|
|
|
|
for (let index = 0; index < uploads.length; index += 1) {
|
|
const entry = uploads[index];
|
|
const nextId = stored[index]?.id;
|
|
if (!nextId || entry.oldId === nextId) continue;
|
|
await applyDocumentIdMapping(entry.oldId, nextId);
|
|
}
|
|
|
|
if (onProgress) {
|
|
onProgress(100, 'Upload complete!');
|
|
}
|
|
|
|
return { lastSync: Date.now() };
|
|
}
|
|
|
|
|
|
export async function loadDocumentsFromServer(
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<{ lastSync: number }> {
|
|
if (onProgress) {
|
|
onProgress(10, 'Starting download...');
|
|
}
|
|
|
|
const documents = await listDocuments({ signal });
|
|
await downloadAndCacheServerDocuments(documents, onProgress, signal);
|
|
|
|
if (onProgress) {
|
|
onProgress(100, 'Load complete!');
|
|
}
|
|
|
|
return { lastSync: Date.now() };
|
|
}
|
|
|
|
export async function loadSelectedDocumentsFromServer(
|
|
selectedIds: string[],
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<{ lastSync: number }> {
|
|
if (onProgress) {
|
|
onProgress(10, 'Starting download...');
|
|
}
|
|
|
|
const documents = await listDocuments({ ids: selectedIds, signal });
|
|
await downloadAndCacheServerDocuments(documents, onProgress, signal);
|
|
|
|
if (onProgress) {
|
|
onProgress(100, 'Load complete!');
|
|
}
|
|
|
|
return { lastSync: Date.now() };
|
|
}
|
|
|
|
async function downloadAndCacheServerDocuments(
|
|
documents: BaseDocument[],
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
) {
|
|
if (onProgress) onProgress(30, 'List complete');
|
|
if (documents.length === 0) {
|
|
if (onProgress) onProgress(95, 'No documents to import');
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < documents.length; i++) {
|
|
const doc = documents[i];
|
|
const bytes = await downloadDocumentContent(doc.id, { signal });
|
|
await cacheStoredDocumentFromBytes(doc, bytes);
|
|
if (onProgress) {
|
|
onProgress(30 + ((i + 1) / documents.length) * 65, `Downloading ${i + 1}/${documents.length}: ${doc.name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
export async function importSelectedDocuments(
|
|
documents: BaseDocument[],
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
if (documents.length === 0) return;
|
|
|
|
const textDecoder = new TextDecoder();
|
|
|
|
for (let i = 0; i < documents.length; i++) {
|
|
const doc = documents[i];
|
|
|
|
if (onProgress) {
|
|
onProgress(10 + (i / documents.length) * 85, `Downloading ${i + 1}/${documents.length}: ${doc.name}`);
|
|
}
|
|
|
|
const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal });
|
|
if (!contentResponse.ok) {
|
|
console.warn(`Failed to download library document: ${doc.name}`);
|
|
continue;
|
|
}
|
|
|
|
const buffer = await contentResponse.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
if (doc.type === 'pdf') {
|
|
const localId = await sha256HexFromBytes(bytes);
|
|
await addPdfDocument({
|
|
id: localId,
|
|
type: 'pdf',
|
|
name: doc.name,
|
|
size: bytes.byteLength,
|
|
lastModified: doc.lastModified,
|
|
data: buffer,
|
|
});
|
|
} else if (doc.type === 'epub') {
|
|
const localId = await sha256HexFromBytes(bytes);
|
|
await addEpubDocument({
|
|
id: localId,
|
|
type: 'epub',
|
|
name: doc.name,
|
|
size: bytes.byteLength,
|
|
lastModified: doc.lastModified,
|
|
data: buffer,
|
|
});
|
|
} else {
|
|
const decoded = textDecoder.decode(bytes);
|
|
const localId = await sha256HexFromString(decoded);
|
|
await addHtmlDocument({
|
|
id: localId,
|
|
type: 'html',
|
|
name: doc.name,
|
|
size: bytes.byteLength,
|
|
lastModified: doc.lastModified,
|
|
data: decoded,
|
|
});
|
|
}
|
|
|
|
if (onProgress) {
|
|
onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function importDocumentsFromLibrary(
|
|
onProgress?: (progress: number, status?: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
if (onProgress) {
|
|
onProgress(5, 'Scanning server library...');
|
|
}
|
|
|
|
const listResponse = await fetch('/api/documents/library', { signal });
|
|
if (!listResponse.ok) {
|
|
throw new Error('Failed to list library documents');
|
|
}
|
|
|
|
const { documents } = (await listResponse.json()) as { documents: BaseDocument[] };
|
|
|
|
if (documents.length === 0) {
|
|
if (onProgress) {
|
|
onProgress(100, 'No documents found in server library');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (onProgress) {
|
|
onProgress(10, `Found ${documents.length} documents. Importing...`);
|
|
}
|
|
|
|
await importSelectedDocuments(documents, onProgress, signal);
|
|
|
|
if (onProgress) {
|
|
onProgress(100, 'Library import complete!');
|
|
}
|
|
}
|