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>(new Map()); + const [runs, setRuns] = useState>(new Map()); + const [running, setRunning] = useState(false); + const [finished, setFinished] = useState(false); + const [stopMessage, setStopMessage] = useState(null); + + const debounceRef = useRef | null>(null); + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); + const onSearchChange = (value: string) => { + setSearchDraft(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout( + () => setQ(value.trim()), + SEARCH_DEBOUNCE_MS, + ); + }; + + const reset = () => { + setSearchDraft(""); + setQ(""); + setTag(""); + setFrom(""); + setTo(""); + setSelected(new Map()); + setRuns(new Map()); + setFinished(false); + setStopMessage(null); + }; + + const switchSystem = (next: DocumentPickerSystem) => { + if (next === active || running) return; + reset(); + setSystem(next); + }; + + const tags = useQuery({ + queryKey: queryKeys.documentSourceTags(slug ?? ""), + enabled: open && slug !== null, + queryFn: () => + apiGet<{ tags: DocumentSourceTagDto[] }>( + `/api/documents/sources/${slug}/tags`, + ), + staleTime: 5 * 60_000, + }); + + const facets = { + q, + ...(tag ? { tag } : {}), + ...(from ? { from } : {}), + ...(to ? { to } : {}), + }; + const rangeInvalid = Boolean(from && to && from > to); + const search = useInfiniteQuery({ + queryKey: queryKeys.documentSourceSearch(slug ?? "", facets), + enabled: open && slug !== null && !rangeInvalid, + initialPageParam: 1, + queryFn: ({ pageParam }) => { + const sp = new URLSearchParams({ page: String(pageParam) }); + if (q) sp.set("q", q); + if (tag) sp.set("tag", tag); + if (from) sp.set("from", from); + if (to) sp.set("to", to); + return apiGet( + `/api/documents/sources/${slug}/search?${sp.toString()}`, + ); + }, + getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined), + staleTime: 30_000, + }); + + const results = useMemo(() => { + const seen = new Set(); + const out: DocumentSourceResultDto[] = []; + for (const page of search.data?.pages ?? []) { + for (const row of page.results) { + if (seen.has(row.sourceId)) continue; + seen.add(row.sourceId); + out.push(row); + } + } + return out; + }, [search.data]); + + // An already imported document is worth picking only when the pick does + // something with it: link it, or hand it to the form. + const canTakeImported = Boolean(link || onImported); + const selectable = (row: DocumentSourceResultDto) => + row.state === "new" || (row.state === "imported" && canTakeImported); + + const toggle = (row: DocumentSourceResultDto) => { + if (running || finished || !selectable(row)) return; + setSelected((prev) => { + const next = new Map(prev); + if (next.has(row.sourceId)) next.delete(row.sourceId); + else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) { + next.set(row.sourceId, row.title); + } + return next; + }); + }; + + async function runImport() { + if (!slug || selected.size === 0) return; + setRunning(true); + setStopMessage(null); + const order = [...selected.keys()]; + setRuns(new Map(order.map((id) => [id, { state: "waiting" }]))); + const handed: string[] = []; + const mark = (id: string, run: RowRun) => + setRuns((prev) => new Map(prev).set(id, run)); + + for (const sourceId of order) { + mark(sourceId, { state: "working" }); + try { + const result = await apiPost( + `/api/documents/sources/${slug}/import`, + { + sourceId, + ...(kind ? { kind } : {}), + ...(link ? { link } : {}), + }, + // The server may take up to a minute to fetch a large file from + // the source; the default 15 s client timeout would abandon it. + { signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) }, + ); + mark(sourceId, { state: result.outcome, linked: result.linked }); + if (result.outcome !== "deleted" && result.documentId) { + handed.push(result.documentId); + } + } catch (err) { + const code = errorCodeOf(err); + const message = sourceErrorMessage(t, code); + mark(sourceId, { state: "failed", message }); + if (isStoppingError(code)) { + setStopMessage(message); + break; + } + } + } + + setRunning(false); + setFinished(true); + setSelected(new Map()); + // The vault, its pickers and the record's own lists read documents + // through the `["documents"]` prefix; the picker's states through its own. + void invalidateKeys(queryClient, [ + queryKeys.documents(), + queryKeys.documentSources(), + ]); + if (handed.length > 0) onImported?.(handed); + } + + const counts = useMemo(() => { + let imported = 0; + let present = 0; + let failed = 0; + for (const run of runs.values()) { + if (run.state === "imported") imported += 1; + else if (run.state === "duplicate" || run.state === "deleted") + present += 1; + else if (run.state === "failed") failed += 1; + } + return { imported, present, failed }; + }, [runs]); + + const name = active ? systemName(active) : ""; + const hasFilters = Boolean(tag || from || to); + + const footer = ( +
+

+ {finished + ? t("documents.sourcePicker.summary", counts) + : running + ? t("documents.sourcePicker.importing") + : t("documents.sourcePicker.selectedCount", { + count: selected.size, + max: DOCUMENT_PICKER_MAX_SELECTION, + })} +

+ {finished ? ( + + ) : ( + + )} +
+ ); + + return ( + { + // A run in progress finishes before the sheet can close; its rows + // are the only place the outcome is shown. + if (!next && running) return; + if (!next) reset(); + onOpenChange(next); + }} + title={t("documents.sourcePicker.title", { name })} + description={t("documents.sourcePicker.description")} + contentWidth="lg" + footer={footer} + > +
+ {systems.length > 1 ? ( +
+ {systems.map((option) => ( + + ))} +
+ ) : null} + +
+ +
+ + onSearchChange(e.target.value)} + placeholder={t("documents.sourcePicker.searchPlaceholder")} + className="pl-9" + maxLength={200} + autoComplete="off" + data-slot="document-source-search" + /> +
+
+ +
+
+ + setTag(e.target.value)} + disabled={tags.isPending && !tags.isError} + > + + {(tags.data?.tags ?? []).map((option) => ( + + ))} + +
+
+ + +
+
+ + +
+
+ {hasFilters ? ( + + ) : null} + + {stopMessage ? ( +

+ {t("documents.sourcePicker.stopped", { reason: stopMessage })} +

+ ) : null} + + {rangeInvalid ? ( +

+ {t("documents.sourcePicker.rangeInvalid")} +

+ ) : search.isPending ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ ) : search.isError ? ( + void search.refetch()} + slot="document-source-error" + /> + ) : results.length === 0 ? ( +

+ {q || hasFilters + ? t("documents.sourcePicker.noMatch") + : t("documents.sourcePicker.empty", { name })} +

+ ) : ( +
    + {results.map((row) => { + const on = selected.has(row.sourceId); + const run = runs.get(row.sourceId); + const allowed = selectable(row); + const meta = [ + row.date ? format.date(`${row.date}T12:00:00.000Z`) : null, + row.sizeBytes !== null + ? formatBytes(row.sizeBytes, locale) + : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
  • + +
  • + ); + })} +
+ )} + + {search.hasNextPage && !search.isError ? ( + + ) : null} +
+
+ ); +} + +/** One row's progress or result, under its title. */ +function RunLine({ run }: { run: RowRun }) { + const { t } = useTranslations(); + switch (run.state) { + case "waiting": + return ( + + {t("documents.sourcePicker.result.waiting")} + + ); + case "working": + return ( + + + {t("documents.sourcePicker.result.working")} + + ); + case "failed": + return ( + + + {run.message} + + ); + case "deleted": + return ( + + {t("documents.sourcePicker.result.deleted")} + + ); + case "imported": + case "duplicate": + return ( + + + {run.state === "imported" + ? run.linked + ? t("documents.sourcePicker.result.importedLinked") + : t("documents.sourcePicker.result.imported") + : run.linked + ? t("documents.sourcePicker.result.duplicateLinked") + : t("documents.sourcePicker.result.duplicate")} + + ); + } +} diff --git a/src/components/documents/sources/import-from-source-button.tsx b/src/components/documents/sources/import-from-source-button.tsx new file mode 100644 index 000000000..09fd872df --- /dev/null +++ b/src/components/documents/sources/import-from-source-button.tsx @@ -0,0 +1,73 @@ +"use client"; + +/** + * "Import from Paperless-ngx / Papra": the button that opens the document + * picker (#1038), for every place a document can be added or linked. + * + * Renders nothing unless the person, in their own record, has at least one + * usable connection: no connection, a delegate or guardian, the module off, or + * the operator's list unset all leave no trace, because a button that can only + * lead to a refusal advertises a feature that is not there. Connecting happens + * in Settings, not here. + */ +import { Import } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +import { + DocumentSourcePicker, + type DocumentPickerLink, +} from "./document-source-picker"; +import { systemName, useDocumentSourcesStatus } from "./use-document-sources"; + +export function ImportFromSourceButton({ + link, + kind, + onImported, + className, +}: { + link?: DocumentPickerLink; + kind?: InboundDocumentKindValue; + onImported?: (documentIds: string[]) => void; + className?: string; +}) { + const { t } = useTranslations(); + const { connected } = useDocumentSourcesStatus(); + const [open, setOpen] = useState(false); + + if (connected.length === 0) return null; + + const label = + connected.length === 1 + ? t("documents.sourcePicker.openNamed", { + name: systemName(connected[0]), + }) + : t("documents.sourcePicker.open"); + + return ( + <> + + + + ); +} diff --git a/src/components/documents/sources/merge-ids.ts b/src/components/documents/sources/merge-ids.ts new file mode 100644 index 000000000..ab8579349 --- /dev/null +++ b/src/components/documents/sources/merge-ids.ts @@ -0,0 +1,6 @@ +/** Add imported ids to a form's selection, in order, never twice. */ +export function mergeIds(selected: string[], added: string[]): string[] { + const merged = [...selected]; + for (const id of added) if (!merged.includes(id)) merged.push(id); + return merged; +} diff --git a/src/components/documents/sources/source-errors.ts b/src/components/documents/sources/source-errors.ts new file mode 100644 index 000000000..3c1739924 --- /dev/null +++ b/src/components/documents/sources/source-errors.ts @@ -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; + +/** 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"); + } +} diff --git a/src/components/documents/sources/use-document-sources.ts b/src/components/documents/sources/use-document-sources.ts new file mode 100644 index 000000000..13b1a4692 --- /dev/null +++ b/src/components/documents/sources/use-document-sources.ts @@ -0,0 +1,51 @@ +"use client"; + +/** + * The document picker's availability for the person on this screen (#1038). + * + * Reads `GET /api/documents/sources` only when it can matter: the documents + * module is on and the person is in their own record with the right to manage + * documents. A delegate or guardian never asks (the route would refuse them + * anyway), so the import buttons and the settings card render nothing for + * them. `connected` is the list of systems with a usable connection, in a + * stable order; an entry whose origin the operator removed is left out. + */ +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiGet } from "@/lib/api/api-fetch"; +import type { + DocumentPickerSystem, + DocumentSourcesStatusDto, +} from "@/lib/documents/sources/types"; +import { queryKeys } from "@/lib/query-keys"; + +export function useDocumentSourcesStatus() { + const { user } = useAuth(); + const { canManageDomain, inSharedRecord } = useRecordCapabilities(); + const eligible = + user?.modules?.inboundDocuments === true && + !inSharedRecord && + canManageDomain("documents"); + + const query = useQuery({ + queryKey: queryKeys.documentSourcesStatus(), + enabled: eligible, + queryFn: () => apiGet("/api/documents/sources"), + staleTime: 60_000, + }); + + const connected: DocumentPickerSystem[] = eligible + ? (query.data?.connections ?? []) + .filter((c) => c.originAllowed) + .map((c) => c.system) + : []; + + return { eligible, query, connected }; +} + +/** Display name of a system; product names, not translated. */ +export function systemName(system: DocumentPickerSystem): string { + return system === "PAPERLESS" ? "Paperless-ngx" : "Papra"; +} diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx index 1dcf1e536..1e0eba0fe 100644 --- a/src/components/encounters/encounter-link-pickers.tsx +++ b/src/components/encounters/encounter-link-pickers.tsx @@ -32,6 +32,8 @@ import { type EntityLinkOption, } from "@/components/links/entity-link-picker"; import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; +import { mergeIds } from "@/components/documents/sources/merge-ids"; /** Fetch cap for the picker — the grouped sheet needs enough rows to group. */ export const PICKER_FETCH_LIMIT = 200; @@ -152,6 +154,17 @@ export function EncounterLinkPickers({ options={documents.options} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} + extraAction={ + // The form owns its selection and replaces the visit's links on + // save, so imported documents join the selection rather than + // being linked on the server. + + onChange({ documentIds: mergeIds(documentIds, ids) }) + } + /> + } /> ) : null} diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 3b3f4a467..8d70d10b0 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -31,7 +31,7 @@ * carries the option's date, so two doses of one vaccine read as two * different records. */ -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { Check, Plus, Search, X } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -164,6 +164,7 @@ export function EntityLinkPicker({ error = false, errorLabel, onRetry, + extraAction, }: { icon: LucideIcon; title: string; @@ -183,6 +184,13 @@ export function EntityLinkPicker({ error?: boolean; errorLabel?: string; onRetry?: () => void; + /** + * A second way to add, beside the add button: the documents pickers put + * "Import from Paperless-ngx / Papra" here. Shown even when there is + * nothing to link yet, because an empty vault is exactly when importing is + * the way forward. The element renders nothing when it has nothing to offer. + */ + extraAction?: ReactNode; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); @@ -238,7 +246,10 @@ export function EntityLinkPicker({ slot={`${slot}-error`} /> ) : options.length === 0 ? ( -

{emptyLabel}

+
+

{emptyLabel}

+ {extraAction} +
) : (
{selectedChips.length > 0 ? ( @@ -312,17 +323,20 @@ export function EntityLinkPicker({

) : null} - +
+ + {extraAction} +
)} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 37ceed391..4b6ce1506 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -37,6 +37,7 @@ import { ConnectionsPanel } from "@/components/settings/integrations/connections import { SectionHeading } from "@/components/ui/section-heading"; import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card"; +import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card"; import { Button } from "@/components/ui/button"; export { @@ -77,6 +78,11 @@ export function IntegrationsSection({ under the same Connections group rather than a new labelled section that could paint an orphaned heading over nothing. */} + + {/* v1.39.3 (#1038) — Paperless-ngx / Papra for the document picker. + Renders nothing unless the operator listed an origin in + DOCUMENT_SOURCE_ORIGINS and the person is in their own record. */} + {/* Delivery channels ("where it's delivered"). The existing channels diff --git a/src/components/settings/integrations/document-sources-card.tsx b/src/components/settings/integrations/document-sources-card.tsx new file mode 100644 index 000000000..b198d16ec --- /dev/null +++ b/src/components/settings/integrations/document-sources-card.tsx @@ -0,0 +1,415 @@ +"use client"; + +/** + * Settings → Integrations → Document archives (#1038): connect Paperless-ngx or + * Papra so the document picker can search it. + * + * Renders nothing unless the operator listed at least one origin in + * `DOCUMENT_SOURCE_ORIGINS`, the documents module is on, and the person is in + * their own record: a delegate never manages the owner's connection, and a + * card that can only be refused advertises a feature that is not there. + * + * One form per system behind a switch. Save tests the connection before it is + * stored and says why when it fails; the token field never shows the stored + * token (the server never returns it), only that one is saved, and leaving it + * empty keeps it. Results sit above the action row; nothing follows it + * (UI standards §12). + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Archive, CheckCircle2, Loader2, Unlink } from "lucide-react"; +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { + errorCodeOf, + sourceErrorMessage, +} from "@/components/documents/sources/source-errors"; +import { + systemName, + useDocumentSourcesStatus, +} from "@/components/documents/sources/use-document-sources"; +import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { + DOCUMENT_PICKER_SYSTEMS, + slugForSystem, + type DocumentPickerSystem, + type DocumentSourceConnectionDto, +} from "@/lib/documents/sources/types"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +type Outcome = { tone: "success" | "error"; message: string } | null; + +export function DocumentSourcesCard() { + const { t } = useTranslations(); + const { eligible, query } = useDocumentSourcesStatus(); + const [system, setSystem] = useState("PAPERLESS"); + + if (!eligible || !query.data?.available) return null; + + const connections = query.data.connections; + const connectedCount = connections.length; + + return ( + + 0 ? ( + + {t("settings.documentSources.statusConnected")} + + ) : null + } + /> + +

{t("settings.documentSources.explainer")}

+ +
+ {DOCUMENT_PICKER_SYSTEMS.map((option) => { + const isConnected = connections.some((c) => c.system === option); + return ( + + ); + })} +
+ + c.system === system) ?? null} + allowedOrigins={query.data.allowedOrigins} + /> +
+ ); +} + +function SourceForm({ + system, + connection, + allowedOrigins, +}: { + system: DocumentPickerSystem; + connection: DocumentSourceConnectionDto | null; + allowedOrigins: string[]; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const queryClient = useQueryClient(); + const slug = slugForSystem(system); + const isPapra = system === "PAPRA"; + + const [baseUrl, setBaseUrl] = useState(connection?.baseUrl ?? ""); + const [organizationId, setOrganizationId] = useState( + connection?.organizationId ?? "", + ); + const [token, setToken] = useState(""); + const [outcome, setOutcome] = useState(null); + + const dirty = + baseUrl.trim() !== (connection?.baseUrl ?? "") || + (isPapra && organizationId.trim() !== (connection?.organizationId ?? "")) || + token.trim() !== ""; + + const refresh = () => + invalidateKeys(queryClient, [queryKeys.documentSources()]); + + const save = useMutation({ + mutationFn: () => + apiPut(`/api/documents/sources/${slug}`, { + baseUrl: baseUrl.trim(), + ...(isPapra ? { organizationId: organizationId.trim() } : {}), + ...(token.trim() ? { token: token.trim() } : {}), + }), + onMutate: () => setOutcome(null), + onSuccess: () => { + setToken(""); + setOutcome({ + tone: "success", + message: t("settings.documentSources.saved"), + }); + void refresh(); + }, + onError: (err) => + setOutcome({ + tone: "error", + message: sourceErrorMessage(t, errorCodeOf(err)), + }), + }); + + const test = useMutation({ + mutationFn: () => + apiPost<{ ok: true; latencyMs: number }>( + `/api/documents/sources/${slug}/test`, + ), + onMutate: () => setOutcome(null), + onSuccess: (data) => { + setOutcome({ + tone: "success", + message: t("settings.documentSources.testOk", { + latency: data.latencyMs, + }), + }); + void refresh(); + }, + onError: (err) => + setOutcome({ + tone: "error", + message: sourceErrorMessage(t, errorCodeOf(err)), + }), + }); + + const disconnect = useMutation({ + mutationFn: () => apiDelete(`/api/documents/sources/${slug}`), + onSuccess: () => { + setBaseUrl(""); + setOrganizationId(""); + setToken(""); + setOutcome(null); + void refresh(); + }, + onError: (err) => + setOutcome({ + tone: "error", + message: sourceErrorMessage(t, errorCodeOf(err)), + }), + }); + + const busy = save.isPending || test.isPending || disconnect.isPending; + const canSave = + baseUrl.trim() !== "" && + (!isPapra || organizationId.trim() !== "") && + (connection !== null || token.trim() !== "") && + dirty; + + return ( +
{ + e.preventDefault(); + if (canSave && !busy) save.mutate(); + }} + data-slot="document-sources-form" + > +

+ {connection + ? connection.lastVerifiedAt + ? t("settings.documentSources.connectedSince", { + date: format.date(connection.lastVerifiedAt), + }) + : t("settings.documentSources.statusConnected") + : t("settings.documentSources.statusNotConnected")} +

+ + {connection && !connection.originAllowed ? ( +

+ {t("settings.documentSources.originRemoved")} +

+ ) : null} + +
+ + setBaseUrl(e.target.value)} + placeholder={allowedOrigins[0] ?? "https://"} + maxLength={2048} + autoComplete="off" + inputMode="url" + spellCheck={false} + autoCapitalize="none" + /> +

+ {t("settings.documentSources.allowedOrigins", { + origins: allowedOrigins.join(", "), + })} +

+
+ + {isPapra ? ( +
+ + setOrganizationId(e.target.value)} + maxLength={128} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + /> +

+ {t("settings.documentSources.organizationHelp")} +

+
+ ) : null} + +
+ + setToken(e.target.value)} + placeholder={ + connection ? t("settings.documentSources.tokenSaved") : undefined + } + maxLength={512} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + /> +

+ {isPapra + ? t("settings.documentSources.tokenHelpPapra") + : t("settings.documentSources.tokenHelpPaperless")} +

+
+ + {outcome ? ( +

+ {outcome.message} +

+ ) : null} + + + {connection ? ( + + + + + + + + {t("settings.documentSources.disconnectTitle", { + name: systemName(system), + })} + + + {t("settings.documentSources.disconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()}> + {t("settings.documentSources.disconnect")} + + + + + ) : null} + {connection ? ( + + ) : null} + + +
+ ); +} diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index d1778773b..6fb195261 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -28,6 +28,8 @@ import { FolderOpen } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { EntityLinkPicker } from "@/components/links/entity-link-picker"; import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; +import { mergeIds } from "@/components/documents/sources/merge-ids"; export function VaccinationDocumentPicker({ enabled, @@ -73,6 +75,14 @@ export function VaccinationDocumentPicker({ options={vault.options} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("vaccinations.form.linkNothingToOffer")} + extraAction={ + // Joins the form's selection; the dose's save writes the links. + onChange(mergeIds(documentIds, ids))} + /> + } /> ); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 2a407a2dc..c5c8e8dc9 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -81,4 +81,27 @@ export const documentKeys = { * toggle; a flip invalidates the document AI capability probe. */ documentsAutoAiRead: () => ["documents", "auto-ai-read"] as const, + /** + * The document picker (#1038): its own top-level prefix, deliberately NOT + * under `["documents"]`. Every vault write invalidates that prefix, and a + * search or tag read here is a request to the person's Paperless-ngx or + * Papra instance; a filing action in the vault must not re-send them. + */ + documentSources: () => ["documentSources"] as const, + /** Availability + the person's connections (`GET /api/documents/sources`). */ + documentSourcesStatus: () => ["documentSources", "status"] as const, + /** One source's tags for the filter. */ + documentSourceTags: (system: string) => + ["documentSources", "tags", system] as const, + /** One page of one source's search. */ + documentSourceSearch: (system: string, search: DocumentSourceSearchKey) => + ["documentSources", "search", system, search] as const, }; + +/** The facets that make one picker search distinct from another. */ +export interface DocumentSourceSearchKey { + q: string; + tag?: string; + from?: string; + to?: string; +}