diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 94cb17c..a52d20a 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -80,6 +80,19 @@ export async function fetchPaperlessDocument( } } +export async function fetchPaperlessDocumentById(documentId: number): Promise { + try { + return await request(`/integrations/paperless/documents/${documentId}`, { + method: "GET" + }); + } catch (error) { + if ((error as { status?: number }).status === 404) { + return null; + } + throw error; + } +} + export async function searchPaperlessDocuments(query: string, page = 1): Promise { const params = new URLSearchParams({ q: query, page: String(page) }); return request(`/integrations/paperless/search?${params.toString()}`, { diff --git a/frontend/src/components/PaperlessSearchDialog.tsx b/frontend/src/components/PaperlessSearchDialog.tsx index a213a78..9ac86af 100644 --- a/frontend/src/components/PaperlessSearchDialog.tsx +++ b/frontend/src/components/PaperlessSearchDialog.tsx @@ -24,6 +24,7 @@ import { useTranslation } from "react-i18next"; import { searchPaperlessDocuments } from "../api/contracts"; import { useSnackbar } from "../hooks/useSnackbar"; import { PaperlessDocument, PaperlessSearchResponse } from "../types"; +import { extractPaperlessProvider, extractPaperlessTags, extractPaperlessTitle } from "../utils/paperless"; interface Props { open: boolean; @@ -101,27 +102,41 @@ export default function PaperlessSearchDialog({ open, onClose, onSelect }: Props )} {results && results.results.length > 0 && ( - {results.results.map((doc) => ( - - { - onSelect(doc); - onClose(); - }} - > - - {(doc as Record).correspondent - ? `${t("paperlessDialog.correspondent")}: ${(doc as Record).correspondent}` - : ""} - - } - /> - - - ))} + {results.results.map((doc) => { + const title = extractPaperlessTitle(doc) ?? `Dokument #${doc.id ?? "?"}`; + const correspondent = extractPaperlessProvider(doc); + const tags = extractPaperlessTags(doc); + + return ( + + { + onSelect(doc); + onClose(); + }} + > + + {correspondent && ( + + {t("paperlessDialog.correspondent")}: {correspondent} + + )} + {tags.length > 0 && ( + + {tags.slice(0, 5).join(", ")} + {tags.length > 5 ? "…" : ""} + + )} + + } + /> + + + ); + })} )} diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json index 5f7dce6..9e44461 100644 --- a/frontend/src/locales/de/common.json +++ b/frontend/src/locales/de/common.json @@ -102,6 +102,8 @@ "tagsPlaceholder": "z.B. strom, wohnung", "paperlessNotConfigured": "Paperless ist nicht konfiguriert. Hinterlege die API-URL und das Token in den Einstellungen.", "paperlessLinked": "Verknüpft mit: {{title}}", + "suggestionProvider": "Korrespondent: {{provider}}", + "suggestionTags": "Tags: {{tags}}", "saving": "Speichere…", "save": "Speichern", "saved": "Vertrag \"{{title}}\" gespeichert", diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 620f55f..9ba0992 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -102,6 +102,8 @@ "tagsPlaceholder": "e.g. energy, apartment", "paperlessNotConfigured": "Paperless is not configured. Provide the API URL and token in settings.", "paperlessLinked": "Linked to: {{title}}", + "suggestionProvider": "Correspondent: {{provider}}", + "suggestionTags": "Tags: {{tags}}", "saving": "Saving…", "save": "Save", "saved": "Contract \"{{title}}\" saved", diff --git a/frontend/src/routes/ContractForm.tsx b/frontend/src/routes/ContractForm.tsx index 7acf1ae..583a82e 100644 --- a/frontend/src/routes/ContractForm.tsx +++ b/frontend/src/routes/ContractForm.tsx @@ -13,7 +13,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { Controller, useForm } from "react-hook-form"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; import { z } from "zod"; @@ -22,6 +22,7 @@ import { createContract, fetchContract, fetchPaperlessDocument, + fetchPaperlessDocumentById, updateContract } from "../api/contracts"; import { fetchServerConfig } from "../api/config"; @@ -29,6 +30,7 @@ import PaperlessSearchDialog from "../components/PaperlessSearchDialog"; import PageHeader from "../components/PageHeader"; import { useSnackbar } from "../hooks/useSnackbar"; import { ContractPayload, PaperlessDocument } from "../types"; +import { extractPaperlessProvider, extractPaperlessTags, extractPaperlessTitle } from "../utils/paperless"; const formSchema = z.object({ title: z.string().min(1, "Titel erforderlich"), @@ -94,7 +96,8 @@ export default function ContractForm({ mode }: Props) { formState: { errors }, reset, setValue, - watch + watch, + getValues } = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -134,6 +137,25 @@ export default function ContractForm({ mode }: Props) { const [selectedDocument, setSelectedDocument] = useState(null); const paperlessDocumentId = watch("paperlessDocumentId"); + const watchedTitle = watch("title"); + const watchedProvider = watch("provider"); + const watchedCategory = watch("category"); + const watchedPaperlessId = watch("paperlessDocumentId"); + const watchedTags = watch("tags"); + + const providerSuggestion = useMemo( + () => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null), + [selectedDocument] + ); + const tagsSuggestion = useMemo( + () => (selectedDocument ? extractPaperlessTags(selectedDocument) : []), + [selectedDocument] + ); + const titleSuggestion = useMemo( + () => (selectedDocument ? extractPaperlessTitle(selectedDocument) : null), + [selectedDocument] + ); + useEffect(() => { if (mode === "edit" && contract) { reset({ @@ -179,6 +201,33 @@ export default function ContractForm({ mode }: Props) { } }, [paperlessDocumentId, selectedDocument]); + useEffect(() => { + if (!selectedDocument) { + return; + } + + if (import.meta.env.DEV) { + console.debug("Paperless document details", selectedDocument); + console.debug("Extracted suggestions", { + title: titleSuggestion, + provider: providerSuggestion, + tags: tagsSuggestion + }); + } + + if (titleSuggestion && !getValues("title")?.trim()) { + setValue("title", titleSuggestion, { shouldDirty: true }); + } + + if (providerSuggestion && !getValues("provider")?.trim()) { + setValue("provider", providerSuggestion, { shouldDirty: true }); + } + + if (tagsSuggestion.length > 0 && !getValues("tags")?.trim()) { + setValue("tags", tagsSuggestion.join(", "), { shouldDirty: true }); + } + }, [selectedDocument, titleSuggestion, providerSuggestion, tagsSuggestion, getValues, setValue]); + const mutation = useMutation({ mutationFn: async (values: FormValues) => { const payload: ContractPayload = { @@ -243,22 +292,34 @@ export default function ContractForm({ mode }: Props) { label={t("contractForm.fields.title")} fullWidth required + InputLabelProps={{ shrink: Boolean(watchedTitle?.trim()) }} {...register("title")} error={Boolean(errors.title)} helperText={errors.title?.message} /> - + - + )} + {(providerSuggestion || tagsSuggestion.length > 0) && ( + + {providerSuggestion + ? t("contractForm.suggestionProvider", { provider: providerSuggestion }) + : null} + {providerSuggestion && tagsSuggestion.length > 0 ? " • " : ""} + {tagsSuggestion.length > 0 + ? t("contractForm.suggestionTags", { tags: tagsSuggestion.slice(0, 5).join(", ") }) + : null} + + )} @@ -390,9 +463,23 @@ export default function ContractForm({ mode }: Props) { setSearchDialogOpen(false)} - onSelect={(doc) => { + onSelect={async (doc) => { const idValue = doc.id ? String(doc.id) : ""; setValue("paperlessDocumentId", idValue, { shouldDirty: true }); + + if (doc.id) { + try { + const detailed = await fetchPaperlessDocumentById(doc.id); + if (detailed) { + setSelectedDocument(detailed); + return; + } + } catch (error) { + // Fallback to the selected doc if the detail fetch fails. + console.error("Failed to fetch Paperless document details", error); + } + } + setSelectedDocument(doc); }} /> diff --git a/frontend/src/routes/ContractForm.tsx.orig b/frontend/src/routes/ContractForm.tsx.orig new file mode 100644 index 0000000..df924d9 --- /dev/null +++ b/frontend/src/routes/ContractForm.tsx.orig @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 75d346e..420f7fd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,6 +36,16 @@ export type PaperlessDocument = Record & { created?: string; modified?: string; notes?: string; + correspondent?: Record | string | null; + correspondent__name?: string; + correspondent_name?: string; + tags?: Array | string>; + tags__name?: string[]; + tag_names?: string[]; + tagNames?: string[]; + tagLabels?: string[]; + metadata?: Record; + tag_details?: Array>; }; export interface PaperlessSearchResponse { diff --git a/frontend/src/utils/paperless.ts b/frontend/src/utils/paperless.ts new file mode 100644 index 0000000..58b4953 --- /dev/null +++ b/frontend/src/utils/paperless.ts @@ -0,0 +1,198 @@ +import { PaperlessDocument } from "../types"; + +const CORRESPONDENT_KEYS = ["correspondent_name", "correspondent__name", "correspondent"]; + +const TAG_SOURCES = ["tag_names", "tags__name", "tagNames", "tags", "tagLabels", "tag_details"]; + +export function extractPaperlessTitle(document: PaperlessDocument): string | null { + const record = document as Record; + const value = record.title; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + return null; +} + +export function extractPaperlessProvider(document: PaperlessDocument): string | null { + const record = document as Record; + + for (const key of CORRESPONDENT_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (value && typeof value === "object") { + const candidate = + (value as Record).name ?? + (value as Record).title ?? + (value as Record).label ?? + (value as Record).value; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + } + + const metadata = record.metadata as Record | undefined; + if (metadata) { + if (Array.isArray(metadata)) { + const providerFromArray = extractProviderFromArray(metadata); + if (providerFromArray) { + return providerFromArray; + } + } + + const keys = Object.keys(metadata); + for (const key of keys) { + if (!key.toLowerCase().includes("correspondent")) { + continue; + } + const value = metadata[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (value && typeof value === "object") { + const candidate = + (value as Record).name ?? + (value as Record).title ?? + (value as Record).label ?? + (value as Record).value; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + } + } + + return null; +} + +export function extractPaperlessTags(document: PaperlessDocument): string[] { + const record = document as Record; + const names = new Set(); + + for (const key of TAG_SOURCES) { + const source = record[key]; + if (!Array.isArray(source)) { + maybeCollectFromValue(source, names); + continue; + } + collectFromArray(source, names); + } + + const metadata = record.metadata as Record | Array | undefined; + if (metadata) { + if (Array.isArray(metadata)) { + for (const entry of metadata) { + maybeCollectFromValue(entry, names); + } + } else { + for (const key of TAG_SOURCES) { + const value = metadata[key]; + if (Array.isArray(value)) { + collectFromArray(value, names); + } else { + maybeCollectFromValue(value, names); + } + } + + const metaTags = metadata.tags ?? metadata.tag_list ?? metadata.TagList; + if (Array.isArray(metaTags)) { + collectFromArray(metaTags, names); + } else if (typeof metaTags === "string") { + collectFromString(metaTags, names); + } + + for (const [key, value] of Object.entries(metadata)) { + if (!key.toLowerCase().includes("tag")) { + continue; + } + if (Array.isArray(value)) { + collectFromArray(value, names); + } else if (typeof value === "string") { + collectFromString(value, names); + } + } + } + } + + return Array.from(names); +} + +function collectFromArray(items: Array, names: Set) { + for (const item of items) { + if (typeof item === "string" && item.trim().length > 0) { + names.add(item.trim()); + continue; + } + maybeCollectFromValue(item, names); + } +} + +function maybeCollectFromValue(value: unknown, names: Set) { + if (!value) { + return; + } + if (typeof value === "string") { + collectFromString(value, names); + return; + } + if (Array.isArray(value)) { + collectFromArray(value, names); + return; + } + const record = value as Record; + const candidate = + record.name ?? + record.label ?? + record.title ?? + record.value ?? + record.slug; + if (typeof candidate === "string" && candidate.trim().length > 0) { + names.add(candidate.trim()); + return; + } + const objectValues = Object.values(record); + for (const nested of objectValues) { + if (typeof nested === "string" && nested.trim().length > 0) { + names.add(nested.trim()); + } else if (nested && typeof nested === "object") { + maybeCollectFromValue(nested, names); + } + } +} + +function collectFromString(value: string, names: Set) { + const parts = value + .split(/[,;]+/) + .map((part) => part.trim()) + .filter(Boolean); + for (const part of parts) { + names.add(part); + } +} + +function extractProviderFromArray(entries: Array): string | null { + for (const entry of entries) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const lowerKeys = Object.keys(record).map((key) => key.toLowerCase()); + const hasCorrespondentKey = lowerKeys.some((key) => key.includes("correspondent")); + const slug = (record.slug ?? record.key ?? record.field) as string | undefined; + const slugMatches = typeof slug === "string" && slug.toLowerCase().includes("correspondent"); + if (!hasCorrespondentKey && !slugMatches) { + continue; + } + const candidate = + record.value ?? + record.name ?? + record.label ?? + record.title; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1035169..181a333 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,5 +15,8 @@ export default defineConfig({ }, preview: { port: 4173 + }, + build: { + chunkSizeWarningLimit: 1500 } }); diff --git a/src/index.ts b/src/index.ts index 8bcda19..a4e17f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -365,6 +365,27 @@ app.get("/integrations/paperless/search", async (req, res, next) => { } }); +app.get("/integrations/paperless/documents/:documentId", async (req, res, next) => { + if (!paperlessClient.isConfigured) { + return res.status(503).json({ error: "Paperless integration not configured" }); + } + + const documentId = parseId(req.params.documentId); + if (!documentId) { + return res.status(400).json({ error: "Invalid document id" }); + } + + try { + const document = await paperlessClient.getDocument(documentId); + if (!document) { + return res.status(404).json({ error: "Document not found" }); + } + res.json(document); + } catch (error) { + next(error); + } +}); + app.get("/contracts", (req, res) => { const skip = Number(req.query.skip ?? 0); const limit = Math.min(Number(req.query.limit ?? 100), 500); diff --git a/src/paperlessClient.ts b/src/paperlessClient.ts index 687576d..702dd8a 100644 --- a/src/paperlessClient.ts +++ b/src/paperlessClient.ts @@ -4,6 +4,12 @@ import { getRuntimeSettings } from "./runtimeSettings.js"; const logger = createLogger(config.logLevel); +type PaperlessDocument = Record; + +interface PaperlessCollectionResponse { + results?: T[]; +} + export class PaperlessClient { get isConfigured() { const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings(); @@ -31,12 +37,23 @@ export class PaperlessClient { return headers; } - async getDocument(documentId: number): Promise | null> { + private async fetchJson(url: URL): Promise { + const response = await fetch(url, { headers: this.getHeaders() }); + if (!response.ok) { + const text = await response.text(); + logger.error(`Paperless API error ${response.status}: ${text}`); + throw new Error(`Paperless request failed with status ${response.status}`); + } + return response.json() as Promise; + } + + async getDocument(documentId: number): Promise { if (!this.isConfigured) { throw new Error("Paperless integration is not configured"); } - const url = this.buildUrl(`/api/documents/${documentId}/`); + const url = new URL(this.buildUrl(`/api/documents/${documentId}/`)); + url.searchParams.set("metadata", "true"); const response = await fetch(url, { headers: this.getHeaders() }); if (response.status === 404) { @@ -49,7 +66,9 @@ export class PaperlessClient { throw new Error(`Paperless request failed with status ${response.status}`); } - return response.json() as Promise>; + const document = (await response.json()) as PaperlessDocument; + await this.enrichDocuments([document]); + return document; } async searchDocuments(query: string, page = 1): Promise> { @@ -60,16 +79,139 @@ export class PaperlessClient { const url = new URL(this.buildUrl("/api/documents/")); url.searchParams.set("query", query); url.searchParams.set("page", page.toString()); + url.searchParams.set("metadata", "true"); - const response = await fetch(url, { headers: this.getHeaders() }); + const payload = await this.fetchJson>(url); + const results = Array.isArray((payload as PaperlessCollectionResponse).results) + ? ((payload as PaperlessCollectionResponse).results as PaperlessDocument[]) + : []; + await this.enrichDocuments(results); + return payload; + } - if (!response.ok) { - const text = await response.text(); - logger.error(`Paperless API error ${response.status}: ${text}`); - throw new Error(`Paperless request failed with status ${response.status}`); + async enrichDocuments(documents: PaperlessDocument[]): Promise { + if (!documents.length) return; + + const correspondentIds = new Set(); + const tagIds = new Set(); + + for (const doc of documents) { + if (typeof doc.correspondent === "number") { + correspondentIds.add(doc.correspondent); + } + + const tags = Array.isArray(doc.tags) ? doc.tags : []; + for (const tag of tags) { + if (typeof tag === "number") { + tagIds.add(tag); + } + } } - return response.json() as Promise>; + const [correspondents, tags] = await Promise.all([ + this.fetchCorrespondents(Array.from(correspondentIds)), + this.fetchTags(Array.from(tagIds)) + ]); + + for (const doc of documents) { + if (typeof doc.correspondent === "number" && correspondents.has(doc.correspondent)) { + const correspondent = correspondents.get(doc.correspondent)!; + doc.correspondent_name = correspondent.name ?? correspondent.slug ?? correspondent.title ?? correspondent.value ?? null; + if (!doc.metadata || typeof doc.metadata !== "object") { + doc.metadata = {}; + } + (doc.metadata as Record).correspondent_name = doc.correspondent_name; + } + + if (Array.isArray(doc.tags) && doc.tags.length > 0) { + const tagNames: string[] = []; + const tagObjects: Array> = []; + for (const tag of doc.tags) { + if (typeof tag === "number" && tags.has(tag)) { + const tagData = tags.get(tag)!; + const name = + tagData.name ?? + tagData.slug ?? + tagData.label ?? + tagData.title ?? + tagData.value ?? + null; + if (name && typeof name === "string") { + tagNames.push(name); + } + tagObjects.push(tagData); + } else if (typeof tag === "string") { + tagNames.push(tag); + } else if (tag && typeof tag === "object") { + tagObjects.push(tag as Record); + } + } + if (tagNames.length > 0) { + doc.tags__name = tagNames; + if (!doc.metadata || typeof doc.metadata !== "object") { + doc.metadata = {}; + } + (doc.metadata as Record).tag_names = tagNames; + } + if (tagObjects.length > 0) { + doc.tag_details = tagObjects; + } + } + } + } + + private async fetchCorrespondents(ids: number[]): Promise>> { + const map = new Map>(); + if (ids.length === 0) { + return map; + } + + const chunks = this.chunkIds(ids); + for (const chunk of chunks) { + const url = new URL(this.buildUrl("/api/correspondents/")); + url.searchParams.set("id__in", chunk.join(",")); + url.searchParams.set("page_size", "100"); + const payload = await this.fetchJson>>(url); + const results = Array.isArray(payload.results) ? payload.results : []; + for (const item of results) { + if (typeof item.id === "number") { + map.set(item.id, item); + } + } + } + + return map; + } + + private async fetchTags(ids: number[]): Promise>> { + const map = new Map>(); + if (ids.length === 0) { + return map; + } + + const chunks = this.chunkIds(ids); + for (const chunk of chunks) { + const url = new URL(this.buildUrl("/api/tags/")); + url.searchParams.set("id__in", chunk.join(",")); + url.searchParams.set("page_size", "100"); + const payload = await this.fetchJson>>(url); + const results = Array.isArray(payload.results) ? payload.results : []; + for (const item of results) { + if (typeof item.id === "number") { + map.set(item.id, item); + } + } + } + + return map; + } + + private chunkIds(ids: number[], size = 50): number[][] { + const chunks: number[][] = []; + for (let i = 0; i < ids.length; i += size) { + chunks.push(ids.slice(i, i + size)); + } + return chunks; } }