Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions app/(app)/export/paperless/actions.ts
Original file line number Diff line number Diff line change
@@ -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<PaperlessMetadata> | null,
_formData: FormData
): Promise<ActionState<PaperlessMetadata>> {
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<ExportResult> | null,
formData: FormData
): Promise<ActionState<ExportResult>> {
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 }
}
146 changes: 146 additions & 0 deletions app/(app)/import/paperless/actions.ts
Original file line number Diff line number Diff line change
@@ -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<PaperlessDocument>
tags: PaperlessTag[]
correspondents: PaperlessCorrespondent[]
}

export async function fetchPaperlessDocumentsAction(
_prevState: ActionState<FetchResult> | null,
formData: FormData
): Promise<ActionState<FetchResult>> {
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<ImportResult> | null,
formData: FormData
): Promise<ActionState<ImportResult>> {
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<string, string> = {
".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"
}
17 changes: 17 additions & 0 deletions app/(app)/import/paperless/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-4 p-4">
<PaperlessImport isPaperlessConfigured={isPaperlessConfigured} />
</div>
)
}
4 changes: 4 additions & 0 deletions app/(app)/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const settingsCategories = [
title: "LLM settings",
href: "/settings/llm",
},
{
title: "Paperless-ngx",
href: "/settings/paperless",
},
{
title: "Fields",
href: "/settings/fields",
Expand Down
30 changes: 30 additions & 0 deletions app/(app)/settings/paperless/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ActionState<{ documentCount: number }>> {
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" }
}
}
14 changes: 14 additions & 0 deletions app/(app)/settings/paperless/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-full max-w-2xl">
<PaperlessSettingsForm settings={settings} />
</div>
)
}
6 changes: 5 additions & 1 deletion app/(app)/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -58,7 +62,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
<TransactionSearchAndFilters categories={categories} projects={projects} fields={fields} />

<main>
<TransactionList transactions={transactions} fields={fields} />
<TransactionList transactions={transactions} fields={fields} isPaperlessEnabled={isPaperlessEnabled} />

{total > TRANSACTIONS_PER_PAGE && <Pagination totalItems={total} itemsPerPage={TRANSACTIONS_PER_PAGE} />}

Expand Down
Loading