@baruchiro/paperless-mcp 0.5.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +334 -11
- package/build/api/PaperlessAPI.d.ts +29 -2
- package/build/api/PaperlessAPI.js +97 -8
- package/build/api/types.d.ts +47 -0
- package/build/index.js +28 -5
- package/build/resources/documents.d.ts +7 -0
- package/build/resources/documents.js +119 -0
- package/build/resources/documents.test.d.ts +1 -0
- package/build/resources/documents.test.js +124 -0
- package/build/server.d.ts +14 -1
- package/build/server.js +21 -5
- package/build/server.test.d.ts +1 -0
- package/build/server.test.js +54 -0
- package/build/tools/documents.d.ts +3 -1
- package/build/tools/documents.js +170 -67
- package/build/tools/documents.test.js +404 -0
- package/build/tools/mail.d.ts +3 -0
- package/build/tools/mail.js +187 -0
- package/build/tools/mail.test.d.ts +1 -0
- package/build/tools/mail.test.js +212 -0
- package/build/tools/notes.d.ts +3 -0
- package/build/tools/notes.js +55 -0
- package/build/tools/notes.test.d.ts +1 -0
- package/build/tools/notes.test.js +177 -0
- package/build/tools/utils/descriptions.d.ts +1 -1
- package/build/tools/utils/descriptions.js +1 -1
- package/build/tools/utils/documentQuery.d.ts +71 -0
- package/build/tools/utils/documentQuery.js +270 -0
- package/build/tools/utils/resourceUri.d.ts +12 -7
- package/build/tools/utils/resourceUri.js +13 -8
- package/build/tools/utils/resourceUri.test.js +12 -12
- package/build/tools/utils/selectFields.d.ts +11 -0
- package/build/tools/utils/selectFields.js +91 -0
- package/build/tools/utils/selectFields.test.d.ts +1 -0
- package/build/tools/utils/selectFields.test.js +164 -0
- package/package.json +2 -1
- package/paperless-mcp.dxt +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DOCUMENT_QUERY_PAPERLESS_FILTER_KEYS = exports.SEARCH_DOCUMENTS_ARGS_SHAPE = exports.QUERY_DOCUMENTS_ARGS_SHAPE = exports.LIST_DOCUMENTS_ARGS_SHAPE = exports.paperlessFiltersSchema = exports.paperlessFilterValueSchema = exports.customFieldQuerySchema = void 0;
|
|
4
|
+
exports.buildDocumentQueryString = buildDocumentQueryString;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const CUSTOM_FIELD_QUERY_GROUP_OPERATORS = ["AND", "OR"];
|
|
7
|
+
const customFieldQueryPrimitiveSchema = zod_1.z.union([
|
|
8
|
+
zod_1.z.string(),
|
|
9
|
+
zod_1.z.number(),
|
|
10
|
+
zod_1.z.boolean(),
|
|
11
|
+
zod_1.z.null(),
|
|
12
|
+
]);
|
|
13
|
+
const customFieldQueryValueSchema = zod_1.z.union([
|
|
14
|
+
customFieldQueryPrimitiveSchema,
|
|
15
|
+
zod_1.z.array(customFieldQueryPrimitiveSchema),
|
|
16
|
+
]);
|
|
17
|
+
function isCustomFieldQueryPrimitive(value) {
|
|
18
|
+
return (typeof value === "string" ||
|
|
19
|
+
typeof value === "number" ||
|
|
20
|
+
typeof value === "boolean" ||
|
|
21
|
+
value === null);
|
|
22
|
+
}
|
|
23
|
+
function isCustomFieldQueryValue(value) {
|
|
24
|
+
if (isCustomFieldQueryPrimitive(value)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return (Array.isArray(value) && value.every((item) => isCustomFieldQueryPrimitive(item)));
|
|
28
|
+
}
|
|
29
|
+
function isCustomFieldQueryGroupOperator(value) {
|
|
30
|
+
return (typeof value === "string" &&
|
|
31
|
+
CUSTOM_FIELD_QUERY_GROUP_OPERATORS.includes(value));
|
|
32
|
+
}
|
|
33
|
+
function isCustomFieldQuery(value) {
|
|
34
|
+
if (!Array.isArray(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (value.length === 3 &&
|
|
38
|
+
(typeof value[0] === "string" || typeof value[0] === "number") &&
|
|
39
|
+
!isCustomFieldQueryGroupOperator(value[0]) &&
|
|
40
|
+
typeof value[1] === "string" &&
|
|
41
|
+
isCustomFieldQueryValue(value[2])) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (value.length === 2 &&
|
|
45
|
+
isCustomFieldQueryGroupOperator(value[0]) &&
|
|
46
|
+
Array.isArray(value[1]) &&
|
|
47
|
+
value[1].length >= 1) {
|
|
48
|
+
return value[1].every((item) => isCustomFieldQuery(item));
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
exports.customFieldQuerySchema = zod_1.z
|
|
53
|
+
.array(zod_1.z.unknown())
|
|
54
|
+
.superRefine((value, ctx) => {
|
|
55
|
+
if (!isCustomFieldQuery(value)) {
|
|
56
|
+
ctx.addIssue({
|
|
57
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
58
|
+
message: "Invalid custom_field_query. Use [field_name_or_id, operator, value] or ['AND'|'OR', [clause1, clause2]].",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const paperlessFilterScalarSchema = zod_1.z.union([
|
|
63
|
+
zod_1.z.string(),
|
|
64
|
+
zod_1.z.number(),
|
|
65
|
+
zod_1.z.boolean(),
|
|
66
|
+
]);
|
|
67
|
+
exports.paperlessFilterValueSchema = zod_1.z.union([
|
|
68
|
+
paperlessFilterScalarSchema,
|
|
69
|
+
zod_1.z.array(paperlessFilterScalarSchema),
|
|
70
|
+
]);
|
|
71
|
+
exports.paperlessFiltersSchema = zod_1.z.record(exports.paperlessFilterValueSchema);
|
|
72
|
+
const DOCUMENT_QUERY_BASE_ARGS_SHAPE = {
|
|
73
|
+
page: zod_1.z.number().optional(),
|
|
74
|
+
page_size: zod_1.z.number().optional(),
|
|
75
|
+
search: zod_1.z.string().optional(),
|
|
76
|
+
correspondent: zod_1.z.number().optional(),
|
|
77
|
+
document_type: zod_1.z.number().optional(),
|
|
78
|
+
tag: zod_1.z.number().optional(),
|
|
79
|
+
storage_path: zod_1.z.number().optional(),
|
|
80
|
+
created__date__gte: zod_1.z.string().optional(),
|
|
81
|
+
created__date__lte: zod_1.z.string().optional(),
|
|
82
|
+
ordering: zod_1.z.string().optional(),
|
|
83
|
+
archive_serial_number: zod_1.z.number().optional(),
|
|
84
|
+
archive_serial_number__isnull: zod_1.z.boolean().optional(),
|
|
85
|
+
custom_fields__icontains: zod_1.z.string().min(1).optional(),
|
|
86
|
+
};
|
|
87
|
+
exports.LIST_DOCUMENTS_ARGS_SHAPE = Object.assign(Object.assign({}, DOCUMENT_QUERY_BASE_ARGS_SHAPE), { custom_field_query: zod_1.z.string().min(1).optional() });
|
|
88
|
+
exports.QUERY_DOCUMENTS_ARGS_SHAPE = Object.assign(Object.assign({}, DOCUMENT_QUERY_BASE_ARGS_SHAPE), { query: zod_1.z.string().optional(), more_like_id: zod_1.z.number().optional(), custom_field_query: exports.customFieldQuerySchema
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("Paperless custom field query. Use [field_name_or_id, operator, value] for a single clause or ['AND'|'OR', [clause1, clause2]] for grouped clauses."), paperless_filters: exports.paperlessFiltersSchema
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Additional documented /api/documents/ Paperless filters. Keys must match Paperless query parameter names exactly. Prefer first-class arguments when available.") });
|
|
93
|
+
exports.SEARCH_DOCUMENTS_ARGS_SHAPE = {
|
|
94
|
+
query: zod_1.z.string(),
|
|
95
|
+
};
|
|
96
|
+
const FIRST_CLASS_QUERY_PARAM_MAP = {
|
|
97
|
+
page: "page",
|
|
98
|
+
page_size: "page_size",
|
|
99
|
+
ordering: "ordering",
|
|
100
|
+
query: "query",
|
|
101
|
+
search: "search",
|
|
102
|
+
more_like_id: "more_like_id",
|
|
103
|
+
correspondent: "correspondent__id",
|
|
104
|
+
document_type: "document_type__id",
|
|
105
|
+
tag: "tags__id",
|
|
106
|
+
storage_path: "storage_path__id",
|
|
107
|
+
created__date__gte: "created__date__gte",
|
|
108
|
+
created__date__lte: "created__date__lte",
|
|
109
|
+
archive_serial_number: "archive_serial_number",
|
|
110
|
+
archive_serial_number__isnull: "archive_serial_number__isnull",
|
|
111
|
+
custom_fields__icontains: "custom_fields__icontains",
|
|
112
|
+
};
|
|
113
|
+
// Derived from the documented /api/documents/ query parameters in Paperless_ngx_REST_API.yaml.
|
|
114
|
+
exports.DOCUMENT_QUERY_PAPERLESS_FILTER_KEYS = [
|
|
115
|
+
"added__date__gt",
|
|
116
|
+
"added__date__gte",
|
|
117
|
+
"added__date__lt",
|
|
118
|
+
"added__date__lte",
|
|
119
|
+
"added__day",
|
|
120
|
+
"added__gt",
|
|
121
|
+
"added__gte",
|
|
122
|
+
"added__lt",
|
|
123
|
+
"added__lte",
|
|
124
|
+
"added__month",
|
|
125
|
+
"added__year",
|
|
126
|
+
"archive_serial_number",
|
|
127
|
+
"archive_serial_number__gt",
|
|
128
|
+
"archive_serial_number__gte",
|
|
129
|
+
"archive_serial_number__isnull",
|
|
130
|
+
"archive_serial_number__lt",
|
|
131
|
+
"archive_serial_number__lte",
|
|
132
|
+
"checksum__icontains",
|
|
133
|
+
"checksum__iendswith",
|
|
134
|
+
"checksum__iexact",
|
|
135
|
+
"checksum__istartswith",
|
|
136
|
+
"content__icontains",
|
|
137
|
+
"content__iendswith",
|
|
138
|
+
"content__iexact",
|
|
139
|
+
"content__istartswith",
|
|
140
|
+
"correspondent__id",
|
|
141
|
+
"correspondent__id__in",
|
|
142
|
+
"correspondent__id__none",
|
|
143
|
+
"correspondent__isnull",
|
|
144
|
+
"correspondent__name__icontains",
|
|
145
|
+
"correspondent__name__iendswith",
|
|
146
|
+
"correspondent__name__iexact",
|
|
147
|
+
"correspondent__name__istartswith",
|
|
148
|
+
"created__date__gt",
|
|
149
|
+
"created__date__gte",
|
|
150
|
+
"created__date__lt",
|
|
151
|
+
"created__date__lte",
|
|
152
|
+
"created__day",
|
|
153
|
+
"created__gt",
|
|
154
|
+
"created__gte",
|
|
155
|
+
"created__lt",
|
|
156
|
+
"created__lte",
|
|
157
|
+
"created__month",
|
|
158
|
+
"created__year",
|
|
159
|
+
"custom_field_query",
|
|
160
|
+
"custom_fields__icontains",
|
|
161
|
+
"custom_fields__id__all",
|
|
162
|
+
"custom_fields__id__in",
|
|
163
|
+
"custom_fields__id__none",
|
|
164
|
+
"document_type__id",
|
|
165
|
+
"document_type__id__in",
|
|
166
|
+
"document_type__id__none",
|
|
167
|
+
"document_type__isnull",
|
|
168
|
+
"document_type__name__icontains",
|
|
169
|
+
"document_type__name__iendswith",
|
|
170
|
+
"document_type__name__iexact",
|
|
171
|
+
"document_type__name__istartswith",
|
|
172
|
+
"fields",
|
|
173
|
+
"full_perms",
|
|
174
|
+
"has_custom_fields",
|
|
175
|
+
"id",
|
|
176
|
+
"id__in",
|
|
177
|
+
"is_in_inbox",
|
|
178
|
+
"is_tagged",
|
|
179
|
+
"mime_type",
|
|
180
|
+
"modified__date__gt",
|
|
181
|
+
"modified__date__gte",
|
|
182
|
+
"modified__date__lt",
|
|
183
|
+
"modified__date__lte",
|
|
184
|
+
"modified__day",
|
|
185
|
+
"modified__gt",
|
|
186
|
+
"modified__gte",
|
|
187
|
+
"modified__lt",
|
|
188
|
+
"modified__lte",
|
|
189
|
+
"modified__month",
|
|
190
|
+
"modified__year",
|
|
191
|
+
"ordering",
|
|
192
|
+
"original_filename__icontains",
|
|
193
|
+
"original_filename__iendswith",
|
|
194
|
+
"original_filename__iexact",
|
|
195
|
+
"original_filename__istartswith",
|
|
196
|
+
"owner__id",
|
|
197
|
+
"owner__id__in",
|
|
198
|
+
"owner__id__none",
|
|
199
|
+
"owner__isnull",
|
|
200
|
+
"page",
|
|
201
|
+
"page_size",
|
|
202
|
+
"query",
|
|
203
|
+
"search",
|
|
204
|
+
"shared_by__id",
|
|
205
|
+
"storage_path__id",
|
|
206
|
+
"storage_path__id__in",
|
|
207
|
+
"storage_path__id__none",
|
|
208
|
+
"storage_path__isnull",
|
|
209
|
+
"storage_path__name__icontains",
|
|
210
|
+
"storage_path__name__iendswith",
|
|
211
|
+
"storage_path__name__iexact",
|
|
212
|
+
"storage_path__name__istartswith",
|
|
213
|
+
"tags__id",
|
|
214
|
+
"tags__id__all",
|
|
215
|
+
"tags__id__in",
|
|
216
|
+
"tags__id__none",
|
|
217
|
+
"tags__name__icontains",
|
|
218
|
+
"tags__name__iendswith",
|
|
219
|
+
"tags__name__iexact",
|
|
220
|
+
"tags__name__istartswith",
|
|
221
|
+
"title__icontains",
|
|
222
|
+
"title__iendswith",
|
|
223
|
+
"title__iexact",
|
|
224
|
+
"title__istartswith",
|
|
225
|
+
"title_content",
|
|
226
|
+
];
|
|
227
|
+
const DOCUMENT_QUERY_PAPERLESS_FILTER_KEY_SET = new Set(exports.DOCUMENT_QUERY_PAPERLESS_FILTER_KEYS);
|
|
228
|
+
function hasValue(value) {
|
|
229
|
+
return value !== undefined && value !== null;
|
|
230
|
+
}
|
|
231
|
+
function setQueryParam(query, key, value, jsonEncode = false) {
|
|
232
|
+
if (jsonEncode) {
|
|
233
|
+
query.set(key, JSON.stringify(value));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(value)) {
|
|
237
|
+
query.set(key, value.map((item) => String(item)).join(","));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
query.set(key, String(value));
|
|
241
|
+
}
|
|
242
|
+
function buildDocumentQueryString(args) {
|
|
243
|
+
const query = new URLSearchParams();
|
|
244
|
+
const firstClassKeys = new Set();
|
|
245
|
+
for (const [argName, queryParamName] of Object.entries(FIRST_CLASS_QUERY_PARAM_MAP)) {
|
|
246
|
+
const value = args[argName];
|
|
247
|
+
if (!hasValue(value)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
setQueryParam(query, queryParamName, value);
|
|
251
|
+
firstClassKeys.add(queryParamName);
|
|
252
|
+
}
|
|
253
|
+
if (hasValue(args.custom_field_query)) {
|
|
254
|
+
setQueryParam(query, "custom_field_query", args.custom_field_query, typeof args.custom_field_query !== "string");
|
|
255
|
+
firstClassKeys.add("custom_field_query");
|
|
256
|
+
}
|
|
257
|
+
if (!args.paperless_filters) {
|
|
258
|
+
return query.toString() ? `?${query.toString()}` : "";
|
|
259
|
+
}
|
|
260
|
+
for (const [key, value] of Object.entries(args.paperless_filters)) {
|
|
261
|
+
if (!DOCUMENT_QUERY_PAPERLESS_FILTER_KEY_SET.has(key)) {
|
|
262
|
+
throw new Error(`Unsupported paperless_filters key '${key}'. Use documented /api/documents/ query parameter names only.`);
|
|
263
|
+
}
|
|
264
|
+
if (firstClassKeys.has(key)) {
|
|
265
|
+
throw new Error(`Duplicate filter '${key}' provided both as a first-class argument and in paperless_filters.`);
|
|
266
|
+
}
|
|
267
|
+
setQueryParam(query, key, value);
|
|
268
|
+
}
|
|
269
|
+
return query.toString() ? `?${query.toString()}` : "";
|
|
270
|
+
}
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resource URI builders for document/thumbnail downloads.
|
|
3
3
|
*
|
|
4
|
-
* URIs mirror
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* URIs mirror document resources under a custom `paperless://` scheme, so the
|
|
5
|
+
* same identifiers can back MCP resources (`resources/list` / `resources/read`)
|
|
6
|
+
* without a second naming scheme.
|
|
7
7
|
*
|
|
8
8
|
* The scheme also keeps the URI well-formed regardless of filename
|
|
9
9
|
* content, which Python MCP clients (pydantic-validated) require.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* Optional flags for document download resource URIs.
|
|
13
|
+
*/
|
|
14
|
+
export interface DocumentResourceUriOptions {
|
|
15
|
+
original?: boolean;
|
|
16
|
+
}
|
|
11
17
|
/**
|
|
12
18
|
* Builds a resource URI for a downloaded document.
|
|
13
19
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* that need the human-readable name can still recover it.
|
|
20
|
+
* The MCP resource URI is intentionally canonical and filename-free; filenames
|
|
21
|
+
* belong in resource metadata, while the URI identifies the fetchable content.
|
|
17
22
|
*/
|
|
18
|
-
export declare function buildDocumentResourceUri(id: number,
|
|
23
|
+
export declare function buildDocumentResourceUri(id: number, optionsOrFilename?: DocumentResourceUriOptions | string): string;
|
|
19
24
|
/**
|
|
20
25
|
* Builds a resource URI for a document thumbnail.
|
|
21
26
|
*
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Resource URI builders for document/thumbnail downloads.
|
|
4
4
|
*
|
|
5
|
-
* URIs mirror
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* URIs mirror document resources under a custom `paperless://` scheme, so the
|
|
6
|
+
* same identifiers can back MCP resources (`resources/list` / `resources/read`)
|
|
7
|
+
* without a second naming scheme.
|
|
8
8
|
*
|
|
9
9
|
* The scheme also keeps the URI well-formed regardless of filename
|
|
10
10
|
* content, which Python MCP clients (pydantic-validated) require.
|
|
@@ -15,12 +15,17 @@ exports.buildThumbnailResourceUri = buildThumbnailResourceUri;
|
|
|
15
15
|
/**
|
|
16
16
|
* Builds a resource URI for a downloaded document.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* that need the human-readable name can still recover it.
|
|
18
|
+
* The MCP resource URI is intentionally canonical and filename-free; filenames
|
|
19
|
+
* belong in resource metadata, while the URI identifies the fetchable content.
|
|
21
20
|
*/
|
|
22
|
-
function buildDocumentResourceUri(id,
|
|
23
|
-
|
|
21
|
+
function buildDocumentResourceUri(id, optionsOrFilename) {
|
|
22
|
+
const options = typeof optionsOrFilename === "string" ? {} : optionsOrFilename || {};
|
|
23
|
+
const params = new URLSearchParams();
|
|
24
|
+
if (options.original) {
|
|
25
|
+
params.set("original", "true");
|
|
26
|
+
}
|
|
27
|
+
const query = params.toString();
|
|
28
|
+
return `paperless://documents/${id}/download${query ? `?${query}` : ""}`;
|
|
24
29
|
}
|
|
25
30
|
/**
|
|
26
31
|
* Builds a resource URI for a document thumbnail.
|
|
@@ -10,32 +10,32 @@ const resourceUri_1 = require("./resourceUri");
|
|
|
10
10
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "doc.pdf");
|
|
11
11
|
strict_1.default.match(uri, /^paperless:\/\/documents\/1\/download(\?|$)/);
|
|
12
12
|
});
|
|
13
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
13
|
+
(0, node_test_1.test)("buildDocumentResourceUri is canonical without filenames", () => {
|
|
14
14
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "invoice 2026.pdf");
|
|
15
|
-
strict_1.default.equal(uri, "paperless://documents/1/download
|
|
15
|
+
strict_1.default.equal(uri, "paperless://documents/1/download");
|
|
16
16
|
});
|
|
17
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
17
|
+
(0, node_test_1.test)("buildDocumentResourceUri ignores reserved chars in legacy filename input", () => {
|
|
18
18
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(42, "weird ?#&=+name.pdf");
|
|
19
19
|
const url = new URL(uri);
|
|
20
|
-
// The path stays canonical — only the literal `?` that separates
|
|
21
|
-
// path from query is allowed; nothing from the filename should
|
|
22
|
-
// bleed into the path or break query parsing.
|
|
23
20
|
strict_1.default.equal(url.pathname, "/42/download");
|
|
24
|
-
strict_1.default.equal(url.
|
|
21
|
+
strict_1.default.equal(url.search, "");
|
|
25
22
|
});
|
|
26
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
23
|
+
(0, node_test_1.test)("buildDocumentResourceUri ignores path separators in legacy filename input", () => {
|
|
27
24
|
// A filename containing a slash must not become an extra path segment.
|
|
28
25
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(7, "sub/dir/file.pdf");
|
|
29
26
|
const url = new URL(uri);
|
|
30
27
|
strict_1.default.equal(url.pathname, "/7/download");
|
|
31
|
-
strict_1.default.equal(url.
|
|
28
|
+
strict_1.default.equal(url.search, "");
|
|
32
29
|
});
|
|
33
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
30
|
+
(0, node_test_1.test)("buildDocumentResourceUri keeps unicode filenames out of the URI", () => {
|
|
34
31
|
const original = "Rechnüng — März.pdf";
|
|
35
32
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, original);
|
|
36
|
-
// Roundtrip via URL parsing should recover the original name.
|
|
37
33
|
const url = new URL(uri);
|
|
38
|
-
strict_1.default.equal(url.
|
|
34
|
+
strict_1.default.equal(url.pathname, "/1/download");
|
|
35
|
+
strict_1.default.equal(url.search, "");
|
|
36
|
+
});
|
|
37
|
+
(0, node_test_1.test)("buildDocumentResourceUri preserves original download intent", () => {
|
|
38
|
+
strict_1.default.equal((0, resourceUri_1.buildDocumentResourceUri)(1, { original: true }), "paperless://documents/1/download?original=true");
|
|
39
39
|
});
|
|
40
40
|
(0, node_test_1.test)("buildDocumentResourceUri produces a valid URL", () => {
|
|
41
41
|
const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "Some File.pdf");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PaperlessAPI } from "../../api/PaperlessAPI";
|
|
2
|
+
import { CustomField, CustomFieldInstanceRequest, CustomFieldValue } from "../../api/types";
|
|
3
|
+
/**
|
|
4
|
+
* Encoding Paperless expects for a select value: `update_document` takes the
|
|
5
|
+
* option index; `bulk_edit` writes `value_select` directly so it needs the
|
|
6
|
+
* stored form (option id on 2.17+, index on pre-2.17 string options).
|
|
7
|
+
*/
|
|
8
|
+
export type SelectValueEncoding = "index" | "stored";
|
|
9
|
+
/** Translates a select value (label, option id, or index) to the `encoding` Paperless expects; throws on no match. */
|
|
10
|
+
export declare function resolveSelectCustomFieldValue(field: CustomField, value: CustomFieldValue, encoding?: SelectValueEncoding): CustomFieldValue;
|
|
11
|
+
export declare function resolveSelectCustomFieldValues(api: PaperlessAPI, customFields: CustomFieldInstanceRequest[] | undefined, encoding?: SelectValueEncoding): Promise<CustomFieldInstanceRequest[] | undefined>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.resolveSelectCustomFieldValue = resolveSelectCustomFieldValue;
|
|
13
|
+
exports.resolveSelectCustomFieldValues = resolveSelectCustomFieldValues;
|
|
14
|
+
function normalizeSelectOptions(field) {
|
|
15
|
+
var _a;
|
|
16
|
+
const rawOptions = (_a = field.extra_data) === null || _a === void 0 ? void 0 : _a.select_options;
|
|
17
|
+
if (!Array.isArray(rawOptions)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
return rawOptions.map((option, index) => {
|
|
21
|
+
if (option && typeof option === "object") {
|
|
22
|
+
const { id, label } = option;
|
|
23
|
+
return {
|
|
24
|
+
index,
|
|
25
|
+
label: typeof label === "string" ? label : String(label),
|
|
26
|
+
id: typeof id === "string" ? id : undefined,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { index, label: String(option) };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function findSelectOption(options, value) {
|
|
33
|
+
if (typeof value === "string") {
|
|
34
|
+
const byLabel = options.find((option) => option.label === value);
|
|
35
|
+
const byId = options.find((option) => option.id === value);
|
|
36
|
+
if (byLabel && byId && byLabel.index !== byId.index) {
|
|
37
|
+
throw new Error(`Ambiguous select value ${JSON.stringify(value)}: it matches one ` +
|
|
38
|
+
`option's label and a different option's id.`);
|
|
39
|
+
}
|
|
40
|
+
return byLabel !== null && byLabel !== void 0 ? byLabel : byId;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
43
|
+
return options.find((option) => option.index === value);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Translates a select value (label, option id, or index) to the `encoding` Paperless expects; throws on no match. */
|
|
48
|
+
function resolveSelectCustomFieldValue(field, value, encoding = "index") {
|
|
49
|
+
var _a;
|
|
50
|
+
if (field.data_type !== "select" || value === null) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
const options = normalizeSelectOptions(field);
|
|
54
|
+
if (options.length === 0) {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
const option = findSelectOption(options, value);
|
|
58
|
+
if (!option) {
|
|
59
|
+
const optionList = options
|
|
60
|
+
.map((o) => `${JSON.stringify(o.label)} (index ${o.index})`)
|
|
61
|
+
.join(", ");
|
|
62
|
+
throw new Error(`Invalid value ${JSON.stringify(value)} for select custom field ` +
|
|
63
|
+
`"${field.name}" (id ${field.id}). Pass one of the option labels. ` +
|
|
64
|
+
`Valid options: ${optionList}.`);
|
|
65
|
+
}
|
|
66
|
+
return encoding === "stored" ? (_a = option.id) !== null && _a !== void 0 ? _a : option.index : option.index;
|
|
67
|
+
}
|
|
68
|
+
function resolveSelectCustomFieldValues(api_1, customFields_1) {
|
|
69
|
+
return __awaiter(this, arguments, void 0, function* (api, customFields, encoding = "index") {
|
|
70
|
+
if (!customFields || customFields.length === 0) {
|
|
71
|
+
return customFields;
|
|
72
|
+
}
|
|
73
|
+
const uniqueFieldIds = [...new Set(customFields.map((cf) => cf.field))];
|
|
74
|
+
const definitions = new Map();
|
|
75
|
+
yield Promise.all(uniqueFieldIds.map((id) => __awaiter(this, void 0, void 0, function* () {
|
|
76
|
+
try {
|
|
77
|
+
definitions.set(id, yield api.getCustomField(id));
|
|
78
|
+
}
|
|
79
|
+
catch (_a) {
|
|
80
|
+
// Definition unavailable; leave the value for Paperless to validate.
|
|
81
|
+
}
|
|
82
|
+
})));
|
|
83
|
+
return customFields.map((cf) => {
|
|
84
|
+
const field = definitions.get(cf.field);
|
|
85
|
+
if (!field || field.data_type !== "select") {
|
|
86
|
+
return cf;
|
|
87
|
+
}
|
|
88
|
+
return Object.assign(Object.assign({}, cf), { value: resolveSelectCustomFieldValue(field, cf.value, encoding) });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
const node_test_1 = require("node:test");
|
|
16
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
17
|
+
const selectFields_1 = require("./selectFields");
|
|
18
|
+
const LEGACY_SELECT_FIELD = {
|
|
19
|
+
id: 2,
|
|
20
|
+
name: "Retention period",
|
|
21
|
+
data_type: "select",
|
|
22
|
+
extra_data: { select_options: ["1 year", "7 years", "2 years"], default_currency: null },
|
|
23
|
+
document_count: 10,
|
|
24
|
+
};
|
|
25
|
+
const OBJECT_SELECT_FIELD = {
|
|
26
|
+
id: 3,
|
|
27
|
+
name: "Priority",
|
|
28
|
+
data_type: "select",
|
|
29
|
+
extra_data: {
|
|
30
|
+
select_options: [
|
|
31
|
+
{ id: "abc123", label: "Low" },
|
|
32
|
+
{ id: "def456", label: "High" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
document_count: 5,
|
|
36
|
+
};
|
|
37
|
+
const STRING_FIELD = {
|
|
38
|
+
id: 4,
|
|
39
|
+
name: "Reference",
|
|
40
|
+
data_type: "string",
|
|
41
|
+
document_count: 1,
|
|
42
|
+
};
|
|
43
|
+
(0, node_test_1.describe)("resolveSelectCustomFieldValue", () => {
|
|
44
|
+
(0, node_test_1.test)("translates a label to its zero-based index (pre-2.17 string options)", () => {
|
|
45
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, "1 year"), 0);
|
|
46
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, "7 years"), 1);
|
|
47
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, "2 years"), 2);
|
|
48
|
+
});
|
|
49
|
+
(0, node_test_1.test)("translates a label to its zero-based index (2.17+ object options)", () => {
|
|
50
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "Low"), 0);
|
|
51
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "High"), 1);
|
|
52
|
+
});
|
|
53
|
+
(0, node_test_1.test)("passes through an already-encoded index (pre-2.17)", () => {
|
|
54
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, 1), 1);
|
|
55
|
+
});
|
|
56
|
+
(0, node_test_1.test)("maps an option id back to its zero-based index (2.17+ round-trip)", () => {
|
|
57
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "def456"), 1);
|
|
58
|
+
});
|
|
59
|
+
(0, node_test_1.test)("resolves both the label and the option id to the same index", () => {
|
|
60
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "Low"), 0);
|
|
61
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "abc123"), 0);
|
|
62
|
+
});
|
|
63
|
+
(0, node_test_1.test)("returns null unchanged so the field can be cleared", () => {
|
|
64
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, null), null);
|
|
65
|
+
});
|
|
66
|
+
(0, node_test_1.test)("leaves non-select field values untouched", () => {
|
|
67
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(STRING_FIELD, "1 year"), "1 year");
|
|
68
|
+
});
|
|
69
|
+
(0, node_test_1.test)("throws an actionable error listing valid options for an unknown value", () => {
|
|
70
|
+
strict_1.default.throws(() => (0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, "forever"), (err) => {
|
|
71
|
+
strict_1.default.match(err.message, /forever/);
|
|
72
|
+
strict_1.default.match(err.message, /Retention period/);
|
|
73
|
+
strict_1.default.match(err.message, /1 year/);
|
|
74
|
+
strict_1.default.match(err.message, /7 years/);
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
(0, node_test_1.test)("rejects an out-of-range index rather than forwarding it", () => {
|
|
79
|
+
strict_1.default.throws(() => (0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, 9));
|
|
80
|
+
});
|
|
81
|
+
(0, node_test_1.test)("throws on a value that matches one option's label and another's id", () => {
|
|
82
|
+
const collidingField = {
|
|
83
|
+
id: 5,
|
|
84
|
+
name: "Collision",
|
|
85
|
+
data_type: "select",
|
|
86
|
+
extra_data: {
|
|
87
|
+
select_options: [
|
|
88
|
+
{ id: "High", label: "Low" },
|
|
89
|
+
{ id: "xyz789", label: "High" },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
document_count: 0,
|
|
93
|
+
};
|
|
94
|
+
strict_1.default.throws(() => (0, selectFields_1.resolveSelectCustomFieldValue)(collidingField, "High"), /Ambiguous/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
(0, node_test_1.describe)("resolveSelectCustomFieldValue with stored encoding (bulk_edit path)", () => {
|
|
98
|
+
(0, node_test_1.test)("translates a label to the option id on 2.17+ object options", () => {
|
|
99
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "Low", "stored"), "abc123");
|
|
100
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "High", "stored"), "def456");
|
|
101
|
+
});
|
|
102
|
+
(0, node_test_1.test)("translates a label to the index on pre-2.17 string options (no id to store)", () => {
|
|
103
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(LEGACY_SELECT_FIELD, "7 years", "stored"), 1);
|
|
104
|
+
});
|
|
105
|
+
(0, node_test_1.test)("passes an already-stored option id through unchanged", () => {
|
|
106
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, "def456", "stored"), "def456");
|
|
107
|
+
});
|
|
108
|
+
(0, node_test_1.test)("maps an option index to its stored id (2.17+)", () => {
|
|
109
|
+
strict_1.default.equal((0, selectFields_1.resolveSelectCustomFieldValue)(OBJECT_SELECT_FIELD, 1, "stored"), "def456");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
function apiReturning(fields) {
|
|
113
|
+
const requestedIds = [];
|
|
114
|
+
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
|
115
|
+
const api = {
|
|
116
|
+
getCustomField: (id) => __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
requestedIds.push(id);
|
|
118
|
+
const field = fieldMap.get(id);
|
|
119
|
+
if (!field)
|
|
120
|
+
throw new Error(`custom field ${id} not found`);
|
|
121
|
+
return field;
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
return { api, requestedIds };
|
|
125
|
+
}
|
|
126
|
+
(0, node_test_1.describe)("resolveSelectCustomFieldValues", () => {
|
|
127
|
+
(0, node_test_1.test)("returns undefined/empty input unchanged without fetching definitions", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
128
|
+
const { api, requestedIds } = apiReturning([]);
|
|
129
|
+
strict_1.default.equal(yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, undefined), undefined);
|
|
130
|
+
strict_1.default.deepEqual(yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, []), []);
|
|
131
|
+
strict_1.default.deepEqual(requestedIds, []);
|
|
132
|
+
}));
|
|
133
|
+
(0, node_test_1.test)("resolves select labels and leaves other fields untouched", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
134
|
+
const { api } = apiReturning([LEGACY_SELECT_FIELD, STRING_FIELD]);
|
|
135
|
+
const resolved = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, [
|
|
136
|
+
{ field: 2, value: "2 years" },
|
|
137
|
+
{ field: 4, value: "INV-001" },
|
|
138
|
+
]);
|
|
139
|
+
strict_1.default.deepEqual(resolved, [
|
|
140
|
+
{ field: 2, value: 2 },
|
|
141
|
+
{ field: 4, value: "INV-001" },
|
|
142
|
+
]);
|
|
143
|
+
}));
|
|
144
|
+
(0, node_test_1.test)("applies the stored encoding (option id) when requested", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
145
|
+
const { api } = apiReturning([OBJECT_SELECT_FIELD]);
|
|
146
|
+
const resolved = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, [{ field: 3, value: "High" }], "stored");
|
|
147
|
+
strict_1.default.deepEqual(resolved, [{ field: 3, value: "def456" }]);
|
|
148
|
+
}));
|
|
149
|
+
(0, node_test_1.test)("fetches each referenced field definition only once", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
150
|
+
const { api, requestedIds } = apiReturning([LEGACY_SELECT_FIELD]);
|
|
151
|
+
yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, [
|
|
152
|
+
{ field: 2, value: "1 year" },
|
|
153
|
+
{ field: 2, value: "7 years" },
|
|
154
|
+
]);
|
|
155
|
+
strict_1.default.deepEqual(requestedIds, [2]);
|
|
156
|
+
}));
|
|
157
|
+
(0, node_test_1.test)("passes the value through when the field definition cannot be fetched", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
const { api } = apiReturning([]);
|
|
159
|
+
const resolved = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, [
|
|
160
|
+
{ field: 99, value: "whatever" },
|
|
161
|
+
]);
|
|
162
|
+
strict_1.default.deepEqual(resolved, [{ field: 99, value: "whatever" }]);
|
|
163
|
+
}));
|
|
164
|
+
});
|