feat(documents): pick documents from Paperless-ngx or Papra in the vault and wherever documents are linked

A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:26:18 +02:00
1 parent 92f2cc729f
commit d109d67889
21 files changed
+2200 -20

No files matched your search

@@ -0,0 +1,81 @@
/**
* The document picker's error codes (#1038), as sentences. One place for the
* sheet and the settings card, so the same refusal reads the same on both.
* Every branch names its key literally so the i18n coverage guard can see it.
*/
import { ApiError } from "@/lib/api/api-fetch";
type T = (key: string, params?: Record<string, string | number>) => string;
/** The `meta.errorCode` a failed request carried, if any. */
export function errorCodeOf(err: unknown): string | undefined {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (typeof code === "string") return code;
}
return undefined;
}
/** Is this a refusal that will not change on the next document? */
export function isStoppingError(code: string | undefined): boolean {
return (
code === "documents.inbound.rateLimited" ||
code === "documents.inbound.quotaExceeded" ||
code === "documents.sources.authRefused" ||
code === "documents.sources.unreachable" ||
code === "documents.sources.originNotAllowed" ||
code === "documents.sources.notConnected" ||
code === "documents.sources.unavailable" ||
code === "documents.sources.browserOnly" ||
code === "documents.sources.linkTargetNotFound"
);
}
export function sourceErrorMessage(t: T, code: string | undefined): string {
switch (code) {
case "documents.sources.unavailable":
return t("documents.sourcePicker.errors.unavailable");
case "documents.sources.notConnected":
return t("documents.sourcePicker.errors.notConnected");
case "documents.sources.originNotAllowed":
return t("documents.sourcePicker.errors.originNotAllowed");
case "documents.sources.unreachable":
return t("documents.sourcePicker.errors.unreachable");
case "documents.sources.redirected":
return t("documents.sourcePicker.errors.redirected");
case "documents.sources.authRefused":
return t("documents.sourcePicker.errors.authRefused");
case "documents.sources.versionTooOld":
return t("documents.sourcePicker.errors.versionTooOld");
case "documents.sources.badResponse":
return t("documents.sourcePicker.errors.badResponse");
case "documents.sources.notFound":
return t("documents.sourcePicker.errors.notFound");
case "documents.sources.linkTargetNotFound":
return t("documents.sourcePicker.errors.linkTargetNotFound");
case "documents.sources.rateLimited":
return t("documents.sourcePicker.errors.rateLimited");
case "documents.sources.browserOnly":
return t("documents.sourcePicker.errors.browserOnly");
case "documents.sources.invalidAddress":
return t("documents.sourcePicker.errors.invalidAddress");
case "documents.sources.organizationRequired":
return t("documents.sourcePicker.errors.organizationRequired");
case "documents.sources.tokenRequired":
return t("documents.sourcePicker.errors.tokenRequired");
case "documents.inbound.fileTooLarge":
return t("documents.sourcePicker.errors.fileTooLarge");
case "documents.inbound.quotaExceeded":
return t("documents.sourcePicker.errors.quotaExceeded");
case "documents.inbound.fileType":
return t("documents.sourcePicker.errors.fileType");
case "documents.inbound.rateLimited":
return t("documents.sourcePicker.errors.uploadLimit");
case "documents.inbound.uploadBusy":
return t("documents.sourcePicker.errors.uploadBusy");
case "module.disabled":
return t("documents.sourcePicker.errors.moduleDisabled");
default:
return t("documents.sourcePicker.errors.generic");
}
}