From 5eb2716ccebadd26735b7fd827269d3356d737cd Mon Sep 17 00:00:00 2001 From: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:20:19 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(scan):=20=E2=9C=A8=20Setup=20Bun=20Pro?= =?UTF-8?q?ject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> --- .lefthook.yml | 7 +++++ biome.jsonc | 39 ++++++++++++++++++++++++++ bun.lock | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ bunfig.toml | 2 ++ package.json | 21 ++++++++++++++ tsconfig.json | 29 +++++++++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 .lefthook.yml create mode 100644 biome.jsonc create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.lefthook.yml b/.lefthook.yml new file mode 100644 index 0000000..9e1762d --- /dev/null +++ b/.lefthook.yml @@ -0,0 +1,7 @@ +pre-commit: + parallel: true + jobs: + - run: bun format {staged_files} + glob: + - "*.ts" + stage_fixed: true diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..f534dcc --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": ["src/**/*.ts", "src/**/*.tsx", "*.json", "*.jsonc"] + }, + "assist": { + "actions": { + "source": { + "organizeImports": { + "level": "on", + "options": { + "groups": [":URL:", ":BUN:"] + } + } + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useConst": "error", + "useImportType": "error" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..4b43388 --- /dev/null +++ b/bun.lock @@ -0,0 +1,78 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "scan-station", + "dependencies": { + "@topcli/spinner": "3.0.0", + }, + "devDependencies": { + "@biomejs/biome": "2.1.4", + "@types/bun": "latest", + "lefthook": "1.12.3", + }, + "peerDependencies": { + "typescript": "5", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.1.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.4", "@biomejs/cli-darwin-x64": "2.1.4", "@biomejs/cli-linux-arm64": "2.1.4", "@biomejs/cli-linux-arm64-musl": "2.1.4", "@biomejs/cli-linux-x64": "2.1.4", "@biomejs/cli-linux-x64-musl": "2.1.4", "@biomejs/cli-win32-arm64": "2.1.4", "@biomejs/cli-win32-x64": "2.1.4" }, "bin": { "biome": "bin/biome" } }, "sha512-QWlrqyxsU0FCebuMnkvBIkxvPqH89afiJzjMl+z67ybutse590jgeaFdDurE9XYtzpjRGTI1tlUZPGWmbKsElA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sCrNENE74I9MV090Wq/9Dg7EhPudx3+5OiSoQOkIe3DLPzFARuL1dOwCWhKCpA3I5RHmbrsbNSRfZwCabwd8Qg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-gOEICJbTCy6iruBywBDcG4X5rHMbqCPs3clh3UQ+hRKlgvJTk4NHWQAyHOXvaLe+AxD1/TNX1jbZeffBJzcrOw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-juhEkdkKR4nbUi5k/KRp1ocGPNWLgFRD4NrHZSveYrD6i98pyvuzmS9yFYgOZa5JhaVqo0HPnci0+YuzSwT2fw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-nYr7H0CyAJPaLupFE2cH16KZmRC5Z9PEftiA2vWxk+CsFkPZQ6dBRdcC6RuS+zJlPc/JOd8xw3uCCt9Pv41WvQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Eoy9ycbhpJVYuR+LskV9s3uyaIkp89+qqgqhGQsWnp/I02Uqg2fXFblHJOpGZR8AxdB9ADy87oFVxn9MpFKUrw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lvwvb2SQQHctHUKvBKptR6PLFCM7JfRjpCCrDaTmvB7EeZ5/dQJPhTYBf36BE/B4CRWR2ZiBLRYhK7hhXBCZAg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3WRYte7orvyi6TRfIZkDN9Jzoogbv+gSvR+b9VOXUg1We1XrjBg6WljADeVEaKTvOcpVdH0a90TwyOQ6ue4fGw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-tBc+W7anBPSFXGAoQW+f/+svkpt8/uXfRwDzN1DvnatkRMt16KIYpEi/iw8u9GahJlFv98kgHcIrSsZHZTR0sw=="], + + "@topcli/spinner": ["@topcli/spinner@3.0.0", "", { "dependencies": { "cli-spinners": "^3.1.0" } }, "sha512-wQHGvZSDiYt8OJyoS9SC2kVX2FjiH4fcpgAeL6xna1kQoiNlF8n6t7y1CbvickRfDUcGCHQKNf0Rp6DJqEmb4Q=="], + + "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + + "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], + + "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + + "cli-spinners": ["cli-spinners@3.2.0", "", {}, "sha512-pXftdQloMZzjCr3pCTIRniDcys6dDzgpgVhAHHk6TKBDbRuP1MkuetTF5KSv4YUutbOPa7+7ZrAJ2kVtbMqyXA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "lefthook": ["lefthook@1.12.3", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.12.3", "lefthook-darwin-x64": "1.12.3", "lefthook-freebsd-arm64": "1.12.3", "lefthook-freebsd-x64": "1.12.3", "lefthook-linux-arm64": "1.12.3", "lefthook-linux-x64": "1.12.3", "lefthook-openbsd-arm64": "1.12.3", "lefthook-openbsd-x64": "1.12.3", "lefthook-windows-arm64": "1.12.3", "lefthook-windows-x64": "1.12.3" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-huMg+mGp6wHPjkaLdchuOvxVRMzvz6OVdhivatiH2Qn47O5Zm46jwzbVPYIanX6N/8ZTjGLBxv8tZ0KYmKt/Jg=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j1lwaosWRy3vhz8oQgCS1M6EUFN95aIYeNuqkczsBoAA6BDNAmVP1ctYEIYUK4bYaIgENbqbA9prYMAhyzh6Og=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@1.12.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-x6aWFfLQX4m5zQ4X9zh5+hHOE5XTvNjz2zB9DI+xbIBLs2RRg0xJNT3OfgSrBU1QtEBneJ5dRQP5nl47td9GDQ=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@1.12.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-41OmulLqVZ0EOHmmHouJrpL59SwDD7FLoso4RsQVIBPaf8fHacdLo07Ye28VWQ5XolZQvnWcr1YXKo4JhqQMyw=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@1.12.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-741/JRCJIS++hgYEH2uefN4FsH872V7gy2zDhcfQofiZnWP7+qhl4Wmwi8IpjIu4X7hLOC4cT18LOVU5L8KV9Q=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@1.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXIy1aDFZmFgmebJliNrEqZfX1lSOD4b/USvANv1UirFrNgTq5SRssd1CKfflT2PwKX6LsJTD4WabLLWZOxp9A=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@1.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-FRdwdj5jsQAP2eVrtkVUqMqYNCbQ2Ix84izy29/BvLlu/hVypAGbDfUkgFnsmAd6ZsCBeYCEtPuqyg3E3SO0Rg=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@1.12.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-tch5wXY4GOjKAYohH7OFoxNdVHuUSYt2Pulo2VTkMYEG8IrvJnRO5MkvgHtKDHzU5mfABQYv5+ccJykDx5hQWA=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@1.12.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-IHbHg/rUFXrAN7LnjcQEtutCHBaD49CZge96Hpk0GZ2eEG5GTCNRnUyEf+Kf3+RTqHFgwtADdpeDa/ZaGZTM4g=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@1.12.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-wghcE5TSpb+mbtemUV6uAo9hEK09kxRzhf2nPdeDX+fw42cL2TGZsbaCnDyzaY144C+L2/wEWrLIHJMnZYkuqA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@1.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-7Co/L8e2x2hGC1L33jDJ4ZlTkO3PJm25GOGpLfN1kqwhGB/uzMLeTI/PBczjlIN8isUv26ouNd9rVR7Bibrwyg=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..b6874be --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +exact = true diff --git a/package.json b/package.json new file mode 100644 index 0000000..26ee3d9 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "scan-station", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "compile": "bun build --compile --outfile dist/scan --minify src/scan.ts", + "format": "biome lint --write && biome format --write" + }, + "devDependencies": { + "@biomejs/biome": "2.1.4", + "@types/bun": "latest", + "lefthook": "1.12.3" + }, + "peerDependencies": { + "typescript": "5" + }, + "dependencies": { + "@topcli/spinner": "3.0.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} From 919b7b43632fd10ce16e7e2ed3c953124158cf8b Mon Sep 17 00:00:00 2001 From: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:21:16 -0600 Subject: [PATCH 2/4] refactor: Restructure Scripts Signed-off-by: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> --- .gitignore | 34 ++++++++++++++++++++++++++++++ scanbd.conf => conf/scanbd.conf | 2 +- discover.sh => scripts/discover.sh | 1 - 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .gitignore rename scanbd.conf => conf/scanbd.conf (96%) rename discover.sh => scripts/discover.sh (94%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/scanbd.conf b/conf/scanbd.conf similarity index 96% rename from scanbd.conf rename to conf/scanbd.conf index e3f1643..0c8801e 100644 --- a/scanbd.conf +++ b/conf/scanbd.conf @@ -26,6 +26,6 @@ global { to-value = 0 } desc = "Scan to file" - script = "scan.sh" + script = "scan" } } diff --git a/discover.sh b/scripts/discover.sh similarity index 94% rename from discover.sh rename to scripts/discover.sh index 765d9d3..7c7f97d 100644 --- a/discover.sh +++ b/scripts/discover.sh @@ -1,6 +1,5 @@ #!/bin/sh -vendor=${vendor:-"fujitsu"} device=$(lsusb | grep -i $vendor | awk '{print $6}') if [ -z "$device" ]; then From 190fdf80897de0f98bb81baa80b63741d5c64e61 Mon Sep 17 00:00:00 2001 From: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> Date: Wed, 13 Aug 2025 03:14:51 -0600 Subject: [PATCH 3/4] =?UTF-8?q?feat(uploader):=20=E2=9C=A8=20Implement=20U?= =?UTF-8?q?ploading=20Plugin=20System?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> --- src/uploader/UploaderPlugin.test.ts | 137 ++++++++++++++++++++++++++++ src/uploader/UploaderPlugin.ts | 108 ++++++++++++++++++++++ src/uploader/plugins/Papra.ts | 48 ++++++++++ src/uploader/plugins/index.ts | 3 + src/uploader/uploader.ts | 19 ++++ 5 files changed, 315 insertions(+) create mode 100644 src/uploader/UploaderPlugin.test.ts create mode 100644 src/uploader/UploaderPlugin.ts create mode 100644 src/uploader/plugins/Papra.ts create mode 100644 src/uploader/plugins/index.ts create mode 100644 src/uploader/uploader.ts diff --git a/src/uploader/UploaderPlugin.test.ts b/src/uploader/UploaderPlugin.test.ts new file mode 100644 index 0000000..c640f62 --- /dev/null +++ b/src/uploader/UploaderPlugin.test.ts @@ -0,0 +1,137 @@ +import { + expect, + test, + describe, + beforeEach, + afterEach, + mock, + beforeAll, +} from 'bun:test'; +import UploaderPlugin, { + type configKeyType, + type UploaderPluginInterface, +} from './UploaderPlugin'; + +const mockSpinnerStart = mock(); +const mockSpinnerSucceed = mock(); +const mockSpinnerFailed = mock(); + +mock.module('@topcli/spinner', () => ({ + Spinner: function spinner() { + return { + start: mockSpinnerStart, + succeed: mockSpinnerSucceed, + failed: mockSpinnerFailed, + }; + }, +})); + +const TEST_URL = 'http://test.com/api/upload'; + +/** + * TestPlugin is a mock implementation of UploaderPlugin for testing purposes. + * It simulates a plugin that uploads files to a test URL with specific headers. + */ +class TestPlugin extends UploaderPlugin implements UploaderPluginInterface { + public override name = 'TestPlugin'; + override configRequired: configKeyType = ['TEST_URL', 'TEST_CONFIG_1']; + override configOptional = ['TEST_CONFIG_OPT_1']; + + override get apiUrl(): string { + return 'http://test.com/api/upload'; + } + + override get headers(): Record { + return { + 'Content-Type': 'multipart/form-data', + Authorization: 'Bearer test-token', + testConfig1: this.config.TEST_CONFIG_1 as string, + testConfigOpt1: this.config.TEST_CONFIG_OPT_1 as string, + }; + } + + override createFormData(filePath: string): FormData { + const formData = new FormData(); + formData.append('filePath', Bun.file(filePath)); + formData.append('testField', 'testValue'); + return formData; + } +} + +describe('UploaderPlugin Unhappy Path: Missing Configs', () => { + test('Should throw error if required config is missing', () => { + const plugin = new TestPlugin(); + expect(() => plugin.init()).toThrowError(); + }); +}); + +describe('Uploader Plugin Happy Path: Configs Exist in ENV.', () => { + const fetchMock = mock(); + + beforeAll(() => { + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + beforeEach(() => { + process.env.TESTPLUGIN_TEST_URL = TEST_URL; + process.env.TESTPLUGIN_TEST_CONFIG_1 = 'value2'; + process.env.TESTPLUGIN_TEST_CONFIG_OPT_1 = 'optionalValue1'; + }); + + afterEach(() => { + mock.clearAllMocks(); + }); + + test('Should initialize the plugin', () => { + const plugin = new TestPlugin(); + plugin.init(); + expect(plugin.name).toBe('TestPlugin'); + expect(plugin.configRequired).toEqual(['TEST_URL', 'TEST_CONFIG_1']); + expect(plugin.configOptional).toEqual(['TEST_CONFIG_OPT_1']); + }); + + test('Should upload file correctly', async () => { + const plugin = new TestPlugin(); + plugin.init(); + + fetchMock.mockReturnValue( + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }), + ); + + await plugin.upload('test-file.pdf'); + expect(fetchMock).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: 'Bearer test-token', + testConfig1: 'value2', + testConfigOpt1: 'optionalValue1', + }, + }), + ); + }); + + test('Should handle upload failure', async () => { + const plugin = new TestPlugin(); + plugin.init(); + + fetchMock.mockReturnValue( + Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }), + ); + + await expect(plugin.upload('test-file.pdf')).rejects; + expect(mockSpinnerFailed).toHaveBeenCalledWith( + `Failed to upload test-file.pdf to TestPlugin: Error: HTTP error! status: 500`, + ); + }); +}); diff --git a/src/uploader/UploaderPlugin.ts b/src/uploader/UploaderPlugin.ts new file mode 100644 index 0000000..7cf9b52 --- /dev/null +++ b/src/uploader/UploaderPlugin.ts @@ -0,0 +1,108 @@ +import { Spinner } from '@topcli/spinner'; + +export type configKeyType = string[]; +type configObjectType = Record; +export interface UploaderPluginInterface { + name: string; + upload(file: string): Promise; +} + +/** + * Base class for uploader plugins. + * It provides methods to load configuration from environment variables, + */ +export default class UploaderPlugin implements UploaderPluginInterface { + private readonly spinner = new Spinner(); + protected config!: configObjectType; + protected readonly configOptional!: configKeyType; + protected readonly configRequired!: configKeyType; + public readonly name!: string; + + /** + * Initializes the plugin by loading the configuration. + */ + public init() { + try { + this.config = this.loadConfig(); + // don't log values in console as they may contain sensitive information. + this.spinner.start(`Plugin ${this.name} initialized with config`); + } catch (error) { + this.spinner.failed(`Failed to initialize plugin ${this.name}: ${error}`); + throw new Error( + `Failed to load config for plugin ${this.name}: ${error}.`, + ); + } + } + + private loadConfig(): configObjectType { + if (!this.name) { + throw new Error('Plugin name is not defined'); + } + + const requiredConfig = this.configRequired.reduce( + (acc, key): configObjectType => { + const envKey = this.envKey(key); + if (!(envKey in process.env)) { + throw new Error( + `Environment variable ${envKey} is required for plugin ${this.name}`, + ); + } + acc[key] = process.env[envKey] as string; + return acc; + }, + {} as configObjectType, + ); + + const optionalConfig = this.configOptional.reduce((acc, key) => { + const envKey = this.envKey(key); + if (envKey in process.env) { + acc[key] = process.env[envKey] as string; + } + return acc; + }, {} as configObjectType); + + return { ...optionalConfig, ...requiredConfig }; + } + + /** + * Uploads a file to the configured API endpoint. + * + * @param file The filepath to upload. + */ + public async upload(file: string): Promise { + this.spinner.start(`Uploading ${file} to ${this.name}`); + try { + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: this.headers, + body: this.createFormData(file), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + this.spinner.succeed( + `Successfully uploaded ${file} to ${this.name}: ${JSON.stringify(response)}`, + ); + } catch (error) { + this.spinner.failed(`Failed to upload ${file} to ${this.name}: ${error}`); + } + } + + private envKey(key: string): string { + return `${this.name.toUpperCase()}_${key.toUpperCase()}`; + } + + protected createFormData(_filePath: string): FormData { + throw new Error( + `createFormData method not implemented for plugin ${this.name}`, + ); + } + + protected get apiUrl(): string { + throw new Error(`apiUrl getter not implemented for plugin ${this.name}`); + } + + protected get headers(): Record { + throw new Error(`headers getter not implemented for plugin ${this.name}`); + } +} diff --git a/src/uploader/plugins/Papra.ts b/src/uploader/plugins/Papra.ts new file mode 100644 index 0000000..d200b72 --- /dev/null +++ b/src/uploader/plugins/Papra.ts @@ -0,0 +1,48 @@ +import UploaderPlugin, { + type configKeyType, + type UploaderPluginInterface, +} from '../UploaderPlugin'; + +export default class PapraPlugin + extends UploaderPlugin + implements UploaderPluginInterface +{ + public override name = 'Papra'; + override configRequired: configKeyType = [ + 'API_URL', + 'API_KEY', + 'ORGANIZATION_ID', + ]; + override configOptional = ['OCR_LANGUAGE']; + + override get apiUrl(): string { + const url = new URL(this.config.API_URL as string); + const organizationId = this.config.ORGANIZATION_ID as string; + url.pathname = `/api/organizations/${organizationId}/documents`; + return url.toString(); + } + + override get headers(): Record { + return { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${this.config.API_KEY as string}`, + }; + } + + override createFormData(filePath: string): FormData { + try { + const fileContent = Bun.file(filePath); + const formData = new FormData(); + + formData.append('file', fileContent); + if (this.config.OCR_LANGUAGE) { + formData.append('ocr_language', this.config.OCR_LANGUAGE); + } + return formData; + } catch (error) { + throw new Error( + `Failed to create FormData for file ${filePath}: ${error}`, + ); + } + } +} diff --git a/src/uploader/plugins/index.ts b/src/uploader/plugins/index.ts new file mode 100644 index 0000000..1c1c844 --- /dev/null +++ b/src/uploader/plugins/index.ts @@ -0,0 +1,3 @@ +import PapraPlugin from './Papra'; + +export default [PapraPlugin]; diff --git a/src/uploader/uploader.ts b/src/uploader/uploader.ts new file mode 100644 index 0000000..bd7d835 --- /dev/null +++ b/src/uploader/uploader.ts @@ -0,0 +1,19 @@ +import uploaderPlugins from './plugins'; +import type UploaderPlugin from './UploaderPlugin'; + +const activatedPlugins = uploaderPlugins.reduce((acc, plugin) => { + try { + const instance = new plugin(); + instance.init(); + acc.push(instance); + } catch (_error) { + // do not throw an error here; + } + return acc; +}, [] as UploaderPlugin[]); + +export default async function uploader(filePath: string) { + return Promise.all( + activatedPlugins.map((plugin) => plugin.upload(filePath as string)), + ); +} From 57bfbae58c7bfbc2458dac7ad47f98ad3ef324c7 Mon Sep 17 00:00:00 2001 From: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> Date: Wed, 13 Aug 2025 03:15:26 -0600 Subject: [PATCH 4/4] =?UTF-8?q?chore(scan):=20=E2=99=BB=EF=B8=8F=20Rewrite?= =?UTF-8?q?=20`scan.sh`=20->=20`scan.ts`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> --- Dockerfile | 18 +++++++++++++++--- scan.sh | 34 ---------------------------------- src/scan.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 37 deletions(-) delete mode 100644 scan.sh create mode 100644 src/scan.ts diff --git a/Dockerfile b/Dockerfile index 0c32ce6..06898fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +FROM oven/bun as build + +WORKDIR /build + +ENV NODE_ENV=production +COPY . . +RUN bun install --frozen-lockfile && \ + bun test --ci && \ + bun compile + FROM debian:bookworm-slim WORKDIR /app @@ -23,11 +33,13 @@ RUN apt update && \ util-linux ENV TZ=${TZ-"America/Edmonton"} +ENV vendor=${vendor-"fujitsu"} + RUN git clone https://github.com/rocketraman/sane-scan-pdf.git --depth 1 -COPY scanbd.conf /etc/scanbd/scanbd.conf -COPY scan.sh /etc/scanbd/scripts/scan.sh -COPY discover.sh /app/discover.sh +COPY conf/scanbd.conf /etc/scanbd/scanbd.conf +COPY scripts/discover.sh /app/discover.sh +COPY --from=build /build/dist/scan /etc/scanbd/scripts/scan RUN chmod +x /etc/scanbd/scripts/scan.sh && \ chmod +x /app/discover.sh diff --git a/scan.sh b/scan.sh deleted file mode 100644 index bb4ab20..0000000 --- a/scan.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -date_format="${date_format:-"%Y-%m-%d-%H%M%S-%3N-%Z"}" -now=`date +"$date_format"` -file_prefix="${file_prefix:-"scan"}" -vendor=${vendor:-"fujitsu"} -dpi=${dpi:-300} -mode=${mode:-"Color"} -tmp_output_dir="/tmp/sane-scan-pdf-output" -file_name="${file_prefix}-${now}.pdf" - -# Ensure the output directory exists -mkdir -p "$tmp_output_dir" -tmp_file="$tmp_output_dir/$file_name" -output_file="/scans/$file_name" - -# Run the scanning command and save the output to a temporary file -/app/sane-scan-pdf/scan -v -d -x $vendor -r $dpi --mode $mode --skip-empty-pages --crop -o "$tmp_file" - -if [ $? -ne 0 ]; then - echo "Error: Scan command failed. Exiting." - exit 1 -fi - -# Once the scan is complete, move the file to the output directory -echo "Scan completed successfully to $tmp_file. Moving file to $output_file" -mv "$tmp_file" "$output_file" - -if [ $? -ne 0 ]; then - echo "Error: Failed to move the file to $output_file. Please check permissions or directory existence." >&2 - exit 1 -fi - -# This is needed to ensure the file is ready to be imported by the next step. diff --git a/src/scan.ts b/src/scan.ts new file mode 100644 index 0000000..2cce518 --- /dev/null +++ b/src/scan.ts @@ -0,0 +1,39 @@ +import { Spinner } from '@topcli/spinner'; +import { $ } from 'bun'; +import uploader from './uploader/uploader'; + +const dateFormat = process.env.date_format ?? '%Y-%m-%d-%H%M%S-%3N-%Z'; +const filePrefix = process.env.file_prefix ?? 'scan'; +const vendor = process.env.vendor ?? 'fujitsu'; +const dpi = process.env.dpi ?? '300'; +const mode = process.env.mode ?? 'Color'; +const outputDir = process.env.output_dir ?? './scans'; +const scanProgram = process.env.scan_program ?? '/app/sane-scan-pdf/scan'; +const spinner = new Spinner(); + +// Run the scan command +try { + const now = await $`date +${dateFormat}`.text(); + const fileOutputPath = `${outputDir}/${filePrefix}-${now}.pdf`; + const flags = { + '-v': '', + '-d': '', + '-x': vendor, + '-r': dpi, + '--mode': mode, + '--skip-empty-pages': '', + '--crop': '', + '-o': fileOutputPath, + }; + + spinner.start(`Scanning document to ${fileOutputPath}`); + await $`${scanProgram} ${Object.entries(flags).flat().filter(Boolean).join(' ')}`; + spinner.succeed(`Scan completed: ${fileOutputPath}`); + + // upload the scanned document + await uploader(fileOutputPath); + process.exit(0); +} catch (error) { + spinner.failed(`Scan failed: ${JSON.stringify(error)}`); + process.exit(1); +}