PaperCortex adds semantic search, auto-classification, receipt extraction, bank statement matching, and DATEV export to Paperless-ngx — powered entirely by local AI through Ollama. Exposes everything as an MCP Server for Claude Code and AI agent integration. - MCP Server with 5 tools (search, classify, receipt, query, export) - Local Ollama embeddings for semantic document search - Receipt data extraction (vendor, amount, date, tax, line items) - DATEV Buchungsstapel CSV export for German accounting - Bank CSV transaction matching - Paperless-ngx REST API client - Docker deployment - Zero cloud dependencies — 100% self-hosted
183 lines
5.0 KiB
TypeScript
183 lines
5.0 KiB
TypeScript
/**
|
|
* Paperless-ngx REST API client.
|
|
*
|
|
* Provides typed access to documents, correspondents, tags, and document types.
|
|
* All methods return immutable result objects.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const client = createPaperlessClient({
|
|
* baseUrl: "http://localhost:8000",
|
|
* token: "your-api-token",
|
|
* });
|
|
* const docs = await client.getDocuments({ query: "invoice" });
|
|
* ```
|
|
*/
|
|
|
|
import type {
|
|
Correspondent,
|
|
DocumentSearchParams,
|
|
DocumentType,
|
|
PaginatedResponse,
|
|
PaperlessConfig,
|
|
PaperlessDocument,
|
|
Tag,
|
|
} from "./types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PaperlessClient {
|
|
/** Fetch a single document by ID. */
|
|
getDocument(id: number): Promise<PaperlessDocument>;
|
|
|
|
/** Search / list documents with optional filters. */
|
|
getDocuments(
|
|
params?: DocumentSearchParams,
|
|
): Promise<PaginatedResponse<PaperlessDocument>>;
|
|
|
|
/** Fetch all correspondents. */
|
|
getCorrespondents(): Promise<PaginatedResponse<Correspondent>>;
|
|
|
|
/** Fetch all tags. */
|
|
getTags(): Promise<PaginatedResponse<Tag>>;
|
|
|
|
/** Fetch all document types. */
|
|
getDocumentTypes(): Promise<PaginatedResponse<DocumentType>>;
|
|
|
|
/** Download the original file content of a document. */
|
|
downloadDocument(id: number): Promise<ArrayBuffer>;
|
|
|
|
/** Update tags on a document (immutable -- returns the updated doc). */
|
|
updateDocumentTags(
|
|
id: number,
|
|
tagIds: readonly number[],
|
|
): Promise<PaperlessDocument>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new Paperless-ngx API client.
|
|
*
|
|
* @param config - Connection configuration (URL + token).
|
|
* @returns A {@link PaperlessClient} instance.
|
|
*/
|
|
export function createPaperlessClient(config: PaperlessConfig): PaperlessClient {
|
|
const { baseUrl, token, timeout = 30_000 } = config;
|
|
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Token ${token}`,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json; version=3",
|
|
};
|
|
|
|
/**
|
|
* Internal fetch wrapper with timeout and error handling.
|
|
*/
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
const url = `${baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: { ...headers, ...((options.headers as Record<string, string>) ?? {}) },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => "");
|
|
throw new Error(
|
|
`Paperless API error: ${response.status} ${response.statusText} -- ${body}`,
|
|
);
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build query string from search params.
|
|
*/
|
|
function buildQuery(params?: DocumentSearchParams): string {
|
|
if (!params) return "";
|
|
const entries = Object.entries(params).filter(
|
|
([, v]) => v !== undefined && v !== null,
|
|
);
|
|
if (entries.length === 0) return "";
|
|
const searchParams = new URLSearchParams();
|
|
for (const [key, value] of entries) {
|
|
if (Array.isArray(value)) {
|
|
searchParams.set(key, value.join(","));
|
|
} else {
|
|
searchParams.set(key, String(value));
|
|
}
|
|
}
|
|
return `?${searchParams.toString()}`;
|
|
}
|
|
|
|
return {
|
|
async getDocument(id) {
|
|
return request<PaperlessDocument>(`/documents/${id}/`);
|
|
},
|
|
|
|
async getDocuments(params) {
|
|
return request<PaginatedResponse<PaperlessDocument>>(
|
|
`/documents/${buildQuery(params)}`,
|
|
);
|
|
},
|
|
|
|
async getCorrespondents() {
|
|
return request<PaginatedResponse<Correspondent>>("/correspondents/");
|
|
},
|
|
|
|
async getTags() {
|
|
return request<PaginatedResponse<Tag>>("/tags/");
|
|
},
|
|
|
|
async getDocumentTypes() {
|
|
return request<PaginatedResponse<DocumentType>>("/document_types/");
|
|
},
|
|
|
|
async downloadDocument(id) {
|
|
const url = `${baseUrl.replace(/\/+$/, "")}/api/documents/${id}/download/`;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Token ${token}` },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Paperless download error: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
return await response.arrayBuffer();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
},
|
|
|
|
async updateDocumentTags(id, tagIds) {
|
|
return request<PaperlessDocument>(`/documents/${id}/`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ tags: [...tagIds] }),
|
|
});
|
|
},
|
|
};
|
|
}
|