Title, Provider, tags from paperless

This commit is contained in:
MDeeApp
2025-10-11 11:32:04 +02:00
parent 8eb060f380
commit 342a73ecb5
11 changed files with 530 additions and 36 deletions

View File

@@ -80,6 +80,19 @@ export async function fetchPaperlessDocument(
}
}
export async function fetchPaperlessDocumentById(documentId: number): Promise<PaperlessDocument | null> {
try {
return await request<PaperlessDocument | null>(`/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<PaperlessSearchResponse> {
const params = new URLSearchParams({ q: query, page: String(page) });
return request<PaperlessSearchResponse>(`/integrations/paperless/search?${params.toString()}`, {

View File

@@ -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 && (
<List>
{results.results.map((doc) => (
<ListItem key={doc.id} disablePadding>
<ListItemButton
onClick={() => {
onSelect(doc);
onClose();
}}
>
<ListItemText
primary={doc.title ?? `Dokument #${doc.id}`}
secondary={
<Box component="span" sx={{ display: "block", color: "text.secondary" }}>
{(doc as Record<string, unknown>).correspondent
? `${t("paperlessDialog.correspondent")}: ${(doc as Record<string, unknown>).correspondent}`
: ""}
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
{results.results.map((doc) => {
const title = extractPaperlessTitle(doc) ?? `Dokument #${doc.id ?? "?"}`;
const correspondent = extractPaperlessProvider(doc);
const tags = extractPaperlessTags(doc);
return (
<ListItem key={doc.id} disablePadding>
<ListItemButton
onClick={() => {
onSelect(doc);
onClose();
}}
>
<ListItemText
primary={title}
secondary={
<Box component="span" sx={{ display: "block", color: "text.secondary" }}>
{correspondent && (
<Typography variant="body2" color="inherit">
{t("paperlessDialog.correspondent")}: {correspondent}
</Typography>
)}
{tags.length > 0 && (
<Typography variant="body2" color="inherit">
{tags.slice(0, 5).join(", ")}
{tags.length > 5 ? "…" : ""}
</Typography>
)}
</Box>
}
/>
</ListItemButton>
</ListItem>
);
})}
</List>
)}
</Stack>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -134,6 +137,25 @@ export default function ContractForm({ mode }: Props) {
const [selectedDocument, setSelectedDocument] = useState<PaperlessDocument | null>(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}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.provider")} fullWidth {...register("provider")} />
<TextField
label={t("contractForm.fields.provider")}
fullWidth
InputLabelProps={{ shrink: Boolean(watchedProvider?.trim()) }}
{...register("provider")}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.category")} fullWidth {...register("category")} />
<TextField
label={t("contractForm.fields.category")}
fullWidth
InputLabelProps={{ shrink: Boolean(watchedCategory?.trim()) }}
{...register("category")}
/>
</Grid>
<Grid item xs={12} md={6}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="flex-start">
<TextField
label={t("contractForm.fields.paperlessId")}
fullWidth
InputLabelProps={{ shrink: Boolean(watchedPaperlessId?.trim()) }}
{...register("paperlessDocumentId")}
error={Boolean(errors.paperlessDocumentId)}
helperText={errors.paperlessDocumentId?.message}
@@ -283,6 +344,17 @@ export default function ContractForm({ mode }: Props) {
})}
</Typography>
)}
{(providerSuggestion || tagsSuggestion.length > 0) && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
{providerSuggestion
? t("contractForm.suggestionProvider", { provider: providerSuggestion })
: null}
{providerSuggestion && tagsSuggestion.length > 0 ? " • " : ""}
{tagsSuggestion.length > 0
? t("contractForm.suggestionTags", { tags: tagsSuggestion.slice(0, 5).join(", ") })
: null}
</Typography>
)}
</Grid>
<Grid item xs={12} md={6}>
<TextField
@@ -368,7 +440,8 @@ export default function ContractForm({ mode }: Props) {
<TextField
label={t("contractForm.fields.tags")}
fullWidth
placeholder={t("contractForm.tagsPlaceholder")}
placeholder={watchedTags?.trim() ? undefined : t("contractForm.tagsPlaceholder")}
InputLabelProps={{ shrink: Boolean(watchedTags?.trim()) }}
{...register("tags")}
/>
</Grid>
@@ -390,9 +463,23 @@ export default function ContractForm({ mode }: Props) {
<PaperlessSearchDialog
open={searchDialogOpen}
onClose={() => 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);
}}
/>

View File

@@ -0,0 +1 @@
<placeholder original snippet>

View File

@@ -36,6 +36,16 @@ export type PaperlessDocument = Record<string, unknown> & {
created?: string;
modified?: string;
notes?: string;
correspondent?: Record<string, unknown> | string | null;
correspondent__name?: string;
correspondent_name?: string;
tags?: Array<Record<string, unknown> | string>;
tags__name?: string[];
tag_names?: string[];
tagNames?: string[];
tagLabels?: string[];
metadata?: Record<string, unknown>;
tag_details?: Array<Record<string, unknown>>;
};
export interface PaperlessSearchResponse {

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>).name ??
(value as Record<string, unknown>).title ??
(value as Record<string, unknown>).label ??
(value as Record<string, unknown>).value;
if (typeof candidate === "string" && candidate.trim().length > 0) {
return candidate.trim();
}
}
}
const metadata = record.metadata as Record<string, unknown> | 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<string, unknown>).name ??
(value as Record<string, unknown>).title ??
(value as Record<string, unknown>).label ??
(value as Record<string, unknown>).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<string, unknown>;
const names = new Set<string>();
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<string, unknown> | Array<unknown> | 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<unknown>, names: Set<string>) {
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<string>) {
if (!value) {
return;
}
if (typeof value === "string") {
collectFromString(value, names);
return;
}
if (Array.isArray(value)) {
collectFromArray(value, names);
return;
}
const record = value as Record<string, unknown>;
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<string>) {
const parts = value
.split(/[,;]+/)
.map((part) => part.trim())
.filter(Boolean);
for (const part of parts) {
names.add(part);
}
}
function extractProviderFromArray(entries: Array<unknown>): string | null {
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
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;
}

View File

@@ -15,5 +15,8 @@ export default defineConfig({
},
preview: {
port: 4173
},
build: {
chunkSizeWarningLimit: 1500
}
});