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> { export async function searchPaperlessDocuments(query: string, page = 1): Promise<PaperlessSearchResponse> {
const params = new URLSearchParams({ q: query, page: String(page) }); const params = new URLSearchParams({ q: query, page: String(page) });
return request<PaperlessSearchResponse>(`/integrations/paperless/search?${params.toString()}`, { 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 { searchPaperlessDocuments } from "../api/contracts";
import { useSnackbar } from "../hooks/useSnackbar"; import { useSnackbar } from "../hooks/useSnackbar";
import { PaperlessDocument, PaperlessSearchResponse } from "../types"; import { PaperlessDocument, PaperlessSearchResponse } from "../types";
import { extractPaperlessProvider, extractPaperlessTags, extractPaperlessTitle } from "../utils/paperless";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -101,27 +102,41 @@ export default function PaperlessSearchDialog({ open, onClose, onSelect }: Props
)} )}
{results && results.results.length > 0 && ( {results && results.results.length > 0 && (
<List> <List>
{results.results.map((doc) => ( {results.results.map((doc) => {
<ListItem key={doc.id} disablePadding> const title = extractPaperlessTitle(doc) ?? `Dokument #${doc.id ?? "?"}`;
<ListItemButton const correspondent = extractPaperlessProvider(doc);
onClick={() => { const tags = extractPaperlessTags(doc);
onSelect(doc);
onClose(); return (
}} <ListItem key={doc.id} disablePadding>
> <ListItemButton
<ListItemText onClick={() => {
primary={doc.title ?? `Dokument #${doc.id}`} onSelect(doc);
secondary={ onClose();
<Box component="span" sx={{ display: "block", color: "text.secondary" }}> }}
{(doc as Record<string, unknown>).correspondent >
? `${t("paperlessDialog.correspondent")}: ${(doc as Record<string, unknown>).correspondent}` <ListItemText
: ""} primary={title}
</Box> secondary={
} <Box component="span" sx={{ display: "block", color: "text.secondary" }}>
/> {correspondent && (
</ListItemButton> <Typography variant="body2" color="inherit">
</ListItem> {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> </List>
)} )}
</Stack> </Stack>

View File

@@ -102,6 +102,8 @@
"tagsPlaceholder": "z.B. strom, wohnung", "tagsPlaceholder": "z.B. strom, wohnung",
"paperlessNotConfigured": "Paperless ist nicht konfiguriert. Hinterlege die API-URL und das Token in den Einstellungen.", "paperlessNotConfigured": "Paperless ist nicht konfiguriert. Hinterlege die API-URL und das Token in den Einstellungen.",
"paperlessLinked": "Verknüpft mit: {{title}}", "paperlessLinked": "Verknüpft mit: {{title}}",
"suggestionProvider": "Korrespondent: {{provider}}",
"suggestionTags": "Tags: {{tags}}",
"saving": "Speichere…", "saving": "Speichere…",
"save": "Speichern", "save": "Speichern",
"saved": "Vertrag \"{{title}}\" gespeichert", "saved": "Vertrag \"{{title}}\" gespeichert",

View File

@@ -102,6 +102,8 @@
"tagsPlaceholder": "e.g. energy, apartment", "tagsPlaceholder": "e.g. energy, apartment",
"paperlessNotConfigured": "Paperless is not configured. Provide the API URL and token in settings.", "paperlessNotConfigured": "Paperless is not configured. Provide the API URL and token in settings.",
"paperlessLinked": "Linked to: {{title}}", "paperlessLinked": "Linked to: {{title}}",
"suggestionProvider": "Correspondent: {{provider}}",
"suggestionTags": "Tags: {{tags}}",
"saving": "Saving…", "saving": "Saving…",
"save": "Save", "save": "Save",
"saved": "Contract \"{{title}}\" saved", "saved": "Contract \"{{title}}\" saved",

View File

@@ -13,7 +13,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
@@ -22,6 +22,7 @@ import {
createContract, createContract,
fetchContract, fetchContract,
fetchPaperlessDocument, fetchPaperlessDocument,
fetchPaperlessDocumentById,
updateContract updateContract
} from "../api/contracts"; } from "../api/contracts";
import { fetchServerConfig } from "../api/config"; import { fetchServerConfig } from "../api/config";
@@ -29,6 +30,7 @@ import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
import PageHeader from "../components/PageHeader"; import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar"; import { useSnackbar } from "../hooks/useSnackbar";
import { ContractPayload, PaperlessDocument } from "../types"; import { ContractPayload, PaperlessDocument } from "../types";
import { extractPaperlessProvider, extractPaperlessTags, extractPaperlessTitle } from "../utils/paperless";
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(1, "Titel erforderlich"), title: z.string().min(1, "Titel erforderlich"),
@@ -94,7 +96,8 @@ export default function ContractForm({ mode }: Props) {
formState: { errors }, formState: { errors },
reset, reset,
setValue, setValue,
watch watch,
getValues
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -134,6 +137,25 @@ export default function ContractForm({ mode }: Props) {
const [selectedDocument, setSelectedDocument] = useState<PaperlessDocument | null>(null); const [selectedDocument, setSelectedDocument] = useState<PaperlessDocument | null>(null);
const paperlessDocumentId = watch("paperlessDocumentId"); 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(() => { useEffect(() => {
if (mode === "edit" && contract) { if (mode === "edit" && contract) {
reset({ reset({
@@ -179,6 +201,33 @@ export default function ContractForm({ mode }: Props) {
} }
}, [paperlessDocumentId, selectedDocument]); }, [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({ const mutation = useMutation({
mutationFn: async (values: FormValues) => { mutationFn: async (values: FormValues) => {
const payload: ContractPayload = { const payload: ContractPayload = {
@@ -243,22 +292,34 @@ export default function ContractForm({ mode }: Props) {
label={t("contractForm.fields.title")} label={t("contractForm.fields.title")}
fullWidth fullWidth
required required
InputLabelProps={{ shrink: Boolean(watchedTitle?.trim()) }}
{...register("title")} {...register("title")}
error={Boolean(errors.title)} error={Boolean(errors.title)}
helperText={errors.title?.message} helperText={errors.title?.message}
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}> <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>
<Grid item xs={12} md={6}> <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>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="flex-start"> <Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="flex-start">
<TextField <TextField
label={t("contractForm.fields.paperlessId")} label={t("contractForm.fields.paperlessId")}
fullWidth fullWidth
InputLabelProps={{ shrink: Boolean(watchedPaperlessId?.trim()) }}
{...register("paperlessDocumentId")} {...register("paperlessDocumentId")}
error={Boolean(errors.paperlessDocumentId)} error={Boolean(errors.paperlessDocumentId)}
helperText={errors.paperlessDocumentId?.message} helperText={errors.paperlessDocumentId?.message}
@@ -283,6 +344,17 @@ export default function ContractForm({ mode }: Props) {
})} })}
</Typography> </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>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField <TextField
@@ -368,7 +440,8 @@ export default function ContractForm({ mode }: Props) {
<TextField <TextField
label={t("contractForm.fields.tags")} label={t("contractForm.fields.tags")}
fullWidth fullWidth
placeholder={t("contractForm.tagsPlaceholder")} placeholder={watchedTags?.trim() ? undefined : t("contractForm.tagsPlaceholder")}
InputLabelProps={{ shrink: Boolean(watchedTags?.trim()) }}
{...register("tags")} {...register("tags")}
/> />
</Grid> </Grid>
@@ -390,9 +463,23 @@ export default function ContractForm({ mode }: Props) {
<PaperlessSearchDialog <PaperlessSearchDialog
open={searchDialogOpen} open={searchDialogOpen}
onClose={() => setSearchDialogOpen(false)} onClose={() => setSearchDialogOpen(false)}
onSelect={(doc) => { onSelect={async (doc) => {
const idValue = doc.id ? String(doc.id) : ""; const idValue = doc.id ? String(doc.id) : "";
setValue("paperlessDocumentId", idValue, { shouldDirty: true }); 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); setSelectedDocument(doc);
}} }}
/> />

View File

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

View File

@@ -36,6 +36,16 @@ export type PaperlessDocument = Record<string, unknown> & {
created?: string; created?: string;
modified?: string; modified?: string;
notes?: 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 { 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: { preview: {
port: 4173 port: 4173
},
build: {
chunkSizeWarningLimit: 1500
} }
}); });

View File

@@ -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) => { app.get("/contracts", (req, res) => {
const skip = Number(req.query.skip ?? 0); const skip = Number(req.query.skip ?? 0);
const limit = Math.min(Number(req.query.limit ?? 100), 500); const limit = Math.min(Number(req.query.limit ?? 100), 500);

View File

@@ -4,6 +4,12 @@ import { getRuntimeSettings } from "./runtimeSettings.js";
const logger = createLogger(config.logLevel); const logger = createLogger(config.logLevel);
type PaperlessDocument = Record<string, unknown>;
interface PaperlessCollectionResponse<T> {
results?: T[];
}
export class PaperlessClient { export class PaperlessClient {
get isConfigured() { get isConfigured() {
const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings(); const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings();
@@ -31,12 +37,23 @@ export class PaperlessClient {
return headers; 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) { if (!this.isConfigured) {
throw new Error("Paperless integration is not configured"); 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() }); const response = await fetch(url, { headers: this.getHeaders() });
if (response.status === 404) { if (response.status === 404) {
@@ -49,7 +66,9 @@ export class PaperlessClient {
throw new Error(`Paperless request failed with status ${response.status}`); 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>> { 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/")); const url = new URL(this.buildUrl("/api/documents/"));
url.searchParams.set("query", query); url.searchParams.set("query", query);
url.searchParams.set("page", page.toString()); 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) { async enrichDocuments(documents: PaperlessDocument[]): Promise<void> {
const text = await response.text(); if (!documents.length) return;
logger.error(`Paperless API error ${response.status}: ${text}`);
throw new Error(`Paperless request failed with status ${response.status}`); 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;
} }
} }