diff --git a/app/(app)/export/paperless/actions.ts b/app/(app)/export/paperless/actions.ts new file mode 100644 index 00000000..bd0b5762 --- /dev/null +++ b/app/(app)/export/paperless/actions.ts @@ -0,0 +1,140 @@ +"use server" + +import { ActionState } from "@/lib/actions" +import { getCurrentUser } from "@/lib/auth" +import { fullPathForFile } from "@/lib/files" +import { getPaperlessClientForUser } from "@/lib/paperless/settings" +import { PaperlessCorrespondent, PaperlessTag } from "@/lib/paperless/types" +import { getFilesByTransactionId, updateFile } from "@/models/files" +import { getTransactionById } from "@/models/transactions" +import { readFile } from "fs/promises" + +interface PaperlessMetadata { + tags: PaperlessTag[] + correspondents: PaperlessCorrespondent[] +} + +export async function fetchPaperlessMetadataAction( + _prevState: ActionState | null, + _formData: FormData +): Promise> { + const user = await getCurrentUser() + const paperless = await getPaperlessClientForUser(user.id) + + if (!paperless) { + return { success: false, error: "Paperless-ngx is not configured" } + } + + try { + const [tags, correspondents] = await Promise.all([ + paperless.client.listTags(), + paperless.client.listCorrespondents(), + ]) + return { success: true, data: { tags, correspondents } } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : "Failed to fetch Paperless metadata" } + } +} + +interface ExportResult { + uploaded: number + skipped: number + failed: number + errors: string[] +} + +const POLL_INTERVAL_MS = 2000 +const MAX_POLL_ATTEMPTS = 10 + +export async function exportToPaperlessAction( + _prevState: ActionState | null, + formData: FormData +): Promise> { + const user = await getCurrentUser() + const paperless = await getPaperlessClientForUser(user.id) + + if (!paperless) { + return { success: false, error: "Paperless-ngx is not configured" } + } + + const transactionIds: string[] = JSON.parse(formData.get("transactionIds") as string) + const tagIds: number[] = JSON.parse(formData.get("tagIds") as string || "[]") + const correspondentId = formData.get("correspondentId") ? Number(formData.get("correspondentId")) : undefined + + if (!transactionIds || transactionIds.length === 0) { + return { success: false, error: "No transactions selected" } + } + + const result: ExportResult = { uploaded: 0, skipped: 0, failed: 0, errors: [] } + + for (const txnId of transactionIds) { + try { + const transaction = await getTransactionById(txnId, user.id) + if (!transaction) { + result.failed++ + result.errors.push(`Transaction ${txnId}: not found`) + continue + } + + const files = await getFilesByTransactionId(txnId, user.id) + if (files.length === 0) { + result.skipped++ + continue + } + + for (const file of files) { + if (file.paperlessDocumentId) { + result.skipped++ + continue + } + + try { + const filePath = fullPathForFile(user, file) + const buffer = await readFile(filePath) + + const taskUuid = await paperless.client.uploadDocument(buffer, file.filename, { + title: transaction.name || file.filename, + created: transaction.issuedAt ? transaction.issuedAt.toISOString() : undefined, + tags: tagIds.length > 0 ? tagIds : undefined, + correspondent: correspondentId, + }) + + let paperlessDocId: number | null = null + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + try { + const status = await paperless.client.getTaskStatus(taskUuid) + if (status.status === "SUCCESS") { + if (status.related_document) { + paperlessDocId = parseInt(status.related_document, 10) + } + break + } + if (status.status === "FAILURE") { + throw new Error(`Upload failed: ${status.result || "unknown error"}`) + } + } catch (e) { + if (attempt === MAX_POLL_ATTEMPTS - 1) throw e + } + } + + if (paperlessDocId) { + await updateFile(file.id, user.id, { paperlessDocumentId: paperlessDocId }) + } + + result.uploaded++ + } catch (fileError) { + result.failed++ + result.errors.push( + `File ${file.filename}: ${fileError instanceof Error ? fileError.message : "Unknown error"}` + ) + } + } + } catch (error) { + result.failed++ + result.errors.push(`Transaction ${txnId}: ${error instanceof Error ? error.message : "Unknown error"}`) + } + } + + return { success: true, data: result } +} diff --git a/app/(app)/import/paperless/actions.ts b/app/(app)/import/paperless/actions.ts new file mode 100644 index 00000000..7c74d53e --- /dev/null +++ b/app/(app)/import/paperless/actions.ts @@ -0,0 +1,146 @@ +"use server" + +import { ActionState } from "@/lib/actions" +import { getCurrentUser } from "@/lib/auth" +import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files" +import { getPaperlessClientForUser } from "@/lib/paperless/settings" +import { PaperlessCorrespondent, PaperlessDocument, PaperlessPaginatedResponse, PaperlessTag } from "@/lib/paperless/types" +import { createFile, getFileByPaperlessDocumentId } from "@/models/files" +import { randomUUID } from "crypto" +import { mkdir, writeFile } from "fs/promises" +import { revalidatePath } from "next/cache" +import path from "path" + +interface FetchResult { + documents: PaperlessPaginatedResponse + tags: PaperlessTag[] + correspondents: PaperlessCorrespondent[] +} + +export async function fetchPaperlessDocumentsAction( + _prevState: ActionState | null, + formData: FormData +): Promise> { + const user = await getCurrentUser() + const paperless = await getPaperlessClientForUser(user.id) + + if (!paperless) { + return { success: false, error: "Paperless-ngx is not configured" } + } + + try { + const page = Number(formData.get("page")) || 1 + const query = (formData.get("query") as string) || undefined + const tagFilter = formData.get("tag") ? Number(formData.get("tag")) : undefined + + const [documents, tags, correspondents] = await Promise.all([ + paperless.client.listDocuments({ + page, + page_size: 25, + query, + tags__id__in: tagFilter ? [tagFilter] : undefined, + ordering: "-created", + }), + paperless.client.listTags(), + paperless.client.listCorrespondents(), + ]) + + return { success: true, data: { documents, tags, correspondents } } + } catch (error) { + if (error instanceof Error) { + return { success: false, error: error.message } + } + return { success: false, error: "Failed to fetch documents from Paperless-ngx" } + } +} + +interface ImportResult { + imported: number + skipped: number + failed: number + errors: string[] +} + +export async function importPaperlessDocumentsAction( + _prevState: ActionState | null, + formData: FormData +): Promise> { + const user = await getCurrentUser() + const paperless = await getPaperlessClientForUser(user.id) + + if (!paperless) { + return { success: false, error: "Paperless-ngx is not configured" } + } + + const documentIds: number[] = JSON.parse(formData.get("documentIds") as string) + if (!documentIds || documentIds.length === 0) { + return { success: false, error: "No documents selected" } + } + + const result: ImportResult = { imported: 0, skipped: 0, failed: 0, errors: [] } + const userUploadsDir = getUserUploadsDirectory(user) + + for (const docId of documentIds) { + try { + const existing = await getFileByPaperlessDocumentId(user.id, docId) + if (existing) { + result.skipped++ + continue + } + + const docMeta = await paperless.client.getDocument(docId) + const { buffer, contentType, filename } = await paperless.client.downloadDocument(docId) + + const fileUuid = randomUUID() + const originalFilename = docMeta.original_file_name || filename || `paperless-${docId}.pdf` + const relPath = unsortedFilePath(fileUuid, originalFilename) + const fullPath = path.join(userUploadsDir, relPath) + + await mkdir(path.dirname(fullPath), { recursive: true }) + await writeFile(fullPath, buffer) + + const mimeType = contentType || guessMimeType(originalFilename) + + await createFile(user.id, { + id: fileUuid, + filename: originalFilename, + path: relPath, + mimetype: mimeType, + metadata: { + paperless: { + documentId: docId, + title: docMeta.title, + correspondent: docMeta.correspondent, + tags: docMeta.tags, + createdDate: docMeta.created_date, + content: docMeta.content?.substring(0, 2000), + }, + }, + paperlessDocumentId: docId, + }) + + result.imported++ + } catch (error) { + result.failed++ + result.errors.push(`Document ${docId}: ${error instanceof Error ? error.message : "Unknown error"}`) + } + } + + revalidatePath("/unsorted") + return { success: true, data: result } +} + +function guessMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase() + const mimeTypes: Record = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".gif": "image/gif", + } + return mimeTypes[ext] || "application/octet-stream" +} diff --git a/app/(app)/import/paperless/page.tsx b/app/(app)/import/paperless/page.tsx new file mode 100644 index 00000000..44df4053 --- /dev/null +++ b/app/(app)/import/paperless/page.tsx @@ -0,0 +1,17 @@ +import { PaperlessImport } from "@/components/import/paperless" +import { getCurrentUser } from "@/lib/auth" +import { getSettings } from "@/models/settings" + +export default async function PaperlessImportPage() { + const user = await getCurrentUser() + const settings = await getSettings(user.id) + + const isPaperlessConfigured = + settings.paperless_enabled === "true" && !!settings.paperless_url && !!settings.paperless_api_token + + return ( +
+ +
+ ) +} diff --git a/app/(app)/settings/layout.tsx b/app/(app)/settings/layout.tsx index fa145f94..bd7c0e17 100644 --- a/app/(app)/settings/layout.tsx +++ b/app/(app)/settings/layout.tsx @@ -24,6 +24,10 @@ const settingsCategories = [ title: "LLM settings", href: "/settings/llm", }, + { + title: "Paperless-ngx", + href: "/settings/paperless", + }, { title: "Fields", href: "/settings/fields", diff --git a/app/(app)/settings/paperless/actions.ts b/app/(app)/settings/paperless/actions.ts new file mode 100644 index 00000000..a6ce9196 --- /dev/null +++ b/app/(app)/settings/paperless/actions.ts @@ -0,0 +1,30 @@ +"use server" + +import { ActionState } from "@/lib/actions" +import { getCurrentUser } from "@/lib/auth" +import { createPaperlessClient } from "@/lib/paperless/client" + +export async function testPaperlessConnectionAction( + _prevState: ActionState<{ documentCount: number }> | null, + formData: FormData +): Promise> { + await getCurrentUser() + + const url = formData.get("paperless_url") as string + const token = formData.get("paperless_api_token") as string + + if (!url || !token) { + return { success: false, error: "URL and API token are required" } + } + + try { + const client = createPaperlessClient(url, token) + const result = await client.listDocuments({ page: 1, page_size: 1 }) + return { success: true, data: { documentCount: result.count } } + } catch (error) { + if (error instanceof Error) { + return { success: false, error: error.message } + } + return { success: false, error: "Connection failed" } + } +} diff --git a/app/(app)/settings/paperless/page.tsx b/app/(app)/settings/paperless/page.tsx new file mode 100644 index 00000000..e9ca0bb5 --- /dev/null +++ b/app/(app)/settings/paperless/page.tsx @@ -0,0 +1,14 @@ +import PaperlessSettingsForm from "@/components/settings/paperless-settings-form" +import { getCurrentUser } from "@/lib/auth" +import { getSettings } from "@/models/settings" + +export default async function PaperlessSettingsPage() { + const user = await getCurrentUser() + const settings = await getSettings(user.id) + + return ( +
+ +
+ ) +} diff --git a/app/(app)/transactions/page.tsx b/app/(app)/transactions/page.tsx index e46f5624..692ad547 100644 --- a/app/(app)/transactions/page.tsx +++ b/app/(app)/transactions/page.tsx @@ -9,6 +9,7 @@ import { getCurrentUser } from "@/lib/auth" import { getCategories } from "@/models/categories" import { getFields } from "@/models/fields" import { getProjects } from "@/models/projects" +import { getSettings } from "@/models/settings" import { getTransactions, TransactionFilters } from "@/models/transactions" import { Download, Plus, Upload } from "lucide-react" import { Metadata } from "next" @@ -31,6 +32,9 @@ export default async function TransactionsPage({ searchParams }: { searchParams: const categories = await getCategories(user.id) const projects = await getProjects(user.id) const fields = await getFields(user.id) + const settings = await getSettings(user.id) + const isPaperlessEnabled = + settings.paperless_enabled === "true" && !!settings.paperless_url && !!settings.paperless_api_token // Reset page if user clicks a filter and no transactions are found if (page && page > 1 && transactions.length === 0) { @@ -58,7 +62,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
- + {total > TRANSACTIONS_PER_PAGE && } diff --git a/components/export/paperless-export-dialog.tsx b/components/export/paperless-export-dialog.tsx new file mode 100644 index 00000000..eab1a058 --- /dev/null +++ b/components/export/paperless-export-dialog.tsx @@ -0,0 +1,161 @@ +"use client" + +import { + exportToPaperlessAction, + fetchPaperlessMetadataAction, +} from "@/app/(app)/export/paperless/actions" +import { FormError } from "@/components/forms/error" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { PaperlessCorrespondent, PaperlessTag } from "@/lib/paperless/types" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { CircleCheckBig, Loader2, Upload } from "lucide-react" +import { startTransition, useActionState, useEffect, useState } from "react" + +export function PaperlessExportDialog({ + selectedTransactionIds, + children, +}: { + selectedTransactionIds: string[] + children: React.ReactNode +}) { + const [open, setOpen] = useState(false) + const [metadataState, fetchMetadata] = useActionState(fetchPaperlessMetadataAction, null) + const [exportState, exportAction, isExporting] = useActionState(exportToPaperlessAction, null) + + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [correspondentId, setCorrespondentId] = useState("") + + const tags = metadataState?.data?.tags || [] + const correspondents = metadataState?.data?.correspondents || [] + + useEffect(() => { + if (open && !metadataState) { + startTransition(() => fetchMetadata(new FormData())) + } + }, [open]) + + function toggleTag(tagId: number) { + setSelectedTagIds((prev) => { + const next = new Set(prev) + if (next.has(tagId)) next.delete(tagId) + else next.add(tagId) + return next + }) + } + + function handleExport() { + const formData = new FormData() + formData.set("transactionIds", JSON.stringify(selectedTransactionIds)) + formData.set("tagIds", JSON.stringify(Array.from(selectedTagIds))) + if (correspondentId && correspondentId !== "-") { + formData.set("correspondentId", correspondentId) + } + startTransition(() => exportAction(formData)) + } + + return ( + + {children} + + + Export to Paperless-ngx + + Upload files from {selectedTransactionIds.length} transaction + {selectedTransactionIds.length !== 1 ? "s" : ""} to Paperless-ngx. + + + + {metadataState?.error && {metadataState.error}} + + {!metadataState?.data && !metadataState?.error && ( +
+ +
+ )} + + {metadataState?.data && ( +
+
+ + +
+ +
+ +
+ {tags.length === 0 && ( +

No tags found in Paperless-ngx

+ )} + {tags.map((tag) => ( + + ))} +
+
+
+ )} + + {exportState?.success && exportState.data && ( +
+ + Uploaded {exportState.data.uploaded} file{exportState.data.uploaded !== 1 ? "s" : ""}. + {exportState.data.skipped > 0 && ` Skipped ${exportState.data.skipped}.`} + {exportState.data.failed > 0 && ` Failed: ${exportState.data.failed}.`} +
+ )} + + {exportState?.error && {exportState.error}} + + + + +
+
+ ) +} diff --git a/components/import/paperless.tsx b/components/import/paperless.tsx new file mode 100644 index 00000000..d14cb977 --- /dev/null +++ b/components/import/paperless.tsx @@ -0,0 +1,277 @@ +"use client" + +import { + fetchPaperlessDocumentsAction, + importPaperlessDocumentsAction, +} from "@/app/(app)/import/paperless/actions" +import { FormError } from "@/components/forms/error" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { PaperlessCorrespondent, PaperlessDocument, PaperlessTag } from "@/lib/paperless/types" +import { ChevronLeft, ChevronRight, Download, Loader2, Search, Settings } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { startTransition, useActionState, useEffect, useState } from "react" + +export function PaperlessImport({ isPaperlessConfigured }: { isPaperlessConfigured: boolean }) { + const router = useRouter() + const [fetchState, fetchAction, isFetching] = useActionState(fetchPaperlessDocumentsAction, null) + const [importState, importAction, isImporting] = useActionState(importPaperlessDocumentsAction, null) + + const [page, setPage] = useState(1) + const [query, setQuery] = useState("") + const [selectedIds, setSelectedIds] = useState>(new Set()) + + const documents = fetchState?.data?.documents + const tags = fetchState?.data?.tags || [] + const correspondents = fetchState?.data?.correspondents || [] + + const tagMap = new Map(tags.map((t) => [t.id, t])) + const correspondentMap = new Map(correspondents.map((c) => [c.id, c])) + + useEffect(() => { + if (isPaperlessConfigured) { + fetchDocuments(1) + } + }, [isPaperlessConfigured]) + + useEffect(() => { + if (importState?.success) { + router.push("/unsorted") + } + }, [importState, router]) + + function fetchDocuments(pageNum: number, searchQuery?: string) { + const formData = new FormData() + formData.set("page", String(pageNum)) + if (searchQuery || query) formData.set("query", searchQuery ?? query) + startTransition(() => fetchAction(formData)) + setPage(pageNum) + } + + function handleSearch(e: React.FormEvent) { + e.preventDefault() + setSelectedIds(new Set()) + fetchDocuments(1, query) + } + + function toggleSelection(id: number) { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function toggleAll() { + if (!documents?.results) return + const allIds = documents.results.map((d) => d.id) + const allSelected = allIds.every((id) => selectedIds.has(id)) + if (allSelected) { + setSelectedIds((prev) => { + const next = new Set(prev) + allIds.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelectedIds((prev) => { + const next = new Set(prev) + allIds.forEach((id) => next.add(id)) + return next + }) + } + } + + function handleImport() { + if (selectedIds.size === 0) return + const formData = new FormData() + formData.set("documentIds", JSON.stringify(Array.from(selectedIds))) + startTransition(() => importAction(formData)) + } + + if (!isPaperlessConfigured) { + return ( +
+ +

Paperless-ngx Not Configured

+

+ To import documents from Paperless-ngx, configure your connection settings first. +

+ + + +
+ ) + } + + return ( +
+
+

Import from Paperless-ngx

+ {selectedIds.size > 0 && ( + + )} +
+ + {importState?.success && importState.data && ( +
+ Imported {importState.data.imported} document{importState.data.imported !== 1 ? "s" : ""}. + {importState.data.skipped > 0 && ` Skipped ${importState.data.skipped} (already imported).`} + {importState.data.failed > 0 && ` Failed: ${importState.data.failed}.`} +
+ )} + + {importState?.error && {importState.error}} + +
+ setQuery(e.target.value)} + className="max-w-sm" + /> + +
+ + {fetchState?.error && {fetchState.error}} + + {isFetching && !documents && ( +
+ +
+ )} + + {documents && ( + <> +
+ {documents.count} document{documents.count !== 1 ? "s" : ""} found +
+ +
+ + + + + + + + + + + + {documents.results.map((doc) => ( + toggleSelection(doc.id)} + correspondentMap={correspondentMap} + tagMap={tagMap} + /> + ))} + {documents.results.length === 0 && ( + + + + )} + +
+ 0 && documents.results.every((d) => selectedIds.has(d.id)) + } + onCheckedChange={toggleAll} + /> + TitleCorrespondentTagsDate
+ No documents found +
+
+ +
+ + Page {page} + +
+ + )} +
+ ) +} + +function DocumentRow({ + doc, + selected, + onToggle, + correspondentMap, + tagMap, +}: { + doc: PaperlessDocument + selected: boolean + onToggle: () => void + correspondentMap: Map + tagMap: Map +}) { + const correspondent = doc.correspondent ? correspondentMap.get(doc.correspondent) : null + + return ( + + e.stopPropagation()}> + + + +
{doc.title}
+
{doc.original_file_name}
+ + + {correspondent?.name || "—"} + + +
+ {doc.tags.slice(0, 3).map((tagId) => { + const tag = tagMap.get(tagId) + return tag ? ( + + {tag.name} + + ) : null + })} + {doc.tags.length > 3 && ( + + +{doc.tags.length - 3} + + )} +
+ + {doc.created_date} + + ) +} diff --git a/components/settings/paperless-settings-form.tsx b/components/settings/paperless-settings-form.tsx new file mode 100644 index 00000000..7ea9e1ac --- /dev/null +++ b/components/settings/paperless-settings-form.tsx @@ -0,0 +1,129 @@ +"use client" + +import { saveSettingsAction } from "@/app/(app)/settings/actions" +import { testPaperlessConnectionAction } from "@/app/(app)/settings/paperless/actions" +import { FormError } from "@/components/forms/error" +import { FormInput } from "@/components/forms/simple" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { CircleCheckBig, Loader2, Plug } from "lucide-react" +import Link from "next/link" +import { useActionState, useState } from "react" + +export default function PaperlessSettingsForm({ settings }: { settings: Record }) { + const [saveState, saveAction, isSaving] = useActionState(saveSettingsAction, null) + const [testState, testAction, isTesting] = useActionState(testPaperlessConnectionAction, null) + const [enabled, setEnabled] = useState(settings.paperless_enabled === "true") + + return ( +
+
+

Paperless-ngx Integration

+

+ Connect to your{" "} + + Paperless-ngx + {" "} + instance to import and export documents. +

+
+ +
+
+ setEnabled(checked === true)} + /> + + +
+ + + + + + + Generate a token from your Paperless-ngx instance under My Profile → Auth Tokens. + + + + + + Comma-separated tag names to apply when exporting documents to Paperless-ngx. + + +
+ + {saveState?.success && ( +

+ + Saved! +

+ )} +
+ + {saveState?.error && {saveState.error}} + + +
+
+ + + + {testState?.success && ( +

+ + Connected! Found {testState.data?.documentCount} documents. +

+ )} + {testState?.error && ( +

{testState.error}

+ )} +
+

+ Save your settings first, then test the connection. +

+
+ + {enabled && settings.paperless_url && settings.paperless_api_token && ( +
+

Quick Links

+
+ + Import from Paperless-ngx + +
+
+ )} +
+ ) +} diff --git a/components/sidebar/sidebar.tsx b/components/sidebar/sidebar.tsx index 398aefc2..8df87192 100644 --- a/components/sidebar/sidebar.tsx +++ b/components/sidebar/sidebar.tsx @@ -139,6 +139,14 @@ export function AppSidebar({ + + + + + Import from Paperless + + + {isSelfHosted && ( diff --git a/components/transactions/bulk-actions.tsx b/components/transactions/bulk-actions.tsx index 34ccc5de..084569da 100644 --- a/components/transactions/bulk-actions.tsx +++ b/components/transactions/bulk-actions.tsx @@ -1,16 +1,18 @@ "use client" import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions" +import { PaperlessExportDialog } from "@/components/export/paperless-export-dialog" import { Button } from "@/components/ui/button" -import { Trash2 } from "lucide-react" +import { FileUp, Trash2 } from "lucide-react" import { useState } from "react" interface BulkActionsMenuProps { selectedIds: string[] onActionComplete?: () => void + isPaperlessEnabled?: boolean } -export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) { +export function BulkActionsMenu({ selectedIds, onActionComplete, isPaperlessEnabled }: BulkActionsMenuProps) { const [isLoading, setIsLoading] = useState(false) const handleDelete = async () => { @@ -34,7 +36,15 @@ export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMe } return ( -
+
+ {isPaperlessEnabled && ( + + + + )}
) diff --git a/docs/paperless-ngx.md b/docs/paperless-ngx.md new file mode 100644 index 00000000..fcbb134f --- /dev/null +++ b/docs/paperless-ngx.md @@ -0,0 +1,173 @@ +# Paperless-ngx Integration + +TaxHacker supports bidirectional integration with [Paperless-ngx](https://docs.paperless-ngx.com/), a popular self-hosted document management system. Import documents from Paperless-ngx into TaxHacker for AI-powered financial data extraction, and export processed transactions back to Paperless-ngx for long-term archival. + +## Setup + +### 1. Get your Paperless-ngx API token + +In your Paperless-ngx instance, navigate to **My Profile** (click your username in the top-right) and scroll to **Auth Tokens**. Click **+ Add** to generate a new token. Copy it. + +### 2. Configure TaxHacker + +Navigate to **Settings > Paperless-ngx** in TaxHacker and fill in: + +| Field | Description | Example | +|-------|-------------|---------| +| **Enable** | Toggle the integration on/off | Checked | +| **Paperless-ngx URL** | Full URL to your Paperless-ngx instance | `https://paperless.example.com` | +| **API Token** | The token you generated above | `abc123...` | +| **Default Tags** | (Optional) Comma-separated tag names to apply when exporting | `taxhacker, invoices` | + +Click **Save Settings**, then **Test Connection** to verify everything works. A successful test will show the number of documents in your Paperless-ngx instance. + +## Importing Documents + +### From the sidebar + +Click **Import from Paperless** in the sidebar to open the import page. + +### How it works + +1. TaxHacker fetches your document list from Paperless-ngx (server-side -- your API token never leaves the server) +2. Browse, search, and paginate through your Paperless-ngx documents +3. Select the documents you want to import using the checkboxes +4. Click **Import N documents** +5. Each selected document is downloaded and saved to TaxHacker's unsorted files +6. Navigate to **Unsorted** to analyze them with AI, just like any other uploaded document + +### Deduplication + +TaxHacker tracks the Paperless-ngx document ID for each imported file. If you try to import a document that's already been imported, it will be automatically skipped. The import summary shows how many documents were imported vs. skipped. + +### What gets imported + +| Paperless-ngx | TaxHacker | +|---------------|-----------| +| Original document file (PDF, image) | Saved as unsorted file | +| Title, tags, correspondent, date | Stored in file metadata for reference | +| OCR content (first 2000 chars) | Stored in file metadata | +| Document ID | Stored on file record for deduplication | + +After import, the documents enter TaxHacker's normal processing pipeline -- you can run AI analysis, edit extracted data, and save as transactions. + +## Exporting Transactions + +### From the transactions page + +1. Go to **Transactions** +2. Select one or more transactions using the checkboxes +3. Click **Export to Paperless** in the bottom action bar (only visible when Paperless-ngx is configured) +4. Choose a **correspondent** and **tags** to apply in Paperless-ngx +5. Click **Export to Paperless-ngx** + +### How it works + +1. For each selected transaction, TaxHacker uploads all associated files to Paperless-ngx +2. Files that were previously exported (already have a Paperless document ID) are skipped +3. TaxHacker polls the Paperless-ngx task queue until each upload is consumed +4. On success, the Paperless-ngx document ID is saved back to the TaxHacker file record + +### What gets exported + +| TaxHacker | Paperless-ngx | +|-----------|---------------| +| Document file (PDF, image) | Uploaded as new document | +| Transaction name | Document title | +| Transaction date | Document created date | +| Selected tags | Applied as tags | +| Selected correspondent | Applied as correspondent | + +## Architecture + +All Paperless-ngx API calls are made **server-side** via Next.js server actions. The API token is stored in the database (Setting model) and is never sent to the browser. + +### Files + +``` +lib/paperless/ + types.ts # TypeScript interfaces for Paperless-ngx API + client.ts # HTTP client (native fetch, auth, pagination, error handling) + settings.ts # Helper to create client from user settings + index.ts # Re-exports + +app/(app)/settings/paperless/ + page.tsx # Settings page + actions.ts # Test connection server action + +app/(app)/import/paperless/ + page.tsx # Import page + actions.ts # Fetch + import server actions + +app/(app)/export/paperless/ + actions.ts # Fetch metadata + export server actions + +components/settings/paperless-settings-form.tsx # Settings form +components/import/paperless.tsx # Import UI +components/export/paperless-export-dialog.tsx # Export dialog +``` + +### Database + +The `File` model has a `paperlessDocumentId` (nullable integer) field used for deduplication: + +```sql +ALTER TABLE "files" ADD COLUMN "paperless_document_id" INTEGER; +CREATE INDEX "files_user_id_paperless_document_id_idx" + ON "files"("user_id", "paperless_document_id"); +``` + +Settings are stored in the `Setting` model (key-value pairs per user): +- `paperless_enabled` -- `"true"` or `"false"` +- `paperless_url` -- Base URL of the Paperless-ngx instance +- `paperless_api_token` -- API token +- `paperless_default_tags` -- Comma-separated default tag names + +## Security + +- **SSRF prevention**: The Paperless URL is validated to only allow `http:` and `https:` schemes +- **Server-side only**: All API calls happen in server actions, never from the browser +- **Token storage**: The API token is stored in the database and transmitted via hidden form fields only within server actions +- **Request timeout**: All Paperless API requests have a 30-second timeout + +## Troubleshooting + +### "Connection failed" on test + +- Verify the URL includes the protocol (e.g., `https://paperless.example.com`, not just `paperless.example.com`) +- Verify the URL does not include a trailing `/api/` -- TaxHacker adds this automatically +- Check that TaxHacker can reach the Paperless-ngx instance on the network (especially if both are in Docker -- they may need to share a network) +- Verify the API token is correct and has not expired + +### "Invalid API token or insufficient permissions" + +- Regenerate the API token in Paperless-ngx and update it in TaxHacker settings +- Ensure the Paperless-ngx user has sufficient permissions to list/read/create documents + +### Import shows 0 documents + +- Verify Paperless-ngx has documents. The test connection button shows the total document count. +- Check the Paperless-ngx logs for API errors + +### Export fails with timeout + +- Large files may take longer to upload. The upload timeout is 60 seconds, and task polling waits up to 20 seconds for consumption. +- Check Paperless-ngx consumer logs for processing errors +- Ensure Paperless-ngx has sufficient disk space + +## Paperless-ngx API Reference + +This integration uses the following Paperless-ngx API endpoints: + +| Endpoint | Method | Usage | +|----------|--------|-------| +| `/api/` | GET | Connection test | +| `/api/documents/` | GET | List and search documents | +| `/api/documents//` | GET | Get document metadata | +| `/api/documents//download/` | GET | Download document file | +| `/api/documents/post_document/` | POST | Upload new document | +| `/api/tasks/` | GET | Poll upload task status | +| `/api/tags/` | GET | List tags | +| `/api/correspondents/` | GET | List correspondents | + +Full API documentation: [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) diff --git a/forms/settings.ts b/forms/settings.ts index 08ab5997..ba323b9a 100644 --- a/forms/settings.ts +++ b/forms/settings.ts @@ -15,6 +15,10 @@ export const settingsFormSchema = z.object({ llm_providers: z.string().default('openai,google,mistral'), prompt_analyse_new_file: z.string().optional(), is_welcome_message_hidden: z.string().optional(), + paperless_url: z.string().url().optional().or(z.literal("")), + paperless_api_token: z.string().optional(), + paperless_enabled: z.string().optional(), + paperless_default_tags: z.string().optional(), }) export const currencyFormSchema = z.object({ diff --git a/lib/paperless/client.ts b/lib/paperless/client.ts new file mode 100644 index 00000000..a74d1175 --- /dev/null +++ b/lib/paperless/client.ts @@ -0,0 +1,222 @@ +import { + PaperlessApiError, + PaperlessCorrespondent, + PaperlessDocument, + PaperlessDocumentListParams, + PaperlessDocumentType, + PaperlessPaginatedResponse, + PaperlessTag, + PaperlessTaskStatus, + PaperlessUploadMetadata, +} from "./types" + +const REQUEST_TIMEOUT_MS = 30000 + +function validatePaperlessUrl(raw: string): string { + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error("Invalid URL format") + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("Only HTTP and HTTPS protocols are allowed") + } + return url.origin + url.pathname.replace(/\/+$/, "") +} + +export interface PaperlessClient { + testConnection(): Promise + listDocuments(params?: PaperlessDocumentListParams): Promise> + getDocument(id: number): Promise + downloadDocument(id: number, original?: boolean): Promise<{ buffer: Buffer; contentType: string; filename: string }> + uploadDocument( + file: Buffer, + filename: string, + metadata?: PaperlessUploadMetadata + ): Promise + getTaskStatus(taskId: string): Promise + listTags(): Promise + createTag(name: string): Promise + listCorrespondents(): Promise + createCorrespondent(name: string): Promise + listDocumentTypes(): Promise +} + +export function createPaperlessClient(baseUrl: string, apiToken: string): PaperlessClient { + const validatedBaseUrl = validatePaperlessUrl(baseUrl) + + const headers: Record = { + Authorization: `Token ${apiToken}`, + Accept: "application/json; version=5", + } + + async function request(path: string, init?: RequestInit): Promise { + const url = `${validatedBaseUrl}/api${path}` + const response = await fetch(url, { + ...init, + headers: { + ...headers, + ...init?.headers, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + if (response.status === 401 || response.status === 403) { + throw new PaperlessApiError(response.status, response.statusText, "Invalid API token or insufficient permissions") + } + throw new PaperlessApiError(response.status, response.statusText, body) + } + + return response.json() as Promise + } + + async function requestRaw(path: string): Promise { + const url = `${validatedBaseUrl}/api${path}` + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new PaperlessApiError(response.status, response.statusText, body) + } + + return response + } + + async function fetchAllPages(path: string): Promise { + const results: T[] = [] + let page = 1 + while (true) { + const separator = path.includes("?") ? "&" : "?" + const response = await request>(`${path}${separator}page=${page}&page_size=100`) + results.push(...response.results) + if (!response.next) break + page++ + } + return results + } + + return { + async testConnection(): Promise { + await request>("/") + return true + }, + + async listDocuments( + params: PaperlessDocumentListParams = {} + ): Promise> { + const searchParams = new URLSearchParams() + if (params.page) searchParams.set("page", String(params.page)) + if (params.page_size) searchParams.set("page_size", String(params.page_size)) + if (params.query) searchParams.set("query", params.query) + if (params.correspondent__id) searchParams.set("correspondent__id", String(params.correspondent__id)) + if (params.tags__id__in?.length) searchParams.set("tags__id__in", params.tags__id__in.join(",")) + if (params.created__date__gt) searchParams.set("created__date__gt", params.created__date__gt) + if (params.created__date__lt) searchParams.set("created__date__lt", params.created__date__lt) + if (params.ordering) searchParams.set("ordering", params.ordering) + + const query = searchParams.toString() + return request>(`/documents/${query ? `?${query}` : ""}`) + }, + + async getDocument(id: number): Promise { + return request(`/documents/${id}/`) + }, + + async downloadDocument( + id: number, + original = true + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + const response = await requestRaw(`/documents/${id}/download/${original ? "?original=true" : ""}`) + const contentType = response.headers.get("content-type") || "application/octet-stream" + + let filename = `document-${id}` + const disposition = response.headers.get("content-disposition") + if (disposition) { + const match = disposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)"?/i) + if (match) filename = decodeURIComponent(match[1]) + } + + const arrayBuffer = await response.arrayBuffer() + return { buffer: Buffer.from(arrayBuffer), contentType, filename } + }, + + async uploadDocument(file: Buffer, filename: string, metadata?: PaperlessUploadMetadata): Promise { + const formData = new FormData() + formData.append("document", new Blob([file]), filename) + + if (metadata?.title) formData.append("title", metadata.title) + if (metadata?.created) formData.append("created", metadata.created) + if (metadata?.correspondent) formData.append("correspondent", String(metadata.correspondent)) + if (metadata?.document_type) formData.append("document_type", String(metadata.document_type)) + if (metadata?.archive_serial_number) { + formData.append("archive_serial_number", String(metadata.archive_serial_number)) + } + if (metadata?.tags) { + for (const tagId of metadata.tags) { + formData.append("tags", String(tagId)) + } + } + + const url = `${validatedBaseUrl}/api/documents/post_document/` + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Token ${apiToken}`, + Accept: "application/json; version=5", + }, + body: formData, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS * 2), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new PaperlessApiError(response.status, response.statusText, body) + } + + const taskId = await response.text() + return taskId.replace(/"/g, "").trim() + }, + + async getTaskStatus(taskId: string): Promise { + const response = await request(`/tasks/?task_id=${taskId}`) + if (!Array.isArray(response) || response.length === 0) { + throw new Error(`Task ${taskId} not found`) + } + return response[0] + }, + + async listTags(): Promise { + return fetchAllPages("/tags/") + }, + + async createTag(name: string): Promise { + return request("/tags/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }) + }, + + async listCorrespondents(): Promise { + return fetchAllPages("/correspondents/") + }, + + async createCorrespondent(name: string): Promise { + return request("/correspondents/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }) + }, + + async listDocumentTypes(): Promise { + return fetchAllPages("/document_types/") + }, + } +} diff --git a/lib/paperless/index.ts b/lib/paperless/index.ts new file mode 100644 index 00000000..86ec3e02 --- /dev/null +++ b/lib/paperless/index.ts @@ -0,0 +1,14 @@ +export { createPaperlessClient } from "./client" +export type { PaperlessClient } from "./client" +export { getPaperlessClientForUser } from "./settings" +export type { + PaperlessApiError, + PaperlessCorrespondent, + PaperlessDocument, + PaperlessDocumentListParams, + PaperlessDocumentType, + PaperlessPaginatedResponse, + PaperlessTag, + PaperlessTaskStatus, + PaperlessUploadMetadata, +} from "./types" diff --git a/lib/paperless/settings.ts b/lib/paperless/settings.ts new file mode 100644 index 00000000..39b76df8 --- /dev/null +++ b/lib/paperless/settings.ts @@ -0,0 +1,14 @@ +import { getSettings } from "@/models/settings" +import { createPaperlessClient, PaperlessClient } from "./client" + +export async function getPaperlessClientForUser( + userId: string +): Promise<{ client: PaperlessClient; settings: Record } | null> { + const settings = await getSettings(userId) + + if (settings.paperless_enabled !== "true") return null + if (!settings.paperless_url || !settings.paperless_api_token) return null + + const client = createPaperlessClient(settings.paperless_url, settings.paperless_api_token) + return { client, settings } +} diff --git a/lib/paperless/types.ts b/lib/paperless/types.ts new file mode 100644 index 00000000..506d87f1 --- /dev/null +++ b/lib/paperless/types.ts @@ -0,0 +1,90 @@ +export interface PaperlessDocument { + id: number + title: string + content: string + correspondent: number | null + document_type: number | null + storage_path: number | null + tags: number[] + created_date: string + modified: string + added: string + archive_serial_number: number | null + original_file_name: string +} + +export interface PaperlessPaginatedResponse { + count: number + next: string | null + previous: string | null + results: T[] +} + +export interface PaperlessTag { + id: number + name: string + slug: string + color: string + text_color: string + match: string + matching_algorithm: number +} + +export interface PaperlessCorrespondent { + id: number + name: string + slug: string + match: string + matching_algorithm: number +} + +export interface PaperlessDocumentType { + id: number + name: string + slug: string + match: string + matching_algorithm: number +} + +export interface PaperlessTaskStatus { + id: number + task_id: string + task_file_name: string + status: "PENDING" | "STARTED" | "SUCCESS" | "FAILURE" | "RETRY" + result: string | null + related_document: string | null +} + +export interface PaperlessDocumentListParams { + page?: number + page_size?: number + query?: string + correspondent__id?: number + tags__id__in?: number[] + created__date__gt?: string + created__date__lt?: string + ordering?: string +} + +export interface PaperlessUploadMetadata { + title?: string + created?: string + correspondent?: number + document_type?: number + tags?: number[] + archive_serial_number?: number +} + +export class PaperlessApiError extends Error { + statusCode: number + statusText: string + responseBody: string + + constructor(statusCode: number, statusText: string, responseBody: string) { + super(`Paperless API error ${statusCode}: ${statusText}`) + this.name = "PaperlessApiError" + this.statusCode = statusCode + this.statusText = statusText + this.responseBody = responseBody + } +} diff --git a/models/files.ts b/models/files.ts index 8d6dd446..92d4227c 100644 --- a/models/files.ts +++ b/models/files.ts @@ -51,6 +51,12 @@ export const getFilesByTransactionId = cache(async (id: string, userId: string) return [] }) +export const getFileByPaperlessDocumentId = cache(async (userId: string, paperlessDocumentId: number) => { + return await prisma.file.findFirst({ + where: { userId, paperlessDocumentId }, + }) +}) + export const createFile = async (userId: string, data: any) => { return await prisma.file.create({ data: { diff --git a/prisma/migrations/20250601000000_add_paperless_document_id_to_files/migration.sql b/prisma/migrations/20250601000000_add_paperless_document_id_to_files/migration.sql new file mode 100644 index 00000000..2af90549 --- /dev/null +++ b/prisma/migrations/20250601000000_add_paperless_document_id_to_files/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "paperless_document_id" INTEGER; + +-- CreateIndex +CREATE INDEX "files_user_id_paperless_document_id_idx" ON "files"("user_id", "paperless_document_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65aa0f5f..8dcdab95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,9 +161,11 @@ model File { metadata Json? isReviewed Boolean @default(false) @map("is_reviewed") isSplitted Boolean @default(false) @map("is_splitted") - cachedParseResult Json? @map("cached_parse_result") - createdAt DateTime @default(now()) @map("created_at") + cachedParseResult Json? @map("cached_parse_result") + paperlessDocumentId Int? @map("paperless_document_id") + createdAt DateTime @default(now()) @map("created_at") + @@index([userId, paperlessDocumentId]) @@map("files") }