diff --git a/messages/de.json b/messages/de.json
index 06801e37e..b0b80d39f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Checkliste wieder anzeigen",
"checklistShown": "Die Checkliste ist wieder auf dem Dashboard.",
"checklistNothingHidden": "Die Checkliste war nicht ausgeblendet; sie öffnet sich auf dem Dashboard."
+ },
+ "documentSources": {
+ "title": "Dokumentarchive",
+ "description": "Paperless-ngx oder Papra aus HealthLog durchsuchen und ausgewählte Dokumente importieren.",
+ "explainer": "Ein Import-Knopf erscheint überall, wo du Dokumente verknüpfst: bei Arztbesuchen, Impfungen und Erkrankungen sowie unter Dokumente. HealthLog speichert von jedem importierten Dokument eine eigene verschlüsselte Kopie und fragt dein Archiv nur, wenn du suchst oder importierst.",
+ "systemLabel": "Archiv",
+ "statusConnected": "Verbunden",
+ "statusNotConnected": "Nicht verbunden",
+ "connectedSince": "Verbunden, zuletzt geprüft am {date}",
+ "originRemoved": "Diese Adresse steht nicht mehr auf der Liste der erlaubten Archive dieses Servers. Frag die Administration oder speichere eine andere Adresse.",
+ "baseUrl": "Adresse",
+ "allowedOrigins": "Auf diesem Server erlaubt: {origins}",
+ "organizationId": "Organisations-ID",
+ "organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra.",
+ "token": "API-Token",
+ "tokenSaved": "Gespeichert. Leer lassen, um es zu behalten.",
+ "tokenHelpPaperless": "In Paperless-ngx: Mein Profil, API-Token.",
+ "tokenHelpPapra": "In Papra: ein API-Schlüssel mit documents:read und tags:read.",
+ "save": "Speichern und testen",
+ "saving": "Wird getestet …",
+ "saved": "Verbunden. Das Archiv hat geantwortet.",
+ "test": "Testen",
+ "testOk": "Das Archiv hat in {latency} ms geantwortet.",
+ "disconnect": "Trennen",
+ "disconnectTitle": "{name} trennen?",
+ "disconnectDescription": "HealthLog vergisst Adresse und Token. Bereits importierte Dokumente bleiben in HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "Beim Lesen mit KI geht dies an deinen KI-Dienst außerhalb dieses Servers. Darf HealthLog Dokumente, Laborbefunde und Medikamentenbeschreibungen zum Lesen senden?",
"grant": "Lesen erlauben",
"error": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuch es erneut."
+ },
+ "sourcePicker": {
+ "open": "Aus Archiv importieren",
+ "openNamed": "Aus {name} importieren",
+ "title": "Aus {name} importieren",
+ "description": "Nach Namen suchen, dann die Dokumente anhaken, die HealthLog übernehmen soll.",
+ "sourceLabel": "Archiv",
+ "searchLabel": "Dokumentname",
+ "searchPlaceholder": "Nach Namen suchen",
+ "tagLabel": "Tag",
+ "tagAny": "Alle Tags",
+ "fromLabel": "Von",
+ "toLabel": "Bis",
+ "clearFilters": "Tag und Datum zurücksetzen",
+ "rangeInvalid": "Das Enddatum liegt vor dem Startdatum.",
+ "empty": "{name} hat keine Dokumente zum Anzeigen.",
+ "noMatch": "Keine passenden Dokumente.",
+ "loadMore": "Mehr laden",
+ "stateImported": "In HealthLog",
+ "stateDeleted": "In HealthLog gelöscht",
+ "selectedCount": "{count} von höchstens {max} ausgewählt",
+ "importAction": "{count} importieren",
+ "importing": "Import läuft, ein Dokument nach dem anderen …",
+ "done": "Fertig",
+ "summary": "Importiert: {imported}. Schon vorhanden: {present}. Nicht importiert: {failed}.",
+ "stopped": "Abgebrochen: {reason}",
+ "result": {
+ "waiting": "Wartet",
+ "working": "Wird importiert …",
+ "imported": "Importiert",
+ "importedLinked": "Importiert und verknüpft",
+ "duplicate": "Schon in HealthLog",
+ "duplicateLinked": "Schon in HealthLog, jetzt verknüpft",
+ "deleted": "In HealthLog gelöscht, bleibt gelöscht"
+ },
+ "errors": {
+ "unavailable": "Dokumentarchive sind auf diesem Server nicht freigeschaltet.",
+ "notConnected": "Dieses Archiv ist nicht verbunden. Verbinde es unter Einstellungen, Integrationen.",
+ "originNotAllowed": "Diese Adresse steht nicht auf der Liste der erlaubten Archive dieses Servers. Bitte die Administration, sie aufzunehmen.",
+ "unreachable": "Das Archiv war nicht erreichbar. Prüfe, ob es läuft und die Adresse stimmt.",
+ "redirected": "Das Archiv hat die Anfrage umgeleitet. Trage die Adresse ein, auf die es umleitet.",
+ "authRefused": "Das Archiv hat das Token abgelehnt. Prüfe das Token und seine Berechtigungen.",
+ "versionTooOld": "Das Archiv ist zu alt. Nötig ist Paperless-ngx 2.16 oder neuer.",
+ "badResponse": "Die Antwort des Archivs ließ sich nicht lesen. Prüfe, ob die Adresse auf das Archiv selbst zeigt.",
+ "notFound": "Das Archiv kennt dieses Dokument oder diese Organisation nicht.",
+ "linkTargetNotFound": "Der Eintrag, mit dem verknüpft werden sollte, existiert nicht mehr.",
+ "rateLimited": "Zu viele Anfragen. Warte eine Minute und versuche es erneut.",
+ "browserOnly": "Dokumentarchive lassen sich nur im Browser verwenden.",
+ "invalidAddress": "Gib die vollständige Adresse ein, beginnend mit https:// oder http://.",
+ "organizationRequired": "Gib die Organisations-ID aus Papra ein.",
+ "tokenRequired": "Gib das API-Token ein.",
+ "fileTooLarge": "Die Datei ist größer, als HealthLog annimmt.",
+ "quotaExceeded": "Dein Dokumentenspeicher ist voll.",
+ "fileType": "HealthLog nimmt diesen Dateityp nicht an.",
+ "uploadLimit": "Das stündliche Upload-Limit ist erreicht. Versuche es später erneut.",
+ "uploadBusy": "Andere Uploads laufen noch. Versuche es gleich noch einmal.",
+ "moduleDisabled": "Dokumente sind in den Einstellungen ausgeschaltet.",
+ "generic": "Etwas ist schiefgelaufen. Versuche es erneut."
+ }
}
},
"selfDescription": {
diff --git a/messages/en.json b/messages/en.json
index fa1fb5458..324370d50 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Show the checklist again",
"checklistShown": "The checklist is back on the dashboard.",
"checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard."
+ },
+ "documentSources": {
+ "title": "Document archives",
+ "description": "Search Paperless-ngx or Papra from HealthLog and import the documents you pick.",
+ "explainer": "An import button appears wherever you link documents: on a visit, a vaccination and a condition, and in Documents. HealthLog keeps its own encrypted copy of each document you import, and asks your archive only when you search or import.",
+ "systemLabel": "Archive",
+ "statusConnected": "Connected",
+ "statusNotConnected": "Not connected",
+ "connectedSince": "Connected, last checked {date}",
+ "originRemoved": "This address is no longer on the server's list of allowed archives. Ask the administrator, or save another address.",
+ "baseUrl": "Address",
+ "allowedOrigins": "Allowed on this server: {origins}",
+ "organizationId": "Organization id",
+ "organizationHelp": "The part after /organizations/ in the Papra address bar.",
+ "token": "API token",
+ "tokenSaved": "Saved. Leave empty to keep it.",
+ "tokenHelpPaperless": "In Paperless-ngx: My Profile, API token.",
+ "tokenHelpPapra": "In Papra: an API key with documents:read and tags:read.",
+ "save": "Save and test",
+ "saving": "Testing…",
+ "saved": "Connected. The archive answered.",
+ "test": "Test",
+ "testOk": "The archive answered in {latency} ms.",
+ "disconnect": "Disconnect",
+ "disconnectTitle": "Disconnect {name}?",
+ "disconnectDescription": "HealthLog forgets the address and the token. Documents you already imported stay in HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "Reading with AI sends this to your AI service outside this server. Allow HealthLog to send documents, lab reports and medication descriptions for reading?",
"grant": "Allow reading",
"error": "The consent could not be saved. Please try again."
+ },
+ "sourcePicker": {
+ "open": "Import from archive",
+ "openNamed": "Import from {name}",
+ "title": "Import from {name}",
+ "description": "Search by name, then tick the documents HealthLog should copy.",
+ "sourceLabel": "Archive",
+ "searchLabel": "Document name",
+ "searchPlaceholder": "Search by name",
+ "tagLabel": "Tag",
+ "tagAny": "Any tag",
+ "fromLabel": "From",
+ "toLabel": "To",
+ "clearFilters": "Clear tag and dates",
+ "rangeInvalid": "The end date is before the start date.",
+ "empty": "{name} has no documents to show.",
+ "noMatch": "No documents match.",
+ "loadMore": "Load more",
+ "stateImported": "In HealthLog",
+ "stateDeleted": "Deleted in HealthLog",
+ "selectedCount": "{count} of up to {max} selected",
+ "importAction": "Import {count}",
+ "importing": "Importing, one document at a time…",
+ "done": "Done",
+ "summary": "Imported: {imported}. Already there: {present}. Not imported: {failed}.",
+ "stopped": "Stopped: {reason}",
+ "result": {
+ "waiting": "Waiting",
+ "working": "Importing…",
+ "imported": "Imported",
+ "importedLinked": "Imported and linked",
+ "duplicate": "Already in HealthLog",
+ "duplicateLinked": "Already in HealthLog, now linked",
+ "deleted": "Deleted in HealthLog, left alone"
+ },
+ "errors": {
+ "unavailable": "Document archives are not enabled on this server.",
+ "notConnected": "This archive is not connected. Connect it under Settings, Integrations.",
+ "originNotAllowed": "This address is not on the server's list of allowed archives. Ask the administrator to add it.",
+ "unreachable": "The archive could not be reached. Check that it is running and that the address is right.",
+ "redirected": "The archive redirected the request. Enter the address it redirects to.",
+ "authRefused": "The archive refused the token. Check the token and its permissions.",
+ "versionTooOld": "The archive is too old. Paperless-ngx 2.16 or later is needed.",
+ "badResponse": "The archive's answer could not be read. Check that the address points at the archive itself.",
+ "notFound": "The archive has no such document, or no such organization.",
+ "linkTargetNotFound": "The record to link to no longer exists.",
+ "rateLimited": "Too many requests. Wait a minute and try again.",
+ "browserOnly": "Document archives can only be used in the browser.",
+ "invalidAddress": "Enter the full address, starting with https:// or http://.",
+ "organizationRequired": "Enter the Papra organization id.",
+ "tokenRequired": "Enter the API token.",
+ "fileTooLarge": "The file is larger than HealthLog accepts.",
+ "quotaExceeded": "Your document storage is full.",
+ "fileType": "HealthLog does not accept this file type.",
+ "uploadLimit": "The hourly upload limit is reached. Try again later.",
+ "uploadBusy": "Other uploads are still running. Try again in a moment.",
+ "moduleDisabled": "Documents are switched off in Settings.",
+ "generic": "Something went wrong. Try again."
+ }
}
},
"selfDescription": {
diff --git a/messages/es.json b/messages/es.json
index 46f903102..f367a4433 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Mostrar la lista de nuevo",
"checklistShown": "La lista vuelve a estar en el panel.",
"checklistNothingHidden": "La lista no estaba oculta; se abre en el panel."
+ },
+ "documentSources": {
+ "title": "Archivos de documentos",
+ "description": "Busca en Paperless-ngx o Papra desde HealthLog e importa los documentos que elijas.",
+ "explainer": "Aparece un botón de importar allí donde vinculas documentos: en visitas, vacunas y afecciones, y en Documentos. HealthLog guarda su propia copia cifrada de cada documento importado y solo consulta tu archivo cuando buscas o importas.",
+ "systemLabel": "Archivo",
+ "statusConnected": "Conectado",
+ "statusNotConnected": "No conectado",
+ "connectedSince": "Conectado, última comprobación el {date}",
+ "originRemoved": "Esta dirección ya no está en la lista de archivos permitidos del servidor. Consulta a la administración o guarda otra dirección.",
+ "baseUrl": "Dirección",
+ "allowedOrigins": "Permitido en este servidor: {origins}",
+ "organizationId": "Id de organización",
+ "organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra.",
+ "token": "Token de la API",
+ "tokenSaved": "Guardado. Déjalo vacío para conservarlo.",
+ "tokenHelpPaperless": "En Paperless-ngx: Mi perfil, token de la API.",
+ "tokenHelpPapra": "En Papra: una clave de API con documents:read y tags:read.",
+ "save": "Guardar y probar",
+ "saving": "Probando…",
+ "saved": "Conectado. El archivo respondió.",
+ "test": "Probar",
+ "testOk": "El archivo respondió en {latency} ms.",
+ "disconnect": "Desconectar",
+ "disconnectTitle": "¿Desconectar {name}?",
+ "disconnectDescription": "HealthLog olvida la dirección y el token. Los documentos ya importados se quedan en HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "Leer con IA envía esto a tu servicio de IA fuera de este servidor. ¿Permitir que HealthLog envíe documentos, informes de laboratorio y descripciones de medicamentos para leerlos?",
"grant": "Permitir lectura",
"error": "No se pudo guardar el consentimiento. Inténtalo de nuevo."
+ },
+ "sourcePicker": {
+ "open": "Importar desde archivo",
+ "openNamed": "Importar desde {name}",
+ "title": "Importar desde {name}",
+ "description": "Busca por nombre y marca los documentos que HealthLog debe copiar.",
+ "sourceLabel": "Archivo",
+ "searchLabel": "Nombre del documento",
+ "searchPlaceholder": "Buscar por nombre",
+ "tagLabel": "Etiqueta",
+ "tagAny": "Cualquier etiqueta",
+ "fromLabel": "Desde",
+ "toLabel": "Hasta",
+ "clearFilters": "Quitar etiqueta y fechas",
+ "rangeInvalid": "La fecha final es anterior a la inicial.",
+ "empty": "{name} no tiene documentos que mostrar.",
+ "noMatch": "Ningún documento coincide.",
+ "loadMore": "Cargar más",
+ "stateImported": "En HealthLog",
+ "stateDeleted": "Eliminado en HealthLog",
+ "selectedCount": "{count} de un máximo de {max} seleccionados",
+ "importAction": "Importar {count}",
+ "importing": "Importando, un documento tras otro…",
+ "done": "Hecho",
+ "summary": "Importados: {imported}. Ya estaban: {present}. No importados: {failed}.",
+ "stopped": "Detenido: {reason}",
+ "result": {
+ "waiting": "En espera",
+ "working": "Importando…",
+ "imported": "Importado",
+ "importedLinked": "Importado y vinculado",
+ "duplicate": "Ya está en HealthLog",
+ "duplicateLinked": "Ya está en HealthLog, ahora vinculado",
+ "deleted": "Eliminado en HealthLog, se deja así"
+ },
+ "errors": {
+ "unavailable": "Los archivos de documentos no están habilitados en este servidor.",
+ "notConnected": "Este archivo no está conectado. Conéctalo en Ajustes, Integraciones.",
+ "originNotAllowed": "Esta dirección no está en la lista de archivos permitidos del servidor. Pide a la administración que la añada.",
+ "unreachable": "No se pudo acceder al archivo. Comprueba que está en marcha y que la dirección es correcta.",
+ "redirected": "El archivo redirigió la solicitud. Introduce la dirección a la que redirige.",
+ "authRefused": "El archivo rechazó el token. Comprueba el token y sus permisos.",
+ "versionTooOld": "El archivo es demasiado antiguo. Se necesita Paperless-ngx 2.16 o posterior.",
+ "badResponse": "No se pudo leer la respuesta del archivo. Comprueba que la dirección apunta al propio archivo.",
+ "notFound": "El archivo no tiene ese documento o esa organización.",
+ "linkTargetNotFound": "El registro que se iba a vincular ya no existe.",
+ "rateLimited": "Demasiadas solicitudes. Espera un minuto y vuelve a intentarlo.",
+ "browserOnly": "Los archivos de documentos solo se pueden usar en el navegador.",
+ "invalidAddress": "Introduce la dirección completa, empezando por https:// o http://.",
+ "organizationRequired": "Introduce el id de organización de Papra.",
+ "tokenRequired": "Introduce el token de la API.",
+ "fileTooLarge": "El archivo supera el tamaño que acepta HealthLog.",
+ "quotaExceeded": "Tu almacenamiento de documentos está lleno.",
+ "fileType": "HealthLog no acepta este tipo de archivo.",
+ "uploadLimit": "Se alcanzó el límite de subidas por hora. Inténtalo más tarde.",
+ "uploadBusy": "Aún hay otras subidas en curso. Inténtalo en un momento.",
+ "moduleDisabled": "Los documentos están desactivados en Ajustes.",
+ "generic": "Algo salió mal. Inténtalo de nuevo."
+ }
}
},
"selfDescription": {
diff --git a/messages/fr.json b/messages/fr.json
index 722b77a24..1037939c5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Réafficher la liste de départ",
"checklistShown": "La liste de départ est de retour sur le tableau de bord.",
"checklistNothingHidden": "La liste n'était pas masquée ; elle s'ouvre sur le tableau de bord."
+ },
+ "documentSources": {
+ "title": "Archives de documents",
+ "description": "Cherchez dans Paperless-ngx ou Papra depuis HealthLog et importez les documents choisis.",
+ "explainer": "Un bouton d'import apparaît partout où vous liez des documents : consultations, vaccinations, affections et Documents. HealthLog garde sa propre copie chiffrée de chaque document importé et n'interroge votre archive que lorsque vous cherchez ou importez.",
+ "systemLabel": "Archive",
+ "statusConnected": "Connecté",
+ "statusNotConnected": "Non connecté",
+ "connectedSince": "Connecté, dernière vérification le {date}",
+ "originRemoved": "Cette adresse ne figure plus dans la liste des archives autorisées du serveur. Voyez avec l'administration ou enregistrez une autre adresse.",
+ "baseUrl": "Adresse",
+ "allowedOrigins": "Autorisé sur ce serveur : {origins}",
+ "organizationId": "Identifiant d'organisation",
+ "organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra.",
+ "token": "Jeton d'API",
+ "tokenSaved": "Enregistré. Laissez vide pour le conserver.",
+ "tokenHelpPaperless": "Dans Paperless-ngx : Mon profil, jeton d'API.",
+ "tokenHelpPapra": "Dans Papra : une clé d'API avec documents:read et tags:read.",
+ "save": "Enregistrer et tester",
+ "saving": "Test en cours…",
+ "saved": "Connecté. L'archive a répondu.",
+ "test": "Tester",
+ "testOk": "L'archive a répondu en {latency} ms.",
+ "disconnect": "Déconnecter",
+ "disconnectTitle": "Déconnecter {name} ?",
+ "disconnectDescription": "HealthLog oublie l'adresse et le jeton. Les documents déjà importés restent dans HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "La lecture par IA envoie ceci à votre service d'IA hors de ce serveur. Autoriser HealthLog à envoyer des documents, des résultats d'analyses et des descriptions de médicaments pour lecture ?",
"grant": "Autoriser la lecture",
"error": "Le consentement n'a pas pu être enregistré. Veuillez réessayer."
+ },
+ "sourcePicker": {
+ "open": "Importer depuis une archive",
+ "openNamed": "Importer depuis {name}",
+ "title": "Importer depuis {name}",
+ "description": "Cherchez par nom, puis cochez les documents que HealthLog doit copier.",
+ "sourceLabel": "Archive",
+ "searchLabel": "Nom du document",
+ "searchPlaceholder": "Chercher par nom",
+ "tagLabel": "Étiquette",
+ "tagAny": "Toutes les étiquettes",
+ "fromLabel": "Du",
+ "toLabel": "Au",
+ "clearFilters": "Effacer l'étiquette et les dates",
+ "rangeInvalid": "La date de fin précède la date de début.",
+ "empty": "{name} n'a aucun document à afficher.",
+ "noMatch": "Aucun document ne correspond.",
+ "loadMore": "Charger plus",
+ "stateImported": "Dans HealthLog",
+ "stateDeleted": "Supprimé dans HealthLog",
+ "selectedCount": "{count} sélectionnés sur {max} au plus",
+ "importAction": "Importer {count}",
+ "importing": "Import en cours, un document après l'autre…",
+ "done": "Terminé",
+ "summary": "Importés : {imported}. Déjà présents : {present}. Non importés : {failed}.",
+ "stopped": "Arrêté : {reason}",
+ "result": {
+ "waiting": "En attente",
+ "working": "Import en cours…",
+ "imported": "Importé",
+ "importedLinked": "Importé et lié",
+ "duplicate": "Déjà dans HealthLog",
+ "duplicateLinked": "Déjà dans HealthLog, désormais lié",
+ "deleted": "Supprimé dans HealthLog, laissé tel quel"
+ },
+ "errors": {
+ "unavailable": "Les archives de documents ne sont pas activées sur ce serveur.",
+ "notConnected": "Cette archive n'est pas connectée. Connectez-la dans Réglages, Intégrations.",
+ "originNotAllowed": "Cette adresse ne figure pas dans la liste des archives autorisées du serveur. Demandez à l'administration de l'ajouter.",
+ "unreachable": "L'archive est injoignable. Vérifiez qu'elle fonctionne et que l'adresse est juste.",
+ "redirected": "L'archive a redirigé la requête. Saisissez l'adresse vers laquelle elle redirige.",
+ "authRefused": "L'archive a refusé le jeton. Vérifiez le jeton et ses droits.",
+ "versionTooOld": "L'archive est trop ancienne. Paperless-ngx 2.16 ou plus récent est nécessaire.",
+ "badResponse": "La réponse de l'archive est illisible. Vérifiez que l'adresse pointe vers l'archive elle-même.",
+ "notFound": "L'archive ne connaît pas ce document ou cette organisation.",
+ "linkTargetNotFound": "L'élément auquel lier le document n'existe plus.",
+ "rateLimited": "Trop de requêtes. Patientez une minute et réessayez.",
+ "browserOnly": "Les archives de documents ne s'utilisent que dans le navigateur.",
+ "invalidAddress": "Saisissez l'adresse complète, commençant par https:// ou http://.",
+ "organizationRequired": "Saisissez l'identifiant d'organisation Papra.",
+ "tokenRequired": "Saisissez le jeton d'API.",
+ "fileTooLarge": "Le fichier dépasse la taille acceptée par HealthLog.",
+ "quotaExceeded": "Votre espace de documents est plein.",
+ "fileType": "HealthLog n'accepte pas ce type de fichier.",
+ "uploadLimit": "La limite horaire d'envois est atteinte. Réessayez plus tard.",
+ "uploadBusy": "D'autres envois sont en cours. Réessayez dans un instant.",
+ "moduleDisabled": "Les documents sont désactivés dans les réglages.",
+ "generic": "Un problème est survenu. Réessayez."
+ }
}
},
"selfDescription": {
diff --git a/messages/it.json b/messages/it.json
index ae8310f2d..1d4c709f7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Mostra di nuovo la lista",
"checklistShown": "La lista è di nuovo nella dashboard.",
"checklistNothingHidden": "La lista non era nascosta; si apre nella dashboard."
+ },
+ "documentSources": {
+ "title": "Archivi di documenti",
+ "description": "Cerca in Paperless-ngx o Papra da HealthLog e importa i documenti che scegli.",
+ "explainer": "Un pulsante di importazione compare ovunque colleghi documenti: nelle visite, nelle vaccinazioni, nelle condizioni e in Documenti. HealthLog conserva una propria copia cifrata di ogni documento importato e interroga il tuo archivio solo quando cerchi o importi.",
+ "systemLabel": "Archivio",
+ "statusConnected": "Collegato",
+ "statusNotConnected": "Non collegato",
+ "connectedSince": "Collegato, ultimo controllo il {date}",
+ "originRemoved": "Questo indirizzo non è più nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione o salva un altro indirizzo.",
+ "baseUrl": "Indirizzo",
+ "allowedOrigins": "Consentito su questo server: {origins}",
+ "organizationId": "Id organizzazione",
+ "organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra.",
+ "token": "Token API",
+ "tokenSaved": "Salvato. Lascia vuoto per mantenerlo.",
+ "tokenHelpPaperless": "In Paperless-ngx: Il mio profilo, token API.",
+ "tokenHelpPapra": "In Papra: una chiave API con documents:read e tags:read.",
+ "save": "Salva e prova",
+ "saving": "Prova in corso…",
+ "saved": "Collegato. L'archivio ha risposto.",
+ "test": "Prova",
+ "testOk": "L'archivio ha risposto in {latency} ms.",
+ "disconnect": "Scollega",
+ "disconnectTitle": "Scollegare {name}?",
+ "disconnectDescription": "HealthLog dimentica indirizzo e token. I documenti già importati restano in HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "La lettura con IA invia questo contenuto al tuo servizio di IA fuori da questo server. Consentire a HealthLog di inviare documenti, referti di laboratorio e descrizioni di farmaci per la lettura?",
"grant": "Consenti lettura",
"error": "Non è stato possibile salvare il consenso. Riprova."
+ },
+ "sourcePicker": {
+ "open": "Importa da archivio",
+ "openNamed": "Importa da {name}",
+ "title": "Importa da {name}",
+ "description": "Cerca per nome, poi spunta i documenti che HealthLog deve copiare.",
+ "sourceLabel": "Archivio",
+ "searchLabel": "Nome del documento",
+ "searchPlaceholder": "Cerca per nome",
+ "tagLabel": "Etichetta",
+ "tagAny": "Qualsiasi etichetta",
+ "fromLabel": "Dal",
+ "toLabel": "Al",
+ "clearFilters": "Azzera etichetta e date",
+ "rangeInvalid": "La data finale è precedente a quella iniziale.",
+ "empty": "{name} non ha documenti da mostrare.",
+ "noMatch": "Nessun documento corrisponde.",
+ "loadMore": "Carica altri",
+ "stateImported": "Già in HealthLog",
+ "stateDeleted": "Eliminato in HealthLog",
+ "selectedCount": "{count} selezionati su un massimo di {max}",
+ "importAction": "Importa {count}",
+ "importing": "Importazione in corso, un documento alla volta…",
+ "done": "Fatto",
+ "summary": "Importati: {imported}. Già presenti: {present}. Non importati: {failed}.",
+ "stopped": "Interrotto: {reason}",
+ "result": {
+ "waiting": "In attesa",
+ "working": "Importazione…",
+ "imported": "Importato",
+ "importedLinked": "Importato e collegato",
+ "duplicate": "Già in HealthLog",
+ "duplicateLinked": "Già in HealthLog, ora collegato",
+ "deleted": "Eliminato in HealthLog, lasciato così"
+ },
+ "errors": {
+ "unavailable": "Gli archivi di documenti non sono abilitati su questo server.",
+ "notConnected": "Questo archivio non è collegato. Collegalo in Impostazioni, Integrazioni.",
+ "originNotAllowed": "Questo indirizzo non è nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione di aggiungerlo.",
+ "unreachable": "L'archivio non è raggiungibile. Verifica che sia in funzione e che l'indirizzo sia corretto.",
+ "redirected": "L'archivio ha reindirizzato la richiesta. Inserisci l'indirizzo verso cui reindirizza.",
+ "authRefused": "L'archivio ha rifiutato il token. Controlla il token e i suoi permessi.",
+ "versionTooOld": "L'archivio è troppo vecchio. Serve Paperless-ngx 2.16 o successivo.",
+ "badResponse": "Non è stato possibile leggere la risposta dell'archivio. Verifica che l'indirizzo punti all'archivio stesso.",
+ "notFound": "L'archivio non ha questo documento o questa organizzazione.",
+ "linkTargetNotFound": "L'elemento da collegare non esiste più.",
+ "rateLimited": "Troppe richieste. Attendi un minuto e riprova.",
+ "browserOnly": "Gli archivi di documenti si usano solo nel browser.",
+ "invalidAddress": "Inserisci l'indirizzo completo, che inizia con https:// o http://.",
+ "organizationRequired": "Inserisci l'id dell'organizzazione Papra.",
+ "tokenRequired": "Inserisci il token API.",
+ "fileTooLarge": "Il file è più grande di quanto HealthLog accetti.",
+ "quotaExceeded": "Lo spazio per i documenti è pieno.",
+ "fileType": "HealthLog non accetta questo tipo di file.",
+ "uploadLimit": "È stato raggiunto il limite orario di caricamenti. Riprova più tardi.",
+ "uploadBusy": "Altri caricamenti sono ancora in corso. Riprova tra un momento.",
+ "moduleDisabled": "I documenti sono disattivati nelle impostazioni.",
+ "generic": "Qualcosa è andato storto. Riprova."
+ }
}
},
"selfDescription": {
diff --git a/messages/ko.json b/messages/ko.json
index 674b6c9a3..42c93bcf7 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6698,6 +6698,32 @@
"showChecklist": "체크리스트 다시 표시",
"checklistShown": "체크리스트가 대시보드에 다시 표시됩니다.",
"checklistNothingHidden": "체크리스트는 숨겨져 있지 않았습니다. 대시보드에서 열립니다."
+ },
+ "documentSources": {
+ "title": "문서 보관함",
+ "description": "HealthLog에서 Paperless-ngx나 Papra를 검색하고 선택한 문서를 가져옵니다.",
+ "explainer": "문서를 연결하는 곳마다 가져오기 버튼이 나타납니다: 진료, 예방접종, 질환, 그리고 문서 페이지. HealthLog는 가져온 문서마다 자체 암호화 사본을 보관하며, 검색하거나 가져올 때만 보관함에 요청합니다.",
+ "systemLabel": "보관함",
+ "statusConnected": "연결됨",
+ "statusNotConnected": "연결 안 됨",
+ "connectedSince": "연결됨, 마지막 확인 {date}",
+ "originRemoved": "이 주소는 더 이상 서버의 허용된 보관함 목록에 없습니다. 관리자에게 문의하거나 다른 주소를 저장하세요.",
+ "baseUrl": "주소",
+ "allowedOrigins": "이 서버에서 허용됨: {origins}",
+ "organizationId": "조직 ID",
+ "organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다.",
+ "token": "API 토큰",
+ "tokenSaved": "저장됨. 유지하려면 비워 두세요.",
+ "tokenHelpPaperless": "Paperless-ngx: 내 프로필의 API 토큰.",
+ "tokenHelpPapra": "Papra: documents:read와 tags:read 권한이 있는 API 키.",
+ "save": "저장하고 테스트",
+ "saving": "테스트 중…",
+ "saved": "연결되었습니다. 보관함이 응답했습니다.",
+ "test": "테스트",
+ "testOk": "보관함이 {latency}ms 만에 응답했습니다.",
+ "disconnect": "연결 해제",
+ "disconnectTitle": "{name} 연결을 해제할까요?",
+ "disconnectDescription": "HealthLog가 주소와 토큰을 잊습니다. 이미 가져온 문서는 HealthLog에 남습니다."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "AI로 읽으면 이 내용이 이 서버 밖의 AI 서비스로 전송됩니다. HealthLog가 문서, 검사 결과지, 약 설명을 읽기용으로 보내도록 허용하시겠습니까?",
"grant": "읽기 허용",
"error": "동의를 저장하지 못했습니다. 다시 시도해 주세요."
+ },
+ "sourcePicker": {
+ "open": "보관함에서 가져오기",
+ "openNamed": "{name}에서 가져오기",
+ "title": "{name}에서 가져오기",
+ "description": "이름으로 검색한 뒤 HealthLog로 복사할 문서를 선택하세요.",
+ "sourceLabel": "보관함",
+ "searchLabel": "문서 이름",
+ "searchPlaceholder": "이름으로 검색",
+ "tagLabel": "태그",
+ "tagAny": "모든 태그",
+ "fromLabel": "시작일",
+ "toLabel": "종료일",
+ "clearFilters": "태그와 날짜 지우기",
+ "rangeInvalid": "종료일이 시작일보다 앞섭니다.",
+ "empty": "{name}에 표시할 문서가 없습니다.",
+ "noMatch": "일치하는 문서가 없습니다.",
+ "loadMore": "더 불러오기",
+ "stateImported": "HealthLog에 있음",
+ "stateDeleted": "HealthLog에서 삭제됨",
+ "selectedCount": "최대 {max}개 중 {count}개 선택",
+ "importAction": "{count}개 가져오기",
+ "importing": "문서를 하나씩 가져오는 중…",
+ "done": "완료",
+ "summary": "가져옴: {imported}. 이미 있음: {present}. 가져오지 못함: {failed}.",
+ "stopped": "중단됨: {reason}",
+ "result": {
+ "waiting": "대기 중",
+ "working": "가져오는 중…",
+ "imported": "가져옴",
+ "importedLinked": "가져오고 연결함",
+ "duplicate": "이미 HealthLog에 있음",
+ "duplicateLinked": "이미 HealthLog에 있음, 이제 연결됨",
+ "deleted": "HealthLog에서 삭제됨, 그대로 둠"
+ },
+ "errors": {
+ "unavailable": "이 서버에서는 문서 보관함이 활성화되어 있지 않습니다.",
+ "notConnected": "이 보관함은 연결되어 있지 않습니다. 설정의 통합에서 연결하세요.",
+ "originNotAllowed": "이 주소는 서버의 허용된 보관함 목록에 없습니다. 관리자에게 추가를 요청하세요.",
+ "unreachable": "보관함에 연결할 수 없습니다. 실행 중인지, 주소가 맞는지 확인하세요.",
+ "redirected": "보관함이 요청을 리디렉션했습니다. 리디렉션되는 주소를 입력하세요.",
+ "authRefused": "보관함이 토큰을 거부했습니다. 토큰과 권한을 확인하세요.",
+ "versionTooOld": "보관함 버전이 너무 오래되었습니다. Paperless-ngx 2.16 이상이 필요합니다.",
+ "badResponse": "보관함의 응답을 읽을 수 없습니다. 주소가 보관함 자체를 가리키는지 확인하세요.",
+ "notFound": "보관함에 해당 문서나 조직이 없습니다.",
+ "linkTargetNotFound": "연결할 기록이 더 이상 존재하지 않습니다.",
+ "rateLimited": "요청이 너무 많습니다. 1분 후에 다시 시도하세요.",
+ "browserOnly": "문서 보관함은 브라우저에서만 사용할 수 있습니다.",
+ "invalidAddress": "https:// 또는 http://로 시작하는 전체 주소를 입력하세요.",
+ "organizationRequired": "Papra 조직 ID를 입력하세요.",
+ "tokenRequired": "API 토큰을 입력하세요.",
+ "fileTooLarge": "파일이 HealthLog가 허용하는 크기보다 큽니다.",
+ "quotaExceeded": "문서 저장 공간이 가득 찼습니다.",
+ "fileType": "HealthLog는 이 파일 형식을 받지 않습니다.",
+ "uploadLimit": "시간당 업로드 한도에 도달했습니다. 나중에 다시 시도하세요.",
+ "uploadBusy": "다른 업로드가 아직 진행 중입니다. 잠시 후 다시 시도하세요.",
+ "moduleDisabled": "설정에서 문서 기능이 꺼져 있습니다.",
+ "generic": "문제가 발생했습니다. 다시 시도하세요."
+ }
}
},
"selfDescription": {
diff --git a/messages/pl.json b/messages/pl.json
index a2c23d8b3..680921471 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6698,6 +6698,32 @@
"showChecklist": "Pokaż listę ponownie",
"checklistShown": "Lista wróciła na panel.",
"checklistNothingHidden": "Lista nie była ukryta; otwiera się na panelu."
+ },
+ "documentSources": {
+ "title": "Archiwa dokumentów",
+ "description": "Przeszukuj Paperless-ngx lub Papra z poziomu HealthLog i importuj wybrane dokumenty.",
+ "explainer": "Przycisk importu pojawia się wszędzie tam, gdzie wiążesz dokumenty: przy wizytach, szczepieniach i schorzeniach oraz w Dokumentach. HealthLog przechowuje własną zaszyfrowaną kopię każdego zaimportowanego dokumentu i pyta archiwum tylko wtedy, gdy szukasz lub importujesz.",
+ "systemLabel": "Archiwum",
+ "statusConnected": "Połączono",
+ "statusNotConnected": "Nie połączono",
+ "connectedSince": "Połączono, ostatnio sprawdzono {date}",
+ "originRemoved": "Tego adresu nie ma już na liście dozwolonych archiwów serwera. Zapytaj administrację albo zapisz inny adres.",
+ "baseUrl": "Adres",
+ "allowedOrigins": "Dozwolone na tym serwerze: {origins}",
+ "organizationId": "Identyfikator organizacji",
+ "organizationHelp": "Część po /organizations/ w pasku adresu Papra.",
+ "token": "Token API",
+ "tokenSaved": "Zapisano. Zostaw puste, aby go zachować.",
+ "tokenHelpPaperless": "W Paperless-ngx: Mój profil, token API.",
+ "tokenHelpPapra": "W Papra: klucz API z uprawnieniami documents:read i tags:read.",
+ "save": "Zapisz i sprawdź",
+ "saving": "Sprawdzanie…",
+ "saved": "Połączono. Archiwum odpowiedziało.",
+ "test": "Sprawdź",
+ "testOk": "Archiwum odpowiedziało w {latency} ms.",
+ "disconnect": "Rozłącz",
+ "disconnectTitle": "Rozłączyć {name}?",
+ "disconnectDescription": "HealthLog zapomina adres i token. Zaimportowane już dokumenty zostają w HealthLog."
}
},
"admin": {
@@ -10147,6 +10173,65 @@
"body": "Odczyt przez AI wysyła to do Twojej usługi AI poza tym serwerem. Czy HealthLog może wysyłać dokumenty, wyniki badań i opisy leków do odczytu?",
"grant": "Zezwól na odczyt",
"error": "Nie udało się zapisać zgody. Spróbuj ponownie."
+ },
+ "sourcePicker": {
+ "open": "Importuj z archiwum",
+ "openNamed": "Importuj z {name}",
+ "title": "Import z {name}",
+ "description": "Szukaj po nazwie, a potem zaznacz dokumenty, które HealthLog ma skopiować.",
+ "sourceLabel": "Archiwum",
+ "searchLabel": "Nazwa dokumentu",
+ "searchPlaceholder": "Szukaj po nazwie",
+ "tagLabel": "Tag",
+ "tagAny": "Dowolny tag",
+ "fromLabel": "Od",
+ "toLabel": "Do",
+ "clearFilters": "Wyczyść tag i daty",
+ "rangeInvalid": "Data końcowa jest wcześniejsza niż początkowa.",
+ "empty": "{name} nie ma dokumentów do pokazania.",
+ "noMatch": "Brak pasujących dokumentów.",
+ "loadMore": "Wczytaj więcej",
+ "stateImported": "W HealthLog",
+ "stateDeleted": "Usunięty w HealthLog",
+ "selectedCount": "Wybrano {count} z maksymalnie {max}",
+ "importAction": "Importuj ({count})",
+ "importing": "Importowanie, dokument po dokumencie…",
+ "done": "Gotowe",
+ "summary": "Zaimportowane: {imported}. Już były: {present}. Niezaimportowane: {failed}.",
+ "stopped": "Zatrzymano: {reason}",
+ "result": {
+ "waiting": "Czeka",
+ "working": "Importowanie…",
+ "imported": "Zaimportowano",
+ "importedLinked": "Zaimportowano i powiązano",
+ "duplicate": "Już jest w HealthLog",
+ "duplicateLinked": "Już jest w HealthLog, teraz powiązany",
+ "deleted": "Usunięty w HealthLog, pozostaje usunięty"
+ },
+ "errors": {
+ "unavailable": "Archiwa dokumentów nie są włączone na tym serwerze.",
+ "notConnected": "To archiwum nie jest połączone. Połącz je w Ustawieniach, Integracje.",
+ "originNotAllowed": "Tego adresu nie ma na liście dozwolonych archiwów serwera. Poproś administrację o jego dodanie.",
+ "unreachable": "Nie udało się połączyć z archiwum. Sprawdź, czy działa i czy adres jest poprawny.",
+ "redirected": "Archiwum przekierowało żądanie. Wpisz adres, na który przekierowuje.",
+ "authRefused": "Archiwum odrzuciło token. Sprawdź token i jego uprawnienia.",
+ "versionTooOld": "Archiwum jest za stare. Potrzebny jest Paperless-ngx 2.16 lub nowszy.",
+ "badResponse": "Nie udało się odczytać odpowiedzi archiwum. Sprawdź, czy adres wskazuje na samo archiwum.",
+ "notFound": "Archiwum nie ma takiego dokumentu ani takiej organizacji.",
+ "linkTargetNotFound": "Wpis, z którym miało nastąpić powiązanie, już nie istnieje.",
+ "rateLimited": "Za dużo żądań. Odczekaj minutę i spróbuj ponownie.",
+ "browserOnly": "Z archiwów dokumentów można korzystać tylko w przeglądarce.",
+ "invalidAddress": "Wpisz pełny adres, zaczynający się od https:// lub http://.",
+ "organizationRequired": "Wpisz identyfikator organizacji Papra.",
+ "tokenRequired": "Wpisz token API.",
+ "fileTooLarge": "Plik jest większy, niż przyjmuje HealthLog.",
+ "quotaExceeded": "Miejsce na dokumenty jest pełne.",
+ "fileType": "HealthLog nie przyjmuje tego typu pliku.",
+ "uploadLimit": "Osiągnięto godzinny limit przesyłania. Spróbuj później.",
+ "uploadBusy": "Trwają jeszcze inne przesyłania. Spróbuj za chwilę.",
+ "moduleDisabled": "Dokumenty są wyłączone w ustawieniach.",
+ "generic": "Coś poszło nie tak. Spróbuj ponownie."
+ }
}
},
"selfDescription": {
diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx
index 67e5ac4aa..c68bb8f5f 100644
--- a/src/components/documents/documents-view.tsx
+++ b/src/components/documents/documents-view.tsx
@@ -67,6 +67,7 @@ import { DocumentShareSheet } from "./document-share-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
+import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
@@ -876,13 +877,17 @@ export function DocumentsView() {
description={t("documents.subtitle")}
actions={
canManageDocuments ? (
-
+
+ {/* Renders only with a connected Paperless-ngx or Papra. */}
+
+
+
) : null
}
/>
diff --git a/src/components/documents/episode-documents-card.tsx b/src/components/documents/episode-documents-card.tsx
index fc42f410b..2c9bc580f 100644
--- a/src/components/documents/episode-documents-card.tsx
+++ b/src/components/documents/episode-documents-card.tsx
@@ -32,6 +32,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
+import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
@@ -167,6 +168,9 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
{t("documents.episodeCard.upload")}
+
>
) : null}
{hasMore ? (
diff --git a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
new file mode 100644
index 000000000..2e3f93881
--- /dev/null
+++ b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
@@ -0,0 +1,230 @@
+/**
+ * The document picker's client gates (#1038), pinned via static renders:
+ *
+ * - the import button exists only for a person in their own record with a
+ * usable connection; a delegate, an unset operator list, no connection, or
+ * a connection whose origin was removed leave no button;
+ * - its label names the one connected system, or stays generic for two;
+ * - the settings card renders nothing unless the operator enabled the
+ * picker, lists the allowed origins, and never puts a token in the markup;
+ * - every error code the routes emit has its own sentence, and an unknown
+ * one falls back to the generic sentence rather than a raw key.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+let modules: Record = { inboundDocuments: true };
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", modules },
+ isLoading: false,
+ isAuthenticated: true,
+ }),
+}));
+
+let shared = false;
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({
+ inSharedRecord: shared,
+ canManageDomain: () => !shared,
+ }),
+}));
+
+vi.mock("@/lib/api/api-fetch", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/api-fetch",
+ );
+ return { ...actual, apiGet: () => new Promise(() => {}) };
+});
+
+import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
+import en from "../../../../../messages/en.json";
+import { ApiError } from "@/lib/api/api-fetch";
+import {
+ DOCUMENT_SOURCE_ERROR_CODES,
+ type DocumentSourcesStatusDto,
+} from "@/lib/documents/sources/types";
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import { ImportFromSourceButton } from "../import-from-source-button";
+import { mergeIds } from "../merge-ids";
+import { errorCodeOf, sourceErrorMessage } from "../source-errors";
+
+const PAPERLESS = {
+ system: "PAPERLESS" as const,
+ baseUrl: "http://paperless.lan:8000",
+ organizationId: null,
+ hasToken: true as const,
+ lastVerifiedAt: "2026-09-20T10:00:00.000Z",
+ originAllowed: true,
+};
+const PAPRA = {
+ ...PAPERLESS,
+ system: "PAPRA" as const,
+ baseUrl: "https://papra.example.com",
+ organizationId: "org_1",
+};
+
+function render(node: React.ReactNode, status?: DocumentSourcesStatusDto) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ if (status)
+ queryClient.setQueryData(queryKeys.documentSourcesStatus(), status);
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+const on = (connections: DocumentSourcesStatusDto["connections"]) => ({
+ available: true,
+ allowedOrigins: ["http://paperless.lan:8000", "https://papra.example.com"],
+ connections,
+});
+
+describe("", () => {
+ it("names the one connected system", () => {
+ const html = render(, on([PAPERLESS]));
+ expect(html).toContain('data-slot="document-source-open"');
+ expect(html).toContain("Import from Paperless-ngx");
+ });
+
+ it("stays generic with two", () => {
+ const html = render(, on([PAPERLESS, PAPRA]));
+ expect(html).toContain("Import from archive");
+ });
+
+ it("renders nothing without a usable connection", () => {
+ expect(render(, on([]))).toBe("");
+ expect(
+ render(
+ ,
+ on([{ ...PAPERLESS, originAllowed: false }]),
+ ),
+ ).toBe("");
+ expect(
+ render(, {
+ available: false,
+ allowedOrigins: [],
+ connections: [],
+ }),
+ ).toBe("");
+ });
+
+ it("renders nothing for a delegate, even with the owner's data cached", () => {
+ shared = true;
+ try {
+ expect(render(, on([PAPERLESS]))).toBe("");
+ } finally {
+ shared = false;
+ }
+ });
+
+ it("renders nothing with the documents module off", () => {
+ modules = { inboundDocuments: false };
+ try {
+ expect(render(, on([PAPERLESS]))).toBe("");
+ } finally {
+ modules = { inboundDocuments: true };
+ }
+ });
+});
+
+describe("", () => {
+ it("renders nothing when the operator has not enabled the picker", () => {
+ expect(
+ render(, {
+ available: false,
+ allowedOrigins: [],
+ connections: [],
+ }),
+ ).toBe("");
+ expect(render()).toBe("");
+ });
+
+ it("renders nothing for a delegate", () => {
+ shared = true;
+ try {
+ expect(render(, on([PAPERLESS]))).toBe("");
+ } finally {
+ shared = false;
+ }
+ });
+
+ it("shows the form, the allowed origins and a saved-token placeholder, never a token", () => {
+ const html = render(, on([PAPERLESS]));
+ expect(html).toContain('data-testid="document-sources-card"');
+ expect(html).toContain("Document archives");
+ expect(html).toContain(
+ "Allowed on this server: http://paperless.lan:8000, https://papra.example.com",
+ );
+ expect(html).toContain("Saved. Leave empty to keep it.");
+ expect(html).toContain('value="http://paperless.lan:8000"');
+ // The password field is empty: the server never sends the token back.
+ expect(html).toMatch(/id="document-source-token-paperless"[^>]*value=""/);
+ expect(html).toContain("Disconnect");
+ });
+
+ it("offers no disconnect or test for a system that is not connected", () => {
+ const html = render(, on([]));
+ expect(html).toContain("Not connected");
+ expect(html).not.toContain("Disconnect");
+ expect(html).toContain("Save and test");
+ });
+});
+
+describe("source error sentences", () => {
+ const t = (key: string) => {
+ let node: unknown = en;
+ for (const part of key.split(".")) {
+ node = (node as Record | undefined)?.[part];
+ }
+ return typeof node === "string" ? node : key;
+ };
+ const generic = t("documents.sourcePicker.errors.generic");
+
+ it("has its own sentence for every code the routes emit", () => {
+ const codes = [
+ ...DOCUMENT_SOURCE_ERROR_CODES,
+ "documents.sources.browserOnly",
+ "documents.sources.invalidAddress",
+ "documents.sources.organizationRequired",
+ "documents.sources.tokenRequired",
+ "documents.inbound.fileTooLarge",
+ "documents.inbound.quotaExceeded",
+ "documents.inbound.fileType",
+ "documents.inbound.rateLimited",
+ "documents.inbound.uploadBusy",
+ "module.disabled",
+ ];
+ const sentences = new Set();
+ for (const code of codes) {
+ const sentence = sourceErrorMessage(t, code);
+ expect(sentence, code).not.toBe(generic);
+ expect(sentence, code).not.toContain("documents.sourcePicker");
+ sentences.add(sentence);
+ }
+ expect(sentences.size).toBe(codes.length);
+ expect(sourceErrorMessage(t, "something.else")).toBe(generic);
+ expect(sourceErrorMessage(t, undefined)).toBe(generic);
+ });
+
+ it("reads the code off a failed request", () => {
+ expect(
+ errorCodeOf(
+ new ApiError("x", 502, { errorCode: "documents.sources.authRefused" }),
+ ),
+ ).toBe("documents.sources.authRefused");
+ expect(errorCodeOf(new Error("x"))).toBeUndefined();
+ });
+});
+
+describe("mergeIds", () => {
+ it("adds imported ids to a form's selection once, in order", () => {
+ expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
+ });
+});
diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx
new file mode 100644
index 000000000..2199193dc
--- /dev/null
+++ b/src/components/documents/sources/document-source-picker.tsx
@@ -0,0 +1,654 @@
+"use client";
+
+/**
+ * The document picker (#1038): search a connected Paperless-ngx or Papra by
+ * document name, narrow by tag and date, tick the documents to bring over,
+ * import them.
+ *
+ * Name first, because that is how the person looks for a letter: the search
+ * field opens focused and debounces into the source's own name search. Tag and
+ * date are filters beside it, not the entry point. Each result says what
+ * HealthLog already holds under that source key: already imported, deleted
+ * here (not offered again: a deletion was a decision), or new.
+ *
+ * The import runs one document per request, in order, so every row can show
+ * its own progress and result, and one failure does not hide the others. A
+ * refusal that will answer every following document the same way (the hourly
+ * upload allowance, a full vault, a refused token) stops the run and says so.
+ *
+ * Where it was opened from decides what an import does besides storing:
+ *
+ * - `link`: the record already exists (a condition's page), so the server
+ * files the document against it as part of the import;
+ * - `onImported`: a form that owns its selection (a visit, a vaccination)
+ * gets the new ids and adds them itself. Linking on the server there would
+ * be undone by the form's own save, which replaces its links.
+ *
+ * With either, a document already in HealthLog can be picked too: it is not
+ * stored twice, it is linked or handed to the form.
+ */
+import {
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import {
+ Check,
+ CircleAlert,
+ CircleCheck,
+ Loader2,
+ Search,
+ X,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { DateField } from "@/components/ui/date-field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TagChip } from "@/components/ui/tag-chip";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
+import {
+ DOCUMENT_PICKER_MAX_SELECTION,
+ slugForSystem,
+ type DocumentImportOutcome,
+ type DocumentPickerLinkKind,
+ type DocumentPickerSystem,
+ type DocumentSourceImportDto,
+ type DocumentSourceResultDto,
+ type DocumentSourceSearchDto,
+ type DocumentSourceTagDto,
+} from "@/lib/documents/sources/types";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { invalidateKeys, queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
+
+import { formatBytes } from "../vault-utils";
+import {
+ errorCodeOf,
+ isStoppingError,
+ sourceErrorMessage,
+} from "./source-errors";
+import { systemName } from "./use-document-sources";
+
+export interface DocumentPickerLink {
+ kind: DocumentPickerLinkKind;
+ id: string;
+}
+
+export interface DocumentSourcePickerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Connected systems, in the order the switch shows them. */
+ systems: DocumentPickerSystem[];
+ /** File the imported documents against this existing record. */
+ link?: DocumentPickerLink;
+ /** Document type for everything imported (default OTHER). */
+ kind?: InboundDocumentKindValue;
+ /** Hand the vault ids to a form that links them on its own save. */
+ onImported?: (documentIds: string[]) => void;
+}
+
+type RowRun =
+ | { state: "waiting" }
+ | { state: "working" }
+ | { state: DocumentImportOutcome; linked: boolean }
+ | { state: "failed"; message: string };
+
+const SEARCH_DEBOUNCE_MS = 300;
+const IMPORT_TIMEOUT_MS = 120_000;
+
+export function DocumentSourcePicker({
+ open,
+ onOpenChange,
+ systems,
+ link,
+ kind,
+ onImported,
+}: DocumentSourcePickerProps) {
+ const { t, locale } = useTranslations();
+ const format = useFormatters();
+ const queryClient = useQueryClient();
+
+ const [system, setSystem] = useState(null);
+ const active =
+ system && systems.includes(system) ? system : (systems[0] ?? null);
+ const slug = active ? slugForSystem(active) : null;
+
+ const [searchDraft, setSearchDraft] = useState("");
+ const [q, setQ] = useState("");
+ const [tag, setTag] = useState("");
+ const [from, setFrom] = useState("");
+ const [to, setTo] = useState("");
+ const [selected, setSelected] = useState