Title, Provider, tags from paperless
This commit is contained in:
@@ -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()}`, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
1
frontend/src/routes/ContractForm.tsx.orig
Normal file
1
frontend/src/routes/ContractForm.tsx.orig
Normal file
@@ -0,0 +1 @@
|
||||
<placeholder original snippet>
|
||||
@@ -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 {
|
||||
|
||||
198
frontend/src/utils/paperless.ts
Normal file
198
frontend/src/utils/paperless.ts
Normal 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;
|
||||
}
|
||||
@@ -15,5 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
preview: {
|
||||
port: 4173
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500
|
||||
}
|
||||
});
|
||||
|
||||
21
src/index.ts
21
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);
|
||||
|
||||
@@ -4,6 +4,12 @@ import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
type PaperlessDocument = Record<string, unknown>;
|
||||
|
||||
interface PaperlessCollectionResponse<T> {
|
||||
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<Record<string, unknown> | null> {
|
||||
private async fetchJson<T>(url: URL): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
async getDocument(documentId: number): Promise<PaperlessDocument | null> {
|
||||
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<Record<string, unknown>>;
|
||||
const document = (await response.json()) as PaperlessDocument;
|
||||
await this.enrichDocuments([document]);
|
||||
return document;
|
||||
}
|
||||
|
||||
async searchDocuments(query: string, page = 1): Promise<Record<string, unknown>> {
|
||||
@@ -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<Record<string, unknown>>(url);
|
||||
const results = Array.isArray((payload as PaperlessCollectionResponse<PaperlessDocument>).results)
|
||||
? ((payload as PaperlessCollectionResponse<PaperlessDocument>).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<void> {
|
||||
if (!documents.length) return;
|
||||
|
||||
const correspondentIds = new Set<number>();
|
||||
const tagIds = new Set<number>();
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>).correspondent_name = doc.correspondent_name;
|
||||
}
|
||||
|
||||
if (Array.isArray(doc.tags) && doc.tags.length > 0) {
|
||||
const tagNames: string[] = [];
|
||||
const tagObjects: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown>);
|
||||
}
|
||||
}
|
||||
if (tagNames.length > 0) {
|
||||
doc.tags__name = tagNames;
|
||||
if (!doc.metadata || typeof doc.metadata !== "object") {
|
||||
doc.metadata = {};
|
||||
}
|
||||
(doc.metadata as Record<string, unknown>).tag_names = tagNames;
|
||||
}
|
||||
if (tagObjects.length > 0) {
|
||||
doc.tag_details = tagObjects;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCorrespondents(ids: number[]): Promise<Map<number, Record<string, unknown>>> {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
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<PaperlessCollectionResponse<Record<string, unknown>>>(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<Map<number, Record<string, unknown>>> {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
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<PaperlessCollectionResponse<Record<string, unknown>>>(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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user