From 59921747033c74b4e571b24e0816e8c0fab8dbe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:57:48 +0000 Subject: [PATCH 1/8] Add Paperless-ngx PDF sync provider - Add PaperlessNgx.ts API helper with token auth, document listing and PDF upload - Add PaperlessNgxPDFSyncService.ts extending BasePDFSyncService - Add PaperlessNgxSettingsView.svelte and PaperlessNgxPDFSyncSettings.svelte settings UI - Register paperless_pdf in types.ts, sync.ts, SyncWorker.ts and SyncListSettings.svelte - Add i18n strings to en.json Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/80eebb5c-7dda-4531-b56c-95ec0e3fbe3b Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- .../settings/sync/SyncListSettings.svelte | 46 ++++++ .../PaperlessNgxPDFSyncSettings.svelte | 34 +++++ .../paperless/PaperlessNgxSettingsView.svelte | 142 ++++++++++++++++++ app/i18n/en.json | 6 + app/services/sync.ts | 3 +- app/services/sync/paperless/PaperlessNgx.ts | 139 +++++++++++++++++ .../paperless/PaperlessNgxPDFSyncService.ts | 93 ++++++++++++ app/services/sync/types.ts | 9 +- app/workers/SyncWorker.ts | 4 +- 9 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 app/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte create mode 100644 app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte create mode 100644 app/services/sync/paperless/PaperlessNgx.ts create mode 100644 app/services/sync/paperless/PaperlessNgxPDFSyncService.ts diff --git a/app/components/settings/sync/SyncListSettings.svelte b/app/components/settings/sync/SyncListSettings.svelte index 01b09ee8..531c2485 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 00000000..7d563d61 --- /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 00000000..d942a714 --- /dev/null +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -0,0 +1,142 @@ + + + + { + $store.serverUrl = e['value']; + testConnectionSuccess = 0; + }} /> + + diff --git a/app/i18n/en.json b/app/i18n/en.json index 427f8d36..f3946f0d 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/services/sync.ts b/app/services/sync.ts index 201d065e..c227875e 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 { diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts new file mode 100644 index 00000000..8bbb936b --- /dev/null +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -0,0 +1,139 @@ +import { File } from '@nativescript/core'; +import { request } from '~/services/api'; +import type { BufferLike } from '~/services/api'; + +export interface PaperlessNgxSyncOptions { + serverUrl: string; + token?: string; + username?: string; + password?: string; +} + +export interface PaperlessDocument { + id: number; + title: string; + content?: string; + created?: string; + modified?: string; + added?: string; + original_file_name?: string; + archived_file_name?: string; +} + +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): Record { + return { + Authorization: `Token ${token}` + }; +} + +/** + * Acquire a token from Paperless-ngx using username/password credentials. + * POST /api/token/ + */ +export async function acquireToken(serverUrl: string, username: string, password: string): Promise { + const baseUrl = getBaseUrl(serverUrl); + const response = await request<{ token: string }>({ + url: `${baseUrl}/api/token/`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password }) + }); + const data = await response.json(); + return data.token; +} + +/** + * Test the connection to a Paperless-ngx server. + * Returns true if successful, false otherwise. + */ +export async function testPaperlessConnection({ serverUrl, token, username, password }: 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(options: PaperlessNgxSyncOptions): Promise { + const baseUrl = getBaseUrl(options.serverUrl); + const results: PaperlessDocument[] = []; + let url: string | null = `${baseUrl}/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name`; + + while (url) { + const response = await request({ + url, + method: 'GET', + headers: { + ...getAuthHeaders(options.token), + 'Content-Type': 'application/json' + } + }); + const data = await response.json(); + results.push(...data.results); + url = data.next; + } + return results; +} + +/** + * Upload a PDF document to Paperless-ngx via POST /api/documents/post_document/ + * Returns the task UUID. + */ +export async function uploadDocument(options: PaperlessNgxSyncOptions, title: string, fileData: File | BufferLike | string): Promise { + const baseUrl = getBaseUrl(options.serverUrl); + const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; + + const response = await request({ + url: `${baseUrl}/api/documents/post_document/`, + method: 'POST', + headers: { + ...getAuthHeaders(options.token), + 'Content-Type': 'multipart/form-data' + }, + body: [ + { + parameterName: 'title', + data: title.replace(/\.pdf$/i, ''), + contentType: 'text/plain' + }, + { + parameterName: 'document', + fileName, + contentType: 'application/pdf', + data: fileData + } + ] + }); + return response.text(); +} diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts new file mode 100644 index 00000000..286ac845 --- /dev/null +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -0,0 +1,93 @@ +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, acquireToken, listDocuments, 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 {} + +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; + + static start(config?: { id: number; [k: string]: any }) { + if (config) { + const service = PaperlessNgxPDFSyncService.getOrCreateInstance(); + Object.assign(service, config); + DEV_LOG && console.log('PaperlessNgxPDFSyncService', 'start', JSON.stringify({ ...config, token: config.token ? '[redacted]' : undefined }), service.autoSync); + return service; + } + } + + override stop() {} + + /** + * Paperless-ngx manages its own storage — no remote folder to create. + */ + override async ensureRemoteFolder(): Promise { + // Ensure we have a valid token (acquire one if only username/password configured) + if (!this.token && this.username && this.password) { + this.token = await acquireToken(this.serverUrl, this.username, this.password); + } + } + + override async getRemoteFolderFiles(_relativePath: string): Promise { + const documents = await listDocuments({ serverUrl: this.serverUrl, token: this.token }); + 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' + }; + }); + } + + 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); + await uploadDocument({ serverUrl: this.serverUrl, token: this.token }, fileName, File.fromPath(localFilePath)); + } +} diff --git a/app/services/sync/types.ts b/app/services/sync/types.ts index dcba78c6..78b30727 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 c3de034d..70e9d6d2 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]'; From 981f94afa8b8be0cedb7e609e4204bbefde3a1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:59:51 +0000 Subject: [PATCH 2/8] Fix code review issues: temp file cleanup and password clear flow Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/80eebb5c-7dda-4531-b56c-95ec0e3fbe3b Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- .../sync/paperless/PaperlessNgxSettingsView.svelte | 13 ++++--------- .../sync/paperless/PaperlessNgxPDFSyncService.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte index d942a714..b9616c83 100644 --- a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -28,15 +28,10 @@ // If only username/password provided, acquire a token first if (!data.token && data.username && data.password) { - try { - const acquiredToken = await acquireToken(data.serverUrl, data.username, data.password); - $store.token = acquiredToken; - // Clear password from store so it is not persisted - $store.password = ''; - } catch (e) { - testConnectionSuccess = -1; - throw e; - } + const acquiredToken = await acquireToken(data.serverUrl, data.username, data.password); + $store.token = acquiredToken; + // Clear password from store so it is not persisted; the token is used going forward + $store.password = ''; } const result = await testPaperlessConnection(get(store)); diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 286ac845..482ff7b3 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -88,6 +88,14 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { await exporter.export({ pages: pages.map((page) => ({ page, document })), folder: temp, filename: fileName, compress: true, options: this.exportOptions }); } const localFilePath = path.join(temp, fileName); - await uploadDocument({ serverUrl: this.serverUrl, token: this.token }, fileName, File.fromPath(localFilePath)); + try { + await uploadDocument({ serverUrl: this.serverUrl, token: this.token }, fileName, File.fromPath(localFilePath)); + } finally { + try { + File.fromPath(localFilePath).remove(); + } catch (_) { + // ignore cleanup errors + } + } } } From df6b53180c2b7cff5865c8ecc92e6cb6da87a01b Mon Sep 17 00:00:00 2001 From: farfromrefuge Date: Thu, 16 Apr 2026 10:22:15 +0200 Subject: [PATCH 3/8] chore: improved paperless service --- .../paperless/PaperlessNgxSettingsView.svelte | 3 +- app/services/sync/paperless/PaperlessNgx.ts | 61 +++++++++++++------ .../paperless/PaperlessNgxPDFSyncService.ts | 13 ++-- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte index b9616c83..948e6742 100644 --- a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -5,6 +5,7 @@ import { lc } from '~/helpers/locale'; import { acquireToken, testPaperlessConnection } from '~/services/sync/paperless/PaperlessNgx'; import type { PaperlessNgxSyncOptions } from '~/services/sync/paperless/PaperlessNgx'; + import { PaperlessNgxPDFSyncService } from '~/services/sync/paperless/PaperlessNgxPDFSyncService'; import { colors } from '~/variables'; $: ({ colorError, colorOnError, colorSecondary } = $colors); @@ -28,7 +29,7 @@ // If only username/password provided, acquire a token first if (!data.token && data.username && data.password) { - const acquiredToken = await acquireToken(data.serverUrl, data.username, data.password); + const acquiredToken = await acquireToken({ serverUrl: data.serverUrl } as PaperlessNgxPDFSyncService, data.username, data.password); $store.token = acquiredToken; // Clear password from store so it is not persisted; the token is used going forward $store.password = ''; diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts index 8bbb936b..6c7b2cee 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -1,6 +1,9 @@ +import { HttpsRequestOptions } from '@nativescript-community/https'; import { File } from '@nativescript/core'; +import PaperlessNgxPDFSyncSettings from '~/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte'; import { request } from '~/services/api'; import type { BufferLike } from '~/services/api'; +import { PaperlessNgxPDFSyncService, PaperlessNgxPDFSyncServiceOptions } from '~/services/sync/paperless/PaperlessNgxPDFSyncService'; export interface PaperlessNgxSyncOptions { serverUrl: string; @@ -32,29 +35,53 @@ function getBaseUrl(serverUrl: string): string { } function getAuthHeaders(token: string): Record { - return { - Authorization: `Token ${token}` - }; + if (token) { + return { + Authorization: `Token ${token}` + }; + } + return {}; +} + +export async function makeRequest(service: PaperlessNgxPDFSyncService, 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(serverUrl: string, username: string, password: string): Promise { - const baseUrl = getBaseUrl(serverUrl); - const response = await request<{ token: string }>({ - url: `${baseUrl}/api/token/`, +export async function acquireToken(service: PaperlessNgxPDFSyncService, username: string, password: string): Promise { + const response = await makeRequest<{ token: string }>(service, `/api/token/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) + body: { username, password } }); const data = await response.json(); return data.token; } +export async function ensureToken(service: PaperlessNgxPDFSyncService) { + 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. @@ -63,7 +90,7 @@ export async function testPaperlessConnection({ serverUrl, token, username, pass try { let authToken = token; if (!authToken && username && password) { - authToken = await acquireToken(serverUrl, username, password); + authToken = await acquireToken({ serverUrl } as PaperlessNgxPDFSyncService, username, password); } const baseUrl = getBaseUrl(serverUrl); const response = await request({ @@ -85,17 +112,17 @@ export async function testPaperlessConnection({ serverUrl, token, username, pass /** * List documents from Paperless-ngx. Fetches all pages. */ -export async function listDocuments(options: PaperlessNgxSyncOptions): Promise { - const baseUrl = getBaseUrl(options.serverUrl); +export async function listDocuments(service: PaperlessNgxPDFSyncService): Promise { + await ensureToken(service); + const baseUrl = getBaseUrl(service.serverUrl); const results: PaperlessDocument[] = []; let url: string | null = `${baseUrl}/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name`; while (url) { - const response = await request({ + const response = await makeRequest(service, '/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name', { url, method: 'GET', headers: { - ...getAuthHeaders(options.token), 'Content-Type': 'application/json' } }); @@ -110,15 +137,13 @@ export async function listDocuments(options: PaperlessNgxSyncOptions): Promise

{ - const baseUrl = getBaseUrl(options.serverUrl); +export async function uploadDocument(service: PaperlessNgxPDFSyncService, title: string, fileData: File | BufferLike | string): Promise { + await ensureToken(service); const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; - const response = await request({ - url: `${baseUrl}/api/documents/post_document/`, + const response = await makeRequest(service, '/api/documents/post_document/', { method: 'POST', headers: { - ...getAuthHeaders(options.token), 'Content-Type': 'multipart/form-data' }, body: [ diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 482ff7b3..5fabd402 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -6,7 +6,7 @@ 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, acquireToken, listDocuments, uploadDocument } from '~/services/sync/paperless/PaperlessNgx'; +import { PaperlessNgxSyncOptions, acquireToken, ensureToken, listDocuments, 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'; @@ -40,15 +40,12 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { /** * Paperless-ngx manages its own storage — no remote folder to create. */ - override async ensureRemoteFolder(): Promise { - // Ensure we have a valid token (acquire one if only username/password configured) - if (!this.token && this.username && this.password) { - this.token = await acquireToken(this.serverUrl, this.username, this.password); - } + override async ensureRemoteFolder(): Promise { + return ensureToken(this); } override async getRemoteFolderFiles(_relativePath: string): Promise { - const documents = await listDocuments({ serverUrl: this.serverUrl, token: this.token }); + 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}`; @@ -89,7 +86,7 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { } const localFilePath = path.join(temp, fileName); try { - await uploadDocument({ serverUrl: this.serverUrl, token: this.token }, fileName, File.fromPath(localFilePath)); + await uploadDocument(this, fileName, File.fromPath(localFilePath)); } finally { try { File.fromPath(localFilePath).remove(); From 70aad90113e6b45fcb5168b90a3042b03906cd26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:12:44 +0000 Subject: [PATCH 4/8] fix: resolve circular dependency and clean up imports in Paperless-ngx service Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/48dc88d2-dacc-4f30-b678-8907c59e2da3 Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- .../paperless/PaperlessNgxSettingsView.svelte | 3 +- app/services/sync/paperless/PaperlessNgx.ts | 31 +++++++++++-------- .../paperless/PaperlessNgxPDFSyncService.ts | 2 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte index 948e6742..8fe6ead0 100644 --- a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -5,7 +5,6 @@ import { lc } from '~/helpers/locale'; import { acquireToken, testPaperlessConnection } from '~/services/sync/paperless/PaperlessNgx'; import type { PaperlessNgxSyncOptions } from '~/services/sync/paperless/PaperlessNgx'; - import { PaperlessNgxPDFSyncService } from '~/services/sync/paperless/PaperlessNgxPDFSyncService'; import { colors } from '~/variables'; $: ({ colorError, colorOnError, colorSecondary } = $colors); @@ -29,7 +28,7 @@ // If only username/password provided, acquire a token first if (!data.token && data.username && data.password) { - const acquiredToken = await acquireToken({ serverUrl: data.serverUrl } as PaperlessNgxPDFSyncService, data.username, data.password); + const acquiredToken = await acquireToken({ serverUrl: data.serverUrl, token: undefined }, data.username, data.password); $store.token = acquiredToken; // Clear password from store so it is not persisted; the token is used going forward $store.password = ''; diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts index 6c7b2cee..c45ff8cd 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -1,9 +1,14 @@ import { HttpsRequestOptions } from '@nativescript-community/https'; import { File } from '@nativescript/core'; -import PaperlessNgxPDFSyncSettings from '~/components/settings/sync/paperless/PaperlessNgxPDFSyncSettings.svelte'; import { request } from '~/services/api'; import type { BufferLike } from '~/services/api'; -import { PaperlessNgxPDFSyncService, PaperlessNgxPDFSyncServiceOptions } from '~/services/sync/paperless/PaperlessNgxPDFSyncService'; + +export interface PaperlessServiceContext { + serverUrl: string; + token: string; + username?: string; + password?: string; +} export interface PaperlessNgxSyncOptions { serverUrl: string; @@ -43,7 +48,7 @@ function getAuthHeaders(token: string): Record { return {}; } -export async function makeRequest(service: PaperlessNgxPDFSyncService, endpoint: string, options: Partial = {}) { +export async function makeRequest(service: PaperlessServiceContext, endpoint: string, options: Partial = {}) { const { headers = {}, ...others } = options; const baseUrl = getBaseUrl(service.serverUrl); @@ -63,7 +68,7 @@ export async function makeRequest(service: PaperlessNgxPDFSyncService, * Acquire a token from Paperless-ngx using username/password credentials. * POST /api/token/ */ -export async function acquireToken(service: PaperlessNgxPDFSyncService, username: string, password: string): Promise { +export async function acquireToken(service: PaperlessServiceContext, username: string, password: string): Promise { const response = await makeRequest<{ token: string }>(service, `/api/token/`, { method: 'POST', headers: { @@ -75,7 +80,7 @@ export async function acquireToken(service: PaperlessNgxPDFSyncService, username return data.token; } -export async function ensureToken(service: PaperlessNgxPDFSyncService) { +export async function ensureToken(service: PaperlessServiceContext) { if (!service.token && service.username && service.password) { service.token = await acquireToken(service, service.username, service.password); } @@ -90,7 +95,7 @@ export async function testPaperlessConnection({ serverUrl, token, username, pass try { let authToken = token; if (!authToken && username && password) { - authToken = await acquireToken({ serverUrl } as PaperlessNgxPDFSyncService, username, password); + authToken = await acquireToken({ serverUrl, token: undefined }, username, password); } const baseUrl = getBaseUrl(serverUrl); const response = await request({ @@ -112,15 +117,15 @@ export async function testPaperlessConnection({ serverUrl, token, username, pass /** * List documents from Paperless-ngx. Fetches all pages. */ -export async function listDocuments(service: PaperlessNgxPDFSyncService): Promise { +export async function listDocuments(service: PaperlessServiceContext): Promise { await ensureToken(service); const baseUrl = getBaseUrl(service.serverUrl); const results: PaperlessDocument[] = []; - let url: string | null = `${baseUrl}/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name`; + let nextUrl: string | null = `${baseUrl}/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name`; - while (url) { - const response = await makeRequest(service, '/api/documents/?page_size=100&fields=id,title,modified,added,original_file_name', { - url, + while (nextUrl) { + const response = await makeRequest(service, '', { + url: nextUrl, method: 'GET', headers: { 'Content-Type': 'application/json' @@ -128,7 +133,7 @@ export async function listDocuments(service: PaperlessNgxPDFSyncService): Promis }); const data = await response.json(); results.push(...data.results); - url = data.next; + nextUrl = data.next; } return results; } @@ -137,7 +142,7 @@ export async function listDocuments(service: PaperlessNgxPDFSyncService): Promis * Upload a PDF document to Paperless-ngx via POST /api/documents/post_document/ * Returns the task UUID. */ -export async function uploadDocument(service: PaperlessNgxPDFSyncService, title: string, fileData: File | BufferLike | string): Promise { +export async function uploadDocument(service: PaperlessServiceContext, title: string, fileData: File | BufferLike | string): Promise { await ensureToken(service); const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 5fabd402..3ffc9918 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -6,7 +6,7 @@ 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, acquireToken, ensureToken, listDocuments, uploadDocument } from '~/services/sync/paperless/PaperlessNgx'; +import { PaperlessNgxSyncOptions, ensureToken, listDocuments, 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'; From ae1fa82909cbf87aa2e98a941777518e5a2fc18c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:14:30 +0000 Subject: [PATCH 5/8] fix: make token optional in PaperlessServiceContext interface Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/48dc88d2-dacc-4f30-b678-8907c59e2da3 Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- .../settings/sync/paperless/PaperlessNgxSettingsView.svelte | 2 +- app/services/sync/paperless/PaperlessNgx.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte index 8fe6ead0..909eeb10 100644 --- a/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte +++ b/app/components/settings/sync/paperless/PaperlessNgxSettingsView.svelte @@ -28,7 +28,7 @@ // If only username/password provided, acquire a token first if (!data.token && data.username && data.password) { - const acquiredToken = await acquireToken({ serverUrl: data.serverUrl, token: undefined }, data.username, data.password); + const acquiredToken = await acquireToken({ serverUrl: data.serverUrl }, data.username, data.password); $store.token = acquiredToken; // Clear password from store so it is not persisted; the token is used going forward $store.password = ''; diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts index c45ff8cd..b11603c8 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -5,7 +5,7 @@ import type { BufferLike } from '~/services/api'; export interface PaperlessServiceContext { serverUrl: string; - token: string; + token?: string; username?: string; password?: string; } @@ -95,7 +95,7 @@ export async function testPaperlessConnection({ serverUrl, token, username, pass try { let authToken = token; if (!authToken && username && password) { - authToken = await acquireToken({ serverUrl, token: undefined }, username, password); + authToken = await acquireToken({ serverUrl }, username, password); } const baseUrl = getBaseUrl(serverUrl); const response = await request({ From 3e326e881faf4b9669281c33c247d537f49a4bed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:27:07 +0000 Subject: [PATCH 6/8] feat: implement Paperless-ngx document versioning and task polling Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/83c26700-fab1-4aca-ad94-1c94c5c2e9ad Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- app/services/sync/paperless/PaperlessNgx.ts | 57 ++++++++++++++++ .../paperless/PaperlessNgxPDFSyncService.ts | 68 ++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts index b11603c8..ad4a0ca7 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -28,6 +28,19 @@ export interface PaperlessDocument { 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; @@ -138,6 +151,50 @@ export async function listDocuments(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. diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 3ffc9918..83732a36 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -6,7 +6,7 @@ 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, ensureToken, listDocuments, uploadDocument } from '~/services/sync/paperless/PaperlessNgx'; +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'; @@ -14,6 +14,12 @@ 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; + export class PaperlessNgxPDFSyncService extends BasePDFSyncService { shouldSync(force?: boolean, event?: DocumentEvents) { return (force || (event && this.autoSync)) && networkService.connected; @@ -26,6 +32,11 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { 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(); @@ -60,6 +71,49 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { }); } + /** + * 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(); + }); + } + + private startPolling() { + if (!this.pollingPromise) { + this.pollingPromise = this.pollLoop(); + } + } + + private async pollLoop() { + while (this.pendingTasks.size > 0) { + 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) { @@ -86,7 +140,17 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { } const localFilePath = path.join(temp, fileName); try { - await uploadDocument(this, fileName, File.fromPath(localFilePath)); + const existingPaperlessId: number | undefined = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number | undefined; + + 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(); From 2c8390f1069bc30d7bd884decac42e59b88b9c4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:32:41 +0000 Subject: [PATCH 7/8] fix: address code review issues (token type, endpoint default, title strip) Agent-Logs-Url: https://github.com/ossdocumentscanner/OSS-DocumentScanner/sessions/83c26700-fab1-4aca-ad94-1c94c5c2e9ad Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- app/services/sync/paperless/PaperlessNgx.ts | 9 ++++++--- .../sync/paperless/PaperlessNgxPDFSyncService.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/services/sync/paperless/PaperlessNgx.ts b/app/services/sync/paperless/PaperlessNgx.ts index ad4a0ca7..521a9244 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -17,6 +17,9 @@ export interface PaperlessNgxSyncOptions { password?: string; } +/** Length of the ".pdf" extension string. */ +const PDF_EXT_LEN = 4; + export interface PaperlessDocument { id: number; title: string; @@ -52,7 +55,7 @@ function getBaseUrl(serverUrl: string): string { return serverUrl.replace(/\/+$/, ''); } -function getAuthHeaders(token: string): Record { +function getAuthHeaders(token: string | undefined): Record { if (token) { return { Authorization: `Token ${token}` @@ -61,7 +64,7 @@ function getAuthHeaders(token: string): Record { return {}; } -export async function makeRequest(service: PaperlessServiceContext, endpoint: string, options: Partial = {}) { +export async function makeRequest(service: PaperlessServiceContext, endpoint: string = '', options: Partial = {}) { const { headers = {}, ...others } = options; const baseUrl = getBaseUrl(service.serverUrl); @@ -211,7 +214,7 @@ export async function uploadDocument(service: PaperlessServiceContext, title: st body: [ { parameterName: 'title', - data: title.replace(/\.pdf$/i, ''), + data: fileName.slice(0, -PDF_EXT_LEN), contentType: 'text/plain' }, { diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 83732a36..820a370f 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -140,7 +140,7 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { } const localFilePath = path.join(temp, fileName); try { - const existingPaperlessId: number | undefined = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number | undefined; + const existingPaperlessId = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number | undefined; if (existingPaperlessId) { // Document already exists on Paperless — upload a new version From 97e215cccfcc96faf6a9b5f2716d79c488346fbc Mon Sep 17 00:00:00 2001 From: farfromrefuge Date: Thu, 16 Apr 2026 18:56:06 +0200 Subject: [PATCH 8/8] chore: working --- app/models/OCRDocument.ts | 2 ++ app/services/api.ts | 4 ++-- app/services/sync.ts | 7 ++++++- app/services/sync/paperless/PaperlessNgx.ts | 5 ++--- .../sync/paperless/PaperlessNgxPDFSyncService.ts | 9 +++++---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/models/OCRDocument.ts b/app/models/OCRDocument.ts index e1f44713..902865ac 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 b1aca0c9..ebab6cef 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 c227875e..62e4b2b3 100644 --- a/app/services/sync.ts +++ b/app/services/sync.ts @@ -174,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) { @@ -239,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 index 521a9244..b3b6b6df 100644 --- a/app/services/sync/paperless/PaperlessNgx.ts +++ b/app/services/sync/paperless/PaperlessNgx.ts @@ -67,7 +67,6 @@ function getAuthHeaders(token: string | undefined): Record { 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: { @@ -107,7 +106,7 @@ export async function ensureToken(service: PaperlessServiceContext) { * Test the connection to a Paperless-ngx server. * Returns true if successful, false otherwise. */ -export async function testPaperlessConnection({ serverUrl, token, username, password }: PaperlessNgxSyncOptions): Promise { +export async function testPaperlessConnection({ password, serverUrl, token, username }: PaperlessNgxSyncOptions): Promise { try { let authToken = token; if (!authToken && username && password) { @@ -225,5 +224,5 @@ export async function uploadDocument(service: PaperlessServiceContext, title: st } ] }); - return response.text(); + return (await response.text()).replaceAll('"', ''); } diff --git a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts index 820a370f..13342cfa 100644 --- a/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts +++ b/app/services/sync/paperless/PaperlessNgxPDFSyncService.ts @@ -19,6 +19,7 @@ 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) { @@ -41,7 +42,6 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { if (config) { const service = PaperlessNgxPDFSyncService.getOrCreateInstance(); Object.assign(service, config); - DEV_LOG && console.log('PaperlessNgxPDFSyncService', 'start', JSON.stringify({ ...config, token: config.token ? '[redacted]' : undefined }), service.autoSync); return service; } } @@ -82,15 +82,16 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { this.startPolling(); }); } - + startPollingStartTime; private startPolling() { if (!this.pollingPromise) { + this.startPollingStartTime = Date.now(); this.pollingPromise = this.pollLoop(); } } private async pollLoop() { - while (this.pendingTasks.size > 0) { + 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); @@ -140,7 +141,7 @@ export class PaperlessNgxPDFSyncService extends BasePDFSyncService { } const localFilePath = path.join(temp, fileName); try { - const existingPaperlessId = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number | undefined; + const existingPaperlessId = document.extra?.[EXTRA_PAPERLESS_ID_KEY] as number; if (existingPaperlessId) { // Document already exists on Paperless — upload a new version