diff --git a/app/components/settings/sync/SyncListSettings.svelte b/app/components/settings/sync/SyncListSettings.svelte index 01b09ee8b..531c24859 100644 --- a/app/components/settings/sync/SyncListSettings.svelte +++ b/app/components/settings/sync/SyncListSettings.svelte @@ -31,10 +31,12 @@ import type WebdavDataSyncSettings__SvelteComponent_ from '~/components/settings/sync/webdav/WebdavDataSyncSettings.svelte'; import type WebdavImageSyncSettings__SvelteComponent_ from '~/components/settings/sync/webdav/WebdavImageSyncSettings.svelte'; import type WebdavPDFSyncSettings__SvelteComponent_ from '~/components/settings/sync/webdav/WebdavPDFSyncSettings.svelte'; + import type PaperlessNgxPDFSyncSettings__SvelteComponent_ from '~/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte'; import { BaseDataSyncServiceOptions } from '~/services/sync/BaseDataSyncService'; import { BaseSyncServiceOptions } from '~/services/sync/BaseSyncService'; import { GoogleDriveSyncOptions } from '~/services/sync/gdrive/GoogleDrive'; import { OneDriveSyncOptions } from '~/services/sync/onedrive/OneDrive'; + import type { PaperlessNgxSyncOptions } from '~/services/sync/paperless/PaperlessNgx'; interface SettingsComponentReturnType { [SyncTypes.folder_image]: typeof FolderImageSyncSettings__SvelteComponent_; [SyncTypes.folder_pdf]: typeof FolderPDFSyncSettings__SvelteComponent_; @@ -47,6 +49,7 @@ [SyncTypes.gdrive_data]: typeof GoogleDriveDataSyncSettings__SvelteComponent_; [SyncTypes.gdrive_image]: typeof GoogleDriveImageSyncSettings__SvelteComponent_; [SyncTypes.gdrive_pdf]: typeof GoogleDrivePDFSyncSettings__SvelteComponent_; + [SyncTypes.paperless_pdf]: typeof PaperlessNgxPDFSyncSettings__SvelteComponent_; } async function getSettingsComponent(syncType: T): Promise { switch (syncType) { @@ -72,6 +75,8 @@ return (await import('~/components/settings/sync/gdrive/GoogleDriveImageSyncSettings.svelte')).default as any; case 'gdrive_pdf': return (await import('~/components/settings/sync/gdrive/GoogleDrivePDFSyncSettings.svelte')).default as any; + case 'paperless_pdf': + return (await import('~/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte')).default as any; } } @@ -188,6 +193,21 @@ } break; } + case SyncTypes.paperless_pdf: { + const type = item.type; + const page = await getSettingsComponent(type); + const result: BaseSyncServiceOptions & PaperlessNgxSyncOptions = await showModal({ + page, + fullscreen: true, + props: { + data: item as BaseSyncServiceOptions & PaperlessNgxSyncOptions + } + }); + if (result) { + configToUpdate = result; + } + break; + } } if (configToUpdate) { syncService.updateService(configToUpdate); @@ -311,6 +331,20 @@ } break; } + case SyncTypes.paperless_pdf: { + const page = await getSettingsComponent(SyncTypes.paperless_pdf); + const result: BaseSyncServiceOptions & PaperlessNgxSyncOptions = await showModal({ + page, + fullscreen: true, + props: { + data + } + }); + if (result) { + configToAdd = result; + } + break; + } } if (configToAdd) { const data = syncService.addService(selection?.data, configToAdd); @@ -343,6 +377,18 @@ case 'onedrive_pdf': case 'onedrive_image': return 'OneDrive'; + case 'paperless_pdf': { + const serverUrl = (item as BaseSyncServiceOptions & PaperlessNgxSyncOptions).serverUrl; + if (serverUrl) { + try { + const url = serverUrl.startsWith('http') ? serverUrl : 'https://' + serverUrl; + return result + url.split('//')[1].split('/')[0]; + } catch (e) { + return result + serverUrl; + } + } + return 'Paperless-ngx'; + } case 'webdav_data': case 'webdav_pdf': case 'webdav_image': diff --git a/app/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte b/app/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte new file mode 100644 index 000000000..7d563d618 --- /dev/null +++ b/app/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte new file mode 100644 index 000000000..909eeb109 --- /dev/null +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -0,0 +1,137 @@ + + + + { + $store.serverUrl = e['value']; + testConnectionSuccess = 0; + }} /> + + diff --git a/app/i18n/en.json b/app/i18n/en.json index 427f8d36a..f3946f0d4 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -317,6 +317,12 @@ "page_margin": "page margin", "page_padding": "margins", "paper_size": "paper size", + "paperless_config": "Paperless-ngx configuration", + "paperless_token_hint": "Enter an API token (generated in your Paperless-ngx profile) or provide username/password to acquire one automatically", + "api_token": "API token", + "missing_paperless_server_url": "Server URL is required", + "missing_paperless_credentials": "An API token or username/password is required", + "or": "or", "passbooks_saved": "Passbooks exported", "passcode_creation": "Create a PIN code", "password": "password", diff --git a/app/models/OCRDocument.ts b/app/models/OCRDocument.ts index e1f44713c..902865ac1 100644 --- a/app/models/OCRDocument.ts +++ b/app/models/OCRDocument.ts @@ -137,6 +137,7 @@ export interface DocumentExtra { color?: string; [k: string]: | string + | number | boolean | { type: string; @@ -542,6 +543,7 @@ export class OCRDocument extends Observable implements Document { // // TODO: fix why do we need to clear the whole cache? wrong cache key? // getImagePipeline().clearCaches(); // } else { + // eslint-disable-next-line @typescript-eslint/await-thenable await getImagePipeline().evictFromCache(croppedImagePath); // } await this.updatePage( diff --git a/app/services/api.ts b/app/services/api.ts index b1aca0c90..ebab6cef9 100644 --- a/app/services/api.ts +++ b/app/services/api.ts @@ -192,7 +192,7 @@ export async function request(requestParams: HttpRequestOptions, retry const requestStartTime = Date.now(); if (__DEV__) { // DEV_LOG && console.log(requestParams.url, JSON.stringify(requestParams)); - // logRequestAsCurl(requestParams.url, requestParams as any); + logRequestAsCurl(requestParams.url, requestParams as any); } try { const response = await https.request(requestParams); @@ -239,7 +239,7 @@ export async function handleRequestResponseError(response: https.HttpsR // if (statusCode === 401 && jsonReturn.error === 'invalid_grant') { // return this.handleRequestRetry(requestParams, retry); // } - const error = jsonReturn.error_description || jsonReturn.error || jsonReturn; + const error = jsonReturn.error_description || jsonReturn.error || JSON.stringify(jsonReturn); throw new HTTPError({ statusCode: error.code || statusCode, message: error.error_description || error.form || error.message || error.error || error, diff --git a/app/services/sync.ts b/app/services/sync.ts index 201d065eb..62e4b2b39 100644 --- a/app/services/sync.ts +++ b/app/services/sync.ts @@ -64,7 +64,8 @@ export const SERVICES_SYNC_TITLES: { [key in SYNC_TYPES]: string } = { gdrive_data: 'Google Drive', onedrive_image: 'OneDrive', onedrive_pdf: 'OneDrive', - onedrive_data: 'OneDrive' + onedrive_data: 'OneDrive', + paperless_pdf: 'Paperless-ngx' }; export interface SyncStateEventData extends EventData { @@ -173,6 +174,11 @@ export class SyncService extends BaseWorkerHandler { sendImageEvent(event: DocumentPagesAddedEventData) { DEV_LOG && console.log('Sync', 'sendImageEvent'); // only used for image sync + this.syncDocumentsInternal({ event, type: SyncType.IMAGE, fromEvent: event.eventName }); + } + sendImagesEvent(event: DocumentPagesAddedEventData) { + DEV_LOG && console.log('Sync', 'sendImagesEvent'); + // only used for image sync this.syncDocumentsInternal({ event, type: SyncType.IMAGE | SyncType.PDF, fromEvent: event.eventName }); } sendDataEvent(event: FolderUpdatedEventData) { @@ -238,7 +244,7 @@ export class SyncService extends BaseWorkerHandler { documentsService.on(EVENT_DOCUMENT_UPDATED, this.onDocumentUpdated, this); documentsService.on(EVENT_DOCUMENT_DELETED, this.onDocumentDeleted, this); documentsService.on(EVENT_DOCUMENT_PAGE_UPDATED, this.sendImageEvent, this); - documentsService.on(EVENT_DOCUMENT_PAGES_ADDED, this.sendImageEvent, this); + documentsService.on(EVENT_DOCUMENT_PAGES_ADDED, this.sendImagesEvent, this); documentsService.on(EVENT_DOCUMENT_MOVED_FOLDER, this.sendDataEvent, this); documentsService.on(EVENT_FOLDER_UPDATED, this.sendDataEvent, this); documentsService.on(EVENT_FOLDER_ADDED, this.sendDataEvent, this); diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts new file mode 100644 index 000000000..b3b6b6df7 --- /dev/null +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -0,0 +1,228 @@ +import { HttpsRequestOptions } from '@nativescript-community/https'; +import { File } from '@nativescript/core'; +import { request } from '~/services/api'; +import type { BufferLike } from '~/services/api'; + +export interface PaperlessServiceContext { + serverUrl: string; + token?: string; + username?: string; + password?: string; +} + +export interface PaperlessNgxSyncOptions { + serverUrl: string; + token?: string; + username?: string; + password?: string; +} + +/** Length of the ".pdf" extension string. */ +const PDF_EXT_LEN = 4; + +export interface PaperlessDocument { + id: number; + title: string; + content?: string; + created?: string; + modified?: string; + added?: string; + original_file_name?: string; + archived_file_name?: string; +} + +export type PaperlessTaskStatus = 'FAILURE' | 'PENDING' | 'RECEIVED' | 'RETRY' | 'REVOKED' | 'STARTED' | 'SUCCESS'; + +export interface PaperlessTask { + task_id: string; + task_file_name?: string; + date_done?: string; + type?: string; + status: PaperlessTaskStatus; + result?: string; + acknowledged?: boolean; + related_document?: number; +} + +export interface PaperlessDocumentListResponse { + count: number; + next: string | null; + previous: string | null; + results: PaperlessDocument[]; +} + +function getBaseUrl(serverUrl: string): string { + return serverUrl.replace(/\/+$/, ''); +} + +function getAuthHeaders(token: string | undefined): Record { + if (token) { + return { + Authorization: `Token ${token}` + }; + } + return {}; +} + +export async function makeRequest(service: PaperlessServiceContext, endpoint: string = '', options: Partial = {}) { + const { headers = {}, ...others } = options; + const baseUrl = getBaseUrl(service.serverUrl); + const requestOptions = { + url: `${baseUrl}${endpoint}`, + headers: { + ...getAuthHeaders(service.token), + ...headers + }, + responseOnMainThread: false, + ...others + } as HttpsRequestOptions; + return request(requestOptions); +} + +/** + * Acquire a token from Paperless-ngx using username/password credentials. + * POST /api/token/ + */ +export async function acquireToken(service: PaperlessServiceContext, username: string, password: string): Promise { + const response = await makeRequest<{ token: string }>(service, `/api/token/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: { username, password } + }); + const data = await response.json(); + return data.token; +} + +export async function ensureToken(service: PaperlessServiceContext) { + if (!service.token && service.username && service.password) { + service.token = await acquireToken(service, service.username, service.password); + } + return service.token; +} + +/** + * Test the connection to a Paperless-ngx server. + * Returns true if successful, false otherwise. + */ +export async function testPaperlessConnection({ password, serverUrl, token, username }: PaperlessNgxSyncOptions): Promise { + try { + let authToken = token; + if (!authToken && username && password) { + authToken = await acquireToken({ serverUrl }, username, password); + } + const baseUrl = getBaseUrl(serverUrl); + const response = await request({ + url: `${baseUrl}/api/documents/?page_size=1`, + method: 'GET', + headers: { + ...getAuthHeaders(authToken), + 'Content-Type': 'application/json' + } + }); + await response.json(); + return true; + } catch (error) { + console.error('PaperlessNgx connection test failed', error, error?.stack); + return false; + } +} + +/** + * List documents from Paperless-ngx. Fetches all pages. + */ +export async function listDocuments(service: PaperlessServiceContext): Promise { + await ensureToken(service); + const baseUrl = getBaseUrl(service.serverUrl); + const results: PaperlessDocument[] = []; + let nextUrl: string | null = `${baseUrl}/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name`; + + while (nextUrl) { + const response = await makeRequest(service, '', { + url: nextUrl, + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + const data = await response.json(); + results.push(...data.results); + nextUrl = data.next; + } + return results; +} + +/** + * Fetch task status list from Paperless-ngx. + * GET /api/tasks/ returns all recent tasks. + */ +export async function fetchTasks(service: PaperlessServiceContext): Promise { + await ensureToken(service); + const response = await makeRequest(service, '/api/tasks/', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + return response.json(); +} + +/** + * Upload a new version of an existing Paperless-ngx document. + * POST /api/documents/{id}/update_version/ + */ +export async function updateDocumentVersion(service: PaperlessServiceContext, paperlessDocId: number, title: string, fileData: File | BufferLike | string): Promise { + await ensureToken(service); + const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; + + await makeRequest(service, `/api/documents/${paperlessDocId}/update_version/`, { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data' + }, + body: [ + { + parameterName: 'version_label', + data: new Date().toISOString(), + contentType: 'text/plain' + }, + { + parameterName: 'document', + fileName, + contentType: 'application/pdf', + data: fileData + } + ] + }); +} + +/** + * Upload a PDF document to Paperless-ngx via POST /api/documents/post_document/ + * Returns the task UUID. + */ +export async function uploadDocument(service: PaperlessServiceContext, title: string, fileData: File | BufferLike | string): Promise { + await ensureToken(service); + const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; + + const response = await makeRequest(service, '/api/documents/post_document/', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data' + }, + body: [ + { + parameterName: 'title', + data: fileName.slice(0, -PDF_EXT_LEN), + contentType: 'text/plain' + }, + { + parameterName: 'document', + fileName, + contentType: 'application/pdf', + data: fileData + } + ] + }); + return (await response.text()).replaceAll('"', ''); +} diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts new file mode 100644 index 000000000..13342cfa5 --- /dev/null +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -0,0 +1,163 @@ +import { File, Screen, knownFolders, path } from '@nativescript/core'; +import { wrapNativeException } from '@nativescript/core/utils'; +import { generatePDFASync } from 'plugin-nativeprocessor'; +import type { DocFolder, OCRDocument } from '~/models/OCRDocument'; +import { networkService } from '~/services/api'; +import { DocumentEvents } from '~/services/documents'; +import PDFExportCanvas from '~/services/pdf/PDFExportCanvas'; +import { BasePDFSyncService, BasePDFSyncServiceOptions } from '~/services/sync/BasePDFSyncService'; +import { PaperlessNgxSyncOptions, PaperlessTask, ensureToken, fetchTasks, listDocuments, updateDocumentVersion, uploadDocument } from '~/services/sync/paperless/PaperlessNgx'; +import { SERVICES_SYNC_MASK } from '~/services/sync/types'; +import { PDF_EXT } from '~/utils/constants'; +import { getPageColorMatrix } from '~/utils/matrix'; +import type { FileStat } from '~/webdav'; + +export interface PaperlessNgxPDFSyncServiceOptions extends BasePDFSyncServiceOptions, PaperlessNgxSyncOptions {} + +/** Key in doc.extra where the linked Paperless document ID is stored. */ +const EXTRA_PAPERLESS_ID_KEY = 'paperless_pdf_id'; + +/** Polling interval in milliseconds. */ +const POLL_INTERVAL_MS = 2000; +const MAX_POLL_TIME_MS = 20000; + +export class PaperlessNgxPDFSyncService extends BasePDFSyncService { + shouldSync(force?: boolean, event?: DocumentEvents) { + return (force || (event && this.autoSync)) && networkService.connected; + } + static type = 'paperless_pdf'; + type = PaperlessNgxPDFSyncService.type; + syncMask = SERVICES_SYNC_MASK[PaperlessNgxPDFSyncService.type]; + serverUrl: string; + token: string; + username?: string; + password?: string; + + /** Map from task UUID to its promise resolvers, used for polling. */ + private pendingTasks = new Map void; reject: (err: Error) => void }>(); + /** Single shared polling loop promise, null when not running. */ + private pollingPromise: Promise | null = null; + + static start(config?: { id: number; [k: string]: any }) { + if (config) { + const service = PaperlessNgxPDFSyncService.getOrCreateInstance(); + Object.assign(service, config); + return service; + } + } + + override stop() {} + + /** + * Paperless-ngx manages its own storage — no remote folder to create. + */ + override async ensureRemoteFolder(): Promise { + return ensureToken(this); + } + + override async getRemoteFolderFiles(_relativePath: string): Promise { + const documents = await listDocuments(this); + return documents.map((doc) => { + const baseName = doc.original_file_name || `${doc.title}.pdf`; + const displayName = baseName.endsWith(PDF_EXT) ? baseName : `${baseName}${PDF_EXT}`; + return { + filename: displayName, + basename: displayName, + lastmod: doc.modified || doc.added || new Date().toISOString(), + size: 0, + type: 'file' as const, + mime: 'application/pdf' + }; + }); + } + + /** + * Register a task UUID and return a Promise that resolves with the Paperless + * document ID once the task reaches SUCCESS, or rejects on failure. + * Starts the polling loop if not already running. + */ + private waitForTask(taskUuid: string): Promise { + return new Promise((resolve, reject) => { + this.pendingTasks.set(taskUuid, { resolve, reject }); + this.startPolling(); + }); + } + startPollingStartTime; + private startPolling() { + if (!this.pollingPromise) { + this.startPollingStartTime = Date.now(); + this.pollingPromise = this.pollLoop(); + } + } + + private async pollLoop() { + while (this.pendingTasks.size > 0 && Date.now() - this.startPollingStartTime < MAX_POLL_TIME_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const tasks: PaperlessTask[] = await fetchTasks(this); + for (const task of tasks) { + const pending = this.pendingTasks.get(task.task_id); + if (!pending) { + continue; + } + if (task.status === 'SUCCESS') { + this.pendingTasks.delete(task.task_id); + pending.resolve(task.related_document); + } else if (task.status === 'FAILURE' || task.status === 'REVOKED') { + this.pendingTasks.delete(task.task_id); + pending.reject(new Error(`Paperless task ${task.task_id} failed with status ${task.status}: ${task.result ?? ''}`)); + } + } + } catch (err) { + DEV_LOG && console.error('PaperlessNgxPDFSyncService', 'pollLoop error', err); + } + } + this.pollingPromise = null; + } + + override async writePDF(document: OCRDocument, fileName: string, _docFolder?: DocFolder) { + const pages = document.pages; + if (!pages || pages.length === 0) { + return; + } + if (!fileName.endsWith(PDF_EXT)) { + fileName += PDF_EXT; + } + const temp = knownFolders.temp().path; + + if (__ANDROID__) { + const exportOptions = this.exportOptions; + const black_white = exportOptions.color === 'black_white'; + const options = JSON.stringify({ + overwrite: true, + text_scale: Screen.mainScreen.scale * 1.4, + pages: pages.map((p) => ({ ...p, colorMatrix: getPageColorMatrix(p, black_white ? 'grayscale' : undefined) })), + ...exportOptions + }); + await generatePDFASync(temp, fileName, options, wrapNativeException); + } else { + const exporter = new PDFExportCanvas(); + await exporter.export({ pages: pages.map((page) => ({ page, document })), folder: temp, filename: fileName, compress: true, options: this.exportOptions }); + } + const localFilePath = path.join(temp, fileName); + try { + const existingPaperlessId = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number; + + if (existingPaperlessId) { + // Document already exists on Paperless — upload a new version + await updateDocumentVersion(this, existingPaperlessId, fileName, File.fromPath(localFilePath)); + } else { + // New document — upload and wait for the task to resolve with the Paperless doc ID + const taskUuid = await uploadDocument(this, fileName, File.fromPath(localFilePath)); + const paperlessDocId = await this.waitForTask(taskUuid); + await document.save({ extra: { [EXTRA_PAPERLESS_ID_KEY]: paperlessDocId } }, false, false); + } + } finally { + try { + File.fromPath(localFilePath).remove(); + } catch (_) { + // ignore cleanup errors + } + } + } +} diff --git a/app/services/sync/types.ts b/app/services/sync/types.ts index dcba78c67..78b30727a 100644 --- a/app/services/sync/types.ts +++ b/app/services/sync/types.ts @@ -11,7 +11,8 @@ export enum SyncTypes { gdrive_pdf = 'gdrive_pdf', onedrive_data = 'onedrive_data', onedrive_image = 'onedrive_image', - onedrive_pdf = 'onedrive_pdf' + onedrive_pdf = 'onedrive_pdf', + paperless_pdf = 'paperless_pdf' } export type SYNC_TYPES = keyof typeof SyncTypes; @@ -26,7 +27,8 @@ export const SERVICES_SYNC_MASK: { [key in SYNC_TYPES]: number } = { gdrive_pdf: 1 << 9, onedrive_data: 1 << 10, onedrive_image: 1 << 11, - onedrive_pdf: 1 << 12 + onedrive_pdf: 1 << 12, + paperless_pdf: 1 << 13 }; export const SERVICES_SYNC_COLOR: { [key in SYNC_TYPES]: string } = { webdav_pdf: '#8293CE', @@ -39,7 +41,8 @@ export const SERVICES_SYNC_COLOR: { [key in SYNC_TYPES]: string } = { gdrive_pdf: '#FBBC05', onedrive_data: '#0078D4', onedrive_image: '#50E6FF', - onedrive_pdf: '#00BCF2' + onedrive_pdf: '#00BCF2', + paperless_pdf: '#17541F' }; export enum SyncType { diff --git a/app/workers/SyncWorker.ts b/app/workers/SyncWorker.ts index c3de034d0..70e9d6d26 100644 --- a/app/workers/SyncWorker.ts +++ b/app/workers/SyncWorker.ts @@ -23,6 +23,7 @@ import { SYNC_TYPES, SyncType, getRemoteDeleteDocumentSettingsKey } from '~/serv import { WebdavDataSyncService } from '~/services/sync/webdav/WebdavDataSyncService'; import { WebdavImageSyncService } from '~/services/sync/webdav/WebdavImageSyncService'; import { WebdavPDFSyncService } from '~/services/sync/webdav/WebdavPDFSyncService'; +import { PaperlessNgxPDFSyncService } from '~/services/sync/paperless/PaperlessNgxPDFSyncService'; import { DOCUMENT_DATA_FILENAME, EVENT_DOCUMENT_ADDED, @@ -57,7 +58,8 @@ export const SERVICES_TYPE_MAP: { [key in SYNC_TYPES]: typeof BaseSyncService } gdrive_pdf: GoogleDrivePDFSyncService, onedrive_data: OneDriveDataSyncService, onedrive_image: OneDriveImageSyncService, - onedrive_pdf: OneDrivePDFSyncService + onedrive_pdf: OneDrivePDFSyncService, + paperless_pdf: PaperlessNgxPDFSyncService }; const TAG = '[SyncWorker]';