mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-26 12:09:36 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d951cc82 | ||
|
|
47f9c5b186 | ||
|
|
0b97e58785 | ||
|
|
d51779aeb8 | ||
|
|
8f30ec0281 | ||
|
|
5868800bce | ||
|
|
b5ccc135ba | ||
|
|
5e46bb9e6a |
5
.changeset/cuddly-shoes-watch.md
Normal file
5
.changeset/cuddly-shoes-watch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added feedback when an error occurs while deleting a tag
|
||||
5
.changeset/cyan-pots-begin.md
Normal file
5
.changeset/cyan-pots-begin.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-server": minor
|
||||
---
|
||||
|
||||
The file content extraction (like OCR) is now done asynchronously by the task runner
|
||||
5
.changeset/green-teeth-fall.md
Normal file
5
.changeset/green-teeth-fall.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-server": minor
|
||||
---
|
||||
|
||||
Fixed the impossibility to delete a tag that has been assigned to a document
|
||||
5
.changeset/six-worms-roll.md
Normal file
5
.changeset/six-worms-roll.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added Italian (it) language support
|
||||
5
.changeset/solid-items-warn.md
Normal file
5
.changeset/solid-items-warn.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"n8n-nodes-papra": major
|
||||
---
|
||||
|
||||
Added n8n nodes package for Papra
|
||||
41
.github/workflows/ci-packages-n8n-nodes.yaml
vendored
Normal file
41
.github/workflows/ci-packages-n8n-nodes.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI - N8N Nodes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-n8n-nodes:
|
||||
name: CI - N8N Nodes
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/n8n-nodes
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Dieser Benutzer ist bereits in dieser O
|
||||
api-errors.user.organization_invitation_limit_reached: Die maximale Anzahl an Einladungen für heute wurde erreicht. Bitte versuchen Sie es morgen erneut.
|
||||
api-errors.demo.not_available: Diese Funktion ist in der Demo nicht verfügbar
|
||||
api-errors.tags.already_exists: Ein Tag mit diesem Namen existiert bereits für diese Organisation
|
||||
api-errors.internal.error: Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: This user is already in this organizati
|
||||
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
|
||||
api-errors.demo.not_available: This feature is not available in demo
|
||||
api-errors.tags.already_exists: A tag with this name already exists for this organization
|
||||
api-errors.internal.error: An error occurred while processing your request. Please try again later.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Este usuario ya está en esta organizac
|
||||
api-errors.user.organization_invitation_limit_reached: Se ha alcanzado el número máximo de invitaciones para hoy. Por favor, inténtalo de nuevo mañana.
|
||||
api-errors.demo.not_available: Esta función no está disponible en la demostración
|
||||
api-errors.tags.already_exists: Ya existe una etiqueta con este nombre en esta organización
|
||||
api-errors.internal.error: Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette o
|
||||
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
|
||||
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
|
||||
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
|
||||
api-errors.internal.error: Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
570
apps/papra-client/src/locales/it.yml
Normal file
570
apps/papra-client/src/locales/it.yml
Normal file
@@ -0,0 +1,570 @@
|
||||
# Authentication
|
||||
|
||||
auth.request-password-reset.title: Reimposta la tua password
|
||||
auth.request-password-reset.description: Inserisci la tua email per reimpostare la password.
|
||||
auth.request-password-reset.requested: Se esiste un account per questa email, ti abbiamo inviato un'email per reimpostare la password.
|
||||
auth.request-password-reset.back-to-login: Torna al login
|
||||
auth.request-password-reset.form.email.label: Email
|
||||
auth.request-password-reset.form.email.placeholder: 'Esempio: ada@papra.app'
|
||||
auth.request-password-reset.form.email.required: Inserisci il tuo indirizzo email
|
||||
auth.request-password-reset.form.email.invalid: Questo indirizzo email non è valido
|
||||
auth.request-password-reset.form.submit: Richiedi reimpostazione password
|
||||
|
||||
auth.reset-password.title: Reimposta la tua password
|
||||
auth.reset-password.description: Inserisci la nuova password per reimpostare la password.
|
||||
auth.reset-password.reset: La tua password è stata reimpostata.
|
||||
auth.reset-password.back-to-login: Torna al login
|
||||
auth.reset-password.form.new-password.label: Nuova password
|
||||
auth.reset-password.form.new-password.placeholder: 'Esempio: **********'
|
||||
auth.reset-password.form.new-password.required: Inserisci la tua nuova password
|
||||
auth.reset-password.form.new-password.min-length: La password deve essere di almeno {{ minLength }} caratteri
|
||||
auth.reset-password.form.new-password.max-length: La password deve essere inferiore a {{ maxLength }} caratteri
|
||||
auth.reset-password.form.submit: Reimposta password
|
||||
|
||||
auth.email-provider.open: Apri {{ provider }}
|
||||
|
||||
auth.login.title: Accedi a Papra
|
||||
auth.login.description: Inserisci la tua email o usa un provider per accedere al tuo account Papra.
|
||||
auth.login.login-with-provider: Accedi con {{ provider }}
|
||||
auth.login.no-account: Non hai un account?
|
||||
auth.login.register: Registrati
|
||||
auth.login.form.email.label: Email
|
||||
auth.login.form.email.placeholder: 'Esempio: ada@papra.app'
|
||||
auth.login.form.email.required: Inserisci il tuo indirizzo email
|
||||
auth.login.form.email.invalid: Questo indirizzo email non è valido
|
||||
auth.login.form.password.label: Password
|
||||
auth.login.form.password.placeholder: Imposta una password
|
||||
auth.login.form.password.required: Inserisci la tua password
|
||||
auth.login.form.remember-me.label: Ricordami
|
||||
auth.login.form.forgot-password.label: Password dimenticata?
|
||||
auth.login.form.submit: Accedi
|
||||
|
||||
auth.register.title: Registrati a Papra
|
||||
auth.register.description: Crea un account per iniziare a usare Papra.
|
||||
auth.register.register-with-email: Registrati tramite email
|
||||
auth.register.register-with-provider: Registrati tramite {{ provider }}
|
||||
auth.register.providers.google: Google
|
||||
auth.register.providers.github: GitHub
|
||||
auth.register.have-account: Hai già un account?
|
||||
auth.register.login: Accedi
|
||||
auth.register.registration-disabled.title: Registrazione disabilitata
|
||||
auth.register.registration-disabled.description: La creazione di nuovi account è attualmente disabilitata su questa istanza di Papra. Solo gli utenti con account esistenti possono accedere. Se pensi che sia un errore, contatta l'amministratore di questa istanza.
|
||||
auth.register.form.email.label: Email
|
||||
auth.register.form.email.placeholder: 'Esempio: ada@papra.app'
|
||||
auth.register.form.email.required: Inserisci il tuo indirizzo email
|
||||
auth.register.form.email.invalid: Questo indirizzo email non è valido
|
||||
auth.register.form.password.label: Password
|
||||
auth.register.form.password.placeholder: Imposta una password
|
||||
auth.register.form.password.required: Inserisci la tua password
|
||||
auth.register.form.password.min-length: La password deve essere di almeno {{ minLength }} caratteri
|
||||
auth.register.form.password.max-length: La password deve essere inferiore a {{ maxLength }} caratteri
|
||||
auth.register.form.name.label: Nome
|
||||
auth.register.form.name.placeholder: 'Esempio: Ada Lovelace'
|
||||
auth.register.form.name.required: Inserisci il tuo nome
|
||||
auth.register.form.name.max-length: Il nome deve essere inferiore a {{ maxLength }} caratteri
|
||||
auth.register.form.submit: Registrati
|
||||
|
||||
auth.email-validation-required.title: Verifica la tua email
|
||||
auth.email-validation-required.description: Una email di verifica è stata inviata al tuo indirizzo email. Verifica il tuo indirizzo cliccando il link nell'email.
|
||||
|
||||
auth.legal-links.description: Continuando, confermi di aver letto e accettato i {{ terms }} e l'{{ privacy }}.
|
||||
auth.legal-links.terms: Termini di servizio
|
||||
auth.legal-links.privacy: Informativa sulla privacy
|
||||
|
||||
auth.no-auth-provider.title: Nessun provider di autenticazione
|
||||
auth.no-auth-provider.description: Nessun provider di autenticazione è abilitato su questa istanza di Papra. Contatta l'amministratore di questa istanza per abilitarli.
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Impostazioni utente
|
||||
user.settings.description: Gestisci qui le impostazioni del tuo account.
|
||||
|
||||
user.settings.email.title: Indirizzo email
|
||||
user.settings.email.description: Il tuo indirizzo email non può essere modificato.
|
||||
user.settings.email.label: Indirizzo email
|
||||
|
||||
user.settings.name.title: Nome completo
|
||||
user.settings.name.description: Il tuo nome completo è visibile agli altri membri dell'organizzazione.
|
||||
user.settings.name.label: Nome completo
|
||||
user.settings.name.placeholder: Es. Mario Rossi
|
||||
user.settings.name.update: Aggiorna nome
|
||||
user.settings.name.updated: Il tuo nome completo è stato aggiornato
|
||||
|
||||
user.settings.logout.title: Logout
|
||||
user.settings.logout.description: Esci dal tuo account. Potrai accedere nuovamente in seguito.
|
||||
user.settings.logout.button: Esci
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Le tue organizzazioni
|
||||
organizations.list.description: Le organizzazioni sono un modo per raggruppare i tuoi documenti e gestire l'accesso. Puoi creare più organizzazioni e invitare i tuoi collaboratori.
|
||||
organizations.list.create-new: Crea una nuova organizzazione
|
||||
|
||||
organizations.details.no-documents.title: Nessun documento
|
||||
organizations.details.no-documents.description: Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.
|
||||
organizations.details.upload-documents: Carica documenti
|
||||
organizations.details.documents-count: documenti in totale
|
||||
organizations.details.total-size: dimensione totale
|
||||
organizations.details.latest-documents: Ultimi documenti importati
|
||||
|
||||
organizations.create.title: Crea una nuova organizzazione
|
||||
organizations.create.description: I tuoi documenti saranno raggruppati per organizzazione. Puoi creare più organizzazioni per separare i documenti, ad esempio per uso personale e lavorativo.
|
||||
organizations.create.back: Indietro
|
||||
organizations.create.error.max-count-reached: Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.
|
||||
organizations.create.form.name.label: Nome organizzazione
|
||||
organizations.create.form.name.placeholder: Es. Acme Inc.
|
||||
organizations.create.form.name.required: Inserisci il nome dell'organizzazione
|
||||
organizations.create.form.submit: Crea organizzazione
|
||||
organizations.create.success: Organizzazione creata con successo
|
||||
|
||||
organizations.create-first.title: Crea la tua organizzazione
|
||||
organizations.create-first.description: I tuoi documenti saranno raggruppati per organizzazione. Puoi creare più organizzazioni per separare i documenti, ad esempio per uso personale e lavorativo.
|
||||
organizations.create-first.default-name: La mia organizzazione
|
||||
organizations.create-first.user-name: 'Organizzazione di {{ name }}'
|
||||
|
||||
organization.settings.title: Impostazioni organizzazione
|
||||
organization.settings.page.title: Impostazioni organizzazione
|
||||
organization.settings.page.description: Gestisci qui le impostazioni della tua organizzazione.
|
||||
organization.settings.name.title: Nome organizzazione
|
||||
organization.settings.name.update: Aggiorna nome
|
||||
organization.settings.name.placeholder: Es. Acme Inc.
|
||||
organization.settings.name.updated: Nome organizzazione aggiornato
|
||||
organization.settings.subscription.title: Sottoscrizione
|
||||
organization.settings.subscription.description: Gestisci fatturazione, fatture e metodi di pagamento.
|
||||
organization.settings.subscription.manage: Gestisci sottoscrizione
|
||||
organization.settings.subscription.error: Impossibile ottenere l'URL del portale clienti
|
||||
organization.settings.delete.title: Elimina organizzazione
|
||||
organization.settings.delete.description: Eliminando questa organizzazione rimuoverai definitivamente tutti i dati associati.
|
||||
organization.settings.delete.confirm.title: Elimina organizzazione
|
||||
organization.settings.delete.confirm.message: Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata e tutti i dati associati saranno rimossi in modo permanente.
|
||||
organization.settings.delete.confirm.confirm-button: Elimina organizzazione
|
||||
organization.settings.delete.confirm.cancel-button: Annulla
|
||||
organization.settings.delete.success: Organizzazione eliminata
|
||||
|
||||
organizations.members.title: Membri
|
||||
organizations.members.description: Gestisci i membri della tua organizzazione
|
||||
organizations.members.invite-member: Invita membro
|
||||
organizations.members.invite-member-disabled-tooltip: Solo gli amministratori o i proprietari possono invitare membri nell'organizzazione
|
||||
organizations.members.remove-from-organization: Rimuovi dall'organizzazione
|
||||
organizations.members.role: Ruolo
|
||||
organizations.members.roles.owner: Proprietario
|
||||
organizations.members.roles.admin: Amministratore
|
||||
organizations.members.roles.member: Membro
|
||||
organizations.members.delete.confirm.title: Rimuovi membro
|
||||
organizations.members.delete.confirm.message: Sei sicuro di voler rimuovere questo membro dall'organizzazione?
|
||||
organizations.members.delete.confirm.confirm-button: Rimuovi
|
||||
organizations.members.delete.confirm.cancel-button: Annulla
|
||||
organizations.members.delete.success: Membro rimosso dall'organizzazione
|
||||
organizations.members.update-role.success: Ruolo del membro aggiornato
|
||||
organizations.members.table.headers.name: Nome
|
||||
organizations.members.table.headers.email: Email
|
||||
organizations.members.table.headers.role: Ruolo
|
||||
organizations.members.table.headers.created: Creato
|
||||
organizations.members.table.headers.actions: Azioni
|
||||
|
||||
organizations.invite-member.title: Invita membro
|
||||
organizations.invite-member.description: Invita un membro nella tua organizzazione
|
||||
organizations.invite-member.form.email.label: Email
|
||||
organizations.invite-member.form.email.placeholder: 'Esempio: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Inserisci un indirizzo email valido
|
||||
organizations.invite-member.form.role.label: Ruolo
|
||||
organizations.invite-member.form.submit: Invita nell'organizzazione
|
||||
organizations.invite-member.success.message: Membro invitato
|
||||
organizations.invite-member.success.description: Il membro è stato invitato nell'organizzazione.
|
||||
organizations.invite-member.error.message: Impossibile invitare il membro
|
||||
|
||||
organizations.invitations.title: Inviti
|
||||
organizations.invitations.description: Gestisci gli inviti della tua organizzazione
|
||||
organizations.invitations.list.cta: Invita membro
|
||||
organizations.invitations.list.empty.title: Nessun invito in sospeso
|
||||
organizations.invitations.list.empty.description: Non sei stato ancora invitato in nessuna organizzazione.
|
||||
organizations.invitations.status.pending: In sospeso
|
||||
organizations.invitations.status.accepted: Accettato
|
||||
organizations.invitations.status.rejected: Rifiutato
|
||||
organizations.invitations.status.expired: Scaduto
|
||||
organizations.invitations.status.cancelled: Cancellato
|
||||
organizations.invitations.resend: Invia di nuovo invito
|
||||
organizations.invitations.cancel.title: Annulla invito
|
||||
organizations.invitations.cancel.description: Sei sicuro di voler annullare questo invito?
|
||||
organizations.invitations.cancel.confirm: Annulla invito
|
||||
organizations.invitations.cancel.cancel: Annulla
|
||||
organizations.invitations.resend.title: Invia di nuovo invito
|
||||
organizations.invitations.resend.description: Sei sicuro di voler inviare nuovamente questo invito? Sarà inviata una nuova email al destinatario.
|
||||
organizations.invitations.resend.confirm: Invia invito
|
||||
organizations.invitations.resend.cancel: Annulla
|
||||
|
||||
invitations.list.title: Inviti
|
||||
invitations.list.description: Gestisci gli inviti della tua organizzazione
|
||||
invitations.list.empty.title: Nessun invito in sospeso
|
||||
invitations.list.empty.description: Non sei stato ancora invitato in nessuna organizzazione.
|
||||
invitations.list.headers.organization: Organizzazione
|
||||
invitations.list.headers.status: Stato
|
||||
invitations.list.headers.created: Creato
|
||||
invitations.list.headers.actions: Azioni
|
||||
invitations.list.actions.accept: Accetta
|
||||
invitations.list.actions.reject: Rifiuta
|
||||
invitations.list.actions.accept.success.message: Invito accettato
|
||||
invitations.list.actions.accept.success.description: L'invito è stato accettato.
|
||||
invitations.list.actions.reject.success.message: Invito rifiutato
|
||||
invitations.list.actions.reject.success.description: L'invito è stato rifiutato.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Documenti
|
||||
documents.list.no-documents.title: Nessun documento
|
||||
documents.list.no-documents.description: Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.
|
||||
documents.list.no-results: Nessun documento trovato
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Contenuto
|
||||
documents.tabs.activity: Attività
|
||||
documents.deleted.message: Questo documento è stato eliminato e sarà rimosso definitivamente tra {{ days }} giorni.
|
||||
documents.actions.download: Scarica
|
||||
documents.actions.open-in-new-tab: Apri in una nuova scheda
|
||||
documents.actions.restore: Ripristina
|
||||
documents.actions.delete: Elimina
|
||||
documents.actions.edit: Modifica
|
||||
documents.actions.cancel: Annulla
|
||||
documents.actions.save: Salva
|
||||
documents.actions.saving: Salvataggio in corso...
|
||||
documents.content.alert: Il contenuto del documento è estratto automaticamente al caricamento. È usato solo per la ricerca e l'indicizzazione.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Nome
|
||||
documents.info.type: Tipo
|
||||
documents.info.size: Dimensione
|
||||
documents.info.created-at: Creato il
|
||||
documents.info.updated-at: Aggiornato il
|
||||
documents.info.never: Mai
|
||||
|
||||
documents.rename.title: Rinomina documento
|
||||
documents.rename.form.name.label: Nome
|
||||
documents.rename.form.name.placeholder: 'Esempio: Fattura 2024'
|
||||
documents.rename.form.name.required: Inserisci un nome per il documento
|
||||
documents.rename.form.name.max-length: Il nome deve essere inferiore a 255 caratteri
|
||||
documents.rename.form.submit: Rinomina documento
|
||||
documents.rename.success: Documento rinominato con successo
|
||||
documents.rename.cancel: Annulla
|
||||
|
||||
import-documents.title.error: '{{ count }} documenti non importati'
|
||||
import-documents.title.success: '{{ count }} documenti importati'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} documenti importati'
|
||||
import-documents.title.none: Importa documenti
|
||||
import-documents.no-import-in-progress: Nessuna importazione documenti in corso
|
||||
|
||||
documents.deleted.title: Documenti eliminati
|
||||
documents.deleted.empty.title: Nessun documento eliminato
|
||||
documents.deleted.empty.description: Non hai documenti eliminati. I documenti eliminati saranno spostati nel cestino per {{ days }} giorni.
|
||||
documents.deleted.retention-notice: Tutti i documenti eliminati sono conservati nel cestino per {{ days }} giorni. Passato questo periodo, saranno eliminati definitivamente e non potrai recuperarli.
|
||||
documents.deleted.deleted-at: Eliminato il
|
||||
documents.deleted.restoring: Ripristino in corso...
|
||||
documents.deleted.deleting: Eliminazione in corso...
|
||||
|
||||
documents.preview.unknown-file-type: Nessuna anteprima disponibile per questo tipo di file
|
||||
documents.preview.binary-file: Sembra essere un file binario e non può essere visualizzato come testo
|
||||
|
||||
trash.delete-all.button: Elimina tutto
|
||||
trash.delete-all.confirm.title: Eliminare definitivamente tutti i documenti?
|
||||
trash.delete-all.confirm.description: Sei sicuro di voler eliminare definitivamente tutti i documenti dal cestino? Questa azione non può essere annullata.
|
||||
trash.delete-all.confirm.label: Elimina
|
||||
trash.delete-all.confirm.cancel: Annulla
|
||||
trash.delete.button: Elimina
|
||||
trash.delete.confirm.title: Eliminare definitivamente il documento?
|
||||
trash.delete.confirm.description: Sei sicuro di voler eliminare definitivamente questo documento dal cestino? Questa azione non può essere annullata.
|
||||
trash.delete.confirm.label: Elimina
|
||||
trash.delete.confirm.cancel: Annulla
|
||||
trash.deleted.success.title: Documento eliminato
|
||||
trash.deleted.success.description: Il documento è stato eliminato definitivamente.
|
||||
|
||||
activity.document.created: Documento creato
|
||||
activity.document.updated.single: Il campo {{ field }} è stato aggiornato
|
||||
activity.document.updated.multiple: I campi {{ fields }} sono stati aggiornati
|
||||
activity.document.updated: Documento aggiornato
|
||||
activity.document.deleted: Documento eliminato
|
||||
activity.document.restored: Documento ripristinato
|
||||
activity.document.tagged: Tag {{ tag }} aggiunto
|
||||
activity.document.untagged: Tag {{ tag }} rimosso
|
||||
|
||||
activity.document.user.name: da {{ name }}
|
||||
|
||||
activity.load-more: Carica altri
|
||||
activity.no-more-activities: Nessuna altra attività per questo documento
|
||||
|
||||
# Tags
|
||||
|
||||
tags.no-tags.title: Nessun tag
|
||||
tags.no-tags.description: Questa organizzazione non ha ancora tag. I tag vengono usati per categorizzare i documenti. Puoi aggiungere tag ai tuoi documenti per trovarli e organizzarli più facilmente.
|
||||
tags.no-tags.create-tag: Crea tag
|
||||
|
||||
tags.title: Tag dei documenti
|
||||
tags.description: I tag vengono usati per categorizzare i documenti. Puoi aggiungere tag ai tuoi documenti per trovarli e organizzarli più facilmente.
|
||||
tags.create: Crea tag
|
||||
tags.update: Aggiorna tag
|
||||
tags.delete: Elimina tag
|
||||
tags.delete.confirm.title: Elimina tag
|
||||
tags.delete.confirm.message: Sei sicuro di voler eliminare questo tag? Il tag verrà rimosso da tutti i documenti.
|
||||
tags.delete.confirm.confirm-button: Elimina
|
||||
tags.delete.confirm.cancel-button: Annulla
|
||||
tags.delete.success: Tag eliminato con successo
|
||||
tags.create.success: Tag "{{ name }}" creato con successo.
|
||||
tags.update.success: Tag "{{ name }}" aggiornato con successo.
|
||||
tags.form.name.label: Nome
|
||||
tags.form.name.placeholder: Es. Contratti
|
||||
tags.form.name.required: Inserisci un nome per il tag
|
||||
tags.form.name.max-length: Il nome del tag deve essere inferiore a 64 caratteri
|
||||
tags.form.color.label: Colore
|
||||
tags.form.color.required: Inserisci un colore
|
||||
tags.form.color.invalid: Il colore hex non è formattato correttamente.
|
||||
tags.form.description.label: Descrizione
|
||||
tags.form.description.optional: (opzionale)
|
||||
tags.form.description.placeholder: Es. Tutti i contratti firmati dall'azienda
|
||||
tags.form.description.max-length: La descrizione deve essere inferiore a 256 caratteri
|
||||
tags.form.no-description: Nessuna descrizione
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Descrizione
|
||||
tags.table.headers.documents: Documenti
|
||||
tags.table.headers.created: Creato
|
||||
tags.table.headers.actions: Azioni
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: nome documento
|
||||
tagging-rules.field.content: contenuto documento
|
||||
tagging-rules.operator.equals: uguale a
|
||||
tagging-rules.operator.not-equals: diverso da
|
||||
tagging-rules.operator.contains: contiene
|
||||
tagging-rules.operator.not-contains: non contiene
|
||||
tagging-rules.operator.starts-with: inizia con
|
||||
tagging-rules.operator.ends-with: termina con
|
||||
tagging-rules.list.title: Regole di tagging
|
||||
tagging-rules.list.description: Gestisci le regole di tagging della tua organizzazione per taggare automaticamente i documenti in base a condizioni definite da te.
|
||||
tagging-rules.list.demo-warning: 'Nota: Essendo un ambiente demo (senza server), le regole di tagging non verranno applicate ai nuovi documenti.'
|
||||
tagging-rules.list.no-tagging-rules.title: Nessuna regola di tagging
|
||||
tagging-rules.list.no-tagging-rules.description: Crea una regola per taggare automaticamente i documenti aggiunti in base a condizioni definite da te.
|
||||
tagging-rules.list.no-tagging-rules.create-tagging-rule: Crea regola di tagging
|
||||
tagging-rules.list.card.no-conditions: Nessuna condizione
|
||||
tagging-rules.list.card.one-condition: 1 condizione
|
||||
tagging-rules.list.card.conditions: '{{ count }} condizioni'
|
||||
tagging-rules.list.card.delete: Elimina regola
|
||||
tagging-rules.list.card.edit: Modifica regola
|
||||
tagging-rules.create.title: Crea regola di tagging
|
||||
tagging-rules.create.success: Regola di tagging creata con successo
|
||||
tagging-rules.create.error: Errore nella creazione della regola di tagging
|
||||
tagging-rules.create.submit: Crea regola
|
||||
tagging-rules.form.name.label: Nome
|
||||
tagging-rules.form.name.placeholder: 'Esempio: Tagga fatture'
|
||||
tagging-rules.form.name.min-length: Inserisci un nome per la regola
|
||||
tagging-rules.form.name.max-length: Il nome deve essere inferiore a 64 caratteri
|
||||
tagging-rules.form.description.label: Descrizione
|
||||
tagging-rules.form.description.placeholder: "Esempio: Tagga i documenti con 'fattura' nel nome"
|
||||
tagging-rules.form.description.max-length: La descrizione deve essere inferiore a 256 caratteri
|
||||
tagging-rules.form.conditions.label: Condizioni
|
||||
tagging-rules.form.conditions.description: Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Tutte le condizioni devono essere soddisfatte.
|
||||
tagging-rules.form.conditions.add-condition: Aggiungi condizione
|
||||
tagging-rules.form.conditions.no-conditions.title: Nessuna condizione
|
||||
tagging-rules.form.conditions.no-conditions.description: Non hai aggiunto nessuna condizione a questa regola. Questa regola applicherà i suoi tag a tutti i documenti.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Applica regola senza condizioni
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Annulla
|
||||
tagging-rules.form.conditions.value.placeholder: 'Esempio: fattura'
|
||||
tagging-rules.form.conditions.value.min-length: Inserisci un valore per la condizione
|
||||
tagging-rules.form.tags.label: Tag
|
||||
tagging-rules.form.tags.description: Seleziona i tag da applicare ai documenti che soddisfano le condizioni
|
||||
tagging-rules.form.tags.min-length: È richiesto almeno un tag da applicare
|
||||
tagging-rules.form.tags.add-tag: Crea tag
|
||||
tagging-rules.form.submit: Crea regola
|
||||
tagging-rules.update.title: Aggiorna regola di tagging
|
||||
tagging-rules.update.error: Errore nell'aggiornamento della regola di tagging
|
||||
tagging-rules.update.submit: Aggiorna regola
|
||||
tagging-rules.update.cancel: Annulla
|
||||
|
||||
# Intake emails
|
||||
|
||||
intake-emails.title: Email di acquisizione
|
||||
intake-emails.description: Gli indirizzi email di acquisizione vengono usati per importare automaticamente email in Papra. Basta inoltrare le email all'indirizzo di acquisizione e gli allegati saranno aggiunti ai documenti dell'organizzazione.
|
||||
intake-emails.disabled.title: Email di acquisizione disabilitate
|
||||
intake-emails.disabled.description: Le email di acquisizione sono disabilitate su questa istanza. Contatta il tuo amministratore per abilitarle. Consulta la {{ documentation }} per maggiori informazioni.
|
||||
intake-emails.disabled.documentation: documentazione
|
||||
intake-emails.info: Solo le email di acquisizione abilitate provenienti da origini consentite saranno processate. Puoi abilitare o disabilitare un'email di acquisizione in qualsiasi momento.
|
||||
intake-emails.empty.title: Nessuna email di acquisizione
|
||||
intake-emails.empty.description: Genera un indirizzo di acquisizione per importare facilmente allegati email.
|
||||
intake-emails.empty.generate: Genera email di acquisizione
|
||||
intake-emails.count: '{{ count }} email di acquisizione per questa organizzazione'
|
||||
intake-emails.new: Nuova email di acquisizione
|
||||
intake-emails.disabled-label: (Disabilitata)
|
||||
intake-emails.no-origins: Nessuna origine email consentita
|
||||
intake-emails.allowed-origins: Consentito da {{ count }} indirizzo/i
|
||||
intake-emails.actions.enable: Abilita
|
||||
intake-emails.actions.disable: Disabilita
|
||||
intake-emails.actions.manage-origins: Gestisci indirizzi origine
|
||||
intake-emails.actions.delete: Elimina
|
||||
intake-emails.delete.confirm.title: Eliminare l'email di acquisizione?
|
||||
intake-emails.delete.confirm.message: Sei sicuro di voler eliminare questa email di acquisizione? Questa azione non può essere annullata.
|
||||
intake-emails.delete.confirm.confirm-button: Elimina email di acquisizione
|
||||
intake-emails.delete.confirm.cancel-button: Annulla
|
||||
intake-emails.delete.success: Email di acquisizione eliminata
|
||||
intake-emails.create.success: Email di acquisizione creata
|
||||
intake-emails.update.success.enabled: Email di acquisizione abilitata
|
||||
intake-emails.update.success.disabled: Email di acquisizione disabilitata
|
||||
intake-emails.allowed-origins.title: Origini consentite
|
||||
intake-emails.allowed-origins.description: Solo le email inviate a {{ email }} da queste origini saranno processate. Se non sono specificate origini, tutte le email saranno scartate.
|
||||
intake-emails.allowed-origins.add.label: Aggiungi email origine consentita
|
||||
intake-emails.allowed-origins.add.placeholder: Es. ada@papra.app
|
||||
intake-emails.allowed-origins.add.button: Aggiungi
|
||||
intake-emails.allowed-origins.add.error.exists: Questa email è già tra le origini consentite per questa email di acquisizione
|
||||
|
||||
# API keys
|
||||
|
||||
api-keys.permissions.documents.title: Documenti
|
||||
api-keys.permissions.documents.documents:create: Crea documenti
|
||||
api-keys.permissions.documents.documents:read: Leggi documenti
|
||||
api-keys.permissions.documents.documents:update: Aggiorna documenti
|
||||
api-keys.permissions.documents.documents:delete: Elimina documenti
|
||||
api-keys.permissions.tags.title: Tag
|
||||
api-keys.permissions.tags.tags:create: Crea tag
|
||||
api-keys.permissions.tags.tags:read: Leggi tag
|
||||
api-keys.permissions.tags.tags:update: Aggiorna tag
|
||||
api-keys.permissions.tags.tags:delete: Elimina tag
|
||||
api-keys.create.title: Crea chiave API
|
||||
api-keys.create.description: Crea una nuova chiave API per accedere all'API di Papra.
|
||||
api-keys.create.success: La chiave API è stata creata con successo.
|
||||
api-keys.create.back: Torna alle chiavi API
|
||||
api-keys.create.form.name.label: Nome
|
||||
api-keys.create.form.name.placeholder: 'Esempio: La mia chiave API'
|
||||
api-keys.create.form.name.required: Inserisci un nome per la chiave API
|
||||
api-keys.create.form.permissions.label: Permessi
|
||||
api-keys.create.form.permissions.required: Seleziona almeno un permesso
|
||||
api-keys.create.form.submit: Crea chiave API
|
||||
api-keys.create.created.title: Chiave API creata
|
||||
api-keys.create.created.description: La chiave API è stata creata con successo. Salvala in un luogo sicuro, non verrà più mostrata.
|
||||
api-keys.list.title: Chiavi API
|
||||
api-keys.list.description: Gestisci qui le tue chiavi API.
|
||||
api-keys.list.create: Crea chiave API
|
||||
api-keys.list.empty.title: Nessuna chiave API
|
||||
api-keys.list.empty.description: Crea una chiave API per accedere all'API di Papra.
|
||||
api-keys.list.card.last-used: Ultimo utilizzo
|
||||
api-keys.list.card.never: Mai
|
||||
api-keys.list.card.created: Creato
|
||||
api-keys.delete.success: La chiave API è stata eliminata con successo
|
||||
api-keys.delete.confirm.title: Eliminare la chiave API
|
||||
api-keys.delete.confirm.message: Sei sicuro di voler eliminare questa chiave API? Questa azione non può essere annullata.
|
||||
api-keys.delete.confirm.confirm-button: Elimina
|
||||
api-keys.delete.confirm.cancel-button: Annulla
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhook
|
||||
webhooks.list.description: Gestisci i webhook della tua organizzazione
|
||||
webhooks.list.empty.title: Nessun webhook
|
||||
webhooks.list.empty.description: Crea il tuo primo webhook per iniziare a ricevere eventi
|
||||
webhooks.list.create: Crea webhook
|
||||
webhooks.list.card.last-triggered: Ultima attivazione
|
||||
webhooks.list.card.never: Mai
|
||||
webhooks.list.card.created: Creato
|
||||
webhooks.create.title: Crea webhook
|
||||
webhooks.create.description: Crea un nuovo webhook per ricevere eventi
|
||||
webhooks.create.success: Webhook creato con successo
|
||||
webhooks.create.back: Indietro
|
||||
webhooks.create.form.submit: Crea webhook
|
||||
webhooks.create.form.name.label: Nome webhook
|
||||
webhooks.create.form.name.placeholder: Inserisci nome webhook
|
||||
webhooks.create.form.name.required: Il nome è obbligatorio
|
||||
webhooks.create.form.url.label: URL webhook
|
||||
webhooks.create.form.url.placeholder: Inserisci URL webhook
|
||||
webhooks.create.form.url.required: L'URL è obbligatorio
|
||||
webhooks.create.form.url.invalid: L'URL non è valido
|
||||
webhooks.create.form.secret.label: Segreto
|
||||
webhooks.create.form.secret.placeholder: Inserisci il segreto del webhook
|
||||
webhooks.create.form.events.label: Eventi
|
||||
webhooks.create.form.events.required: È richiesto almeno un evento
|
||||
webhooks.update.title: Modifica webhook
|
||||
webhooks.update.description: Aggiorna i dettagli del webhook
|
||||
webhooks.update.success: Webhook aggiornato con successo
|
||||
webhooks.update.submit: Aggiorna webhook
|
||||
webhooks.update.cancel: Annulla
|
||||
webhooks.update.form.secret.placeholder: Inserisci nuovo segreto
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Segreto nascosto]'
|
||||
webhooks.update.form.rotate-secret.button: Rigenera segreto
|
||||
webhooks.delete.success: Webhook eliminato con successo
|
||||
webhooks.delete.confirm.title: Eliminare webhook
|
||||
webhooks.delete.confirm.message: Sei sicuro di voler eliminare questo webhook?
|
||||
webhooks.delete.confirm.confirm-button: Elimina
|
||||
webhooks.delete.confirm.cancel-button: Annulla
|
||||
|
||||
webhooks.events.documents.title: Eventi documenti
|
||||
webhooks.events.documents.document:created.description: Documento creato
|
||||
webhooks.events.documents.document:deleted.description: Documento eliminato
|
||||
webhooks.events.documents.document:updated.description: Documento aggiornato
|
||||
webhooks.events.documents.document:tag:added.description: Un tag è stato aggiunto a un documento
|
||||
webhooks.events.documents.document:tag:removed.description: Un tag è stato rimosso da un documento
|
||||
|
||||
# Navigation
|
||||
|
||||
layout.menu.home: Home
|
||||
layout.menu.documents: Documenti
|
||||
layout.menu.tags: Tag
|
||||
layout.menu.tagging-rules: Regole di tagging
|
||||
layout.menu.deleted-documents: Documenti eliminati
|
||||
layout.menu.organization-settings: Impostazioni
|
||||
layout.menu.api-keys: Chiavi API
|
||||
layout.menu.settings: Impostazioni
|
||||
layout.menu.account: Account
|
||||
layout.menu.general-settings: Impostazioni generali
|
||||
layout.menu.intake-emails: Email di acquisizione
|
||||
layout.menu.webhooks: Webhook
|
||||
layout.menu.members: Membri
|
||||
layout.menu.invitations: Inviti
|
||||
|
||||
layout.theme.light: Modalità chiara
|
||||
layout.theme.dark: Modalità scura
|
||||
layout.theme.system: Modalità sistema
|
||||
|
||||
layout.search.placeholder: Cerca...
|
||||
layout.menu.import-document: Importa un documento
|
||||
|
||||
user-menu.account-settings: Impostazioni account
|
||||
user-menu.api-keys: Chiavi API
|
||||
user-menu.invitations: Inviti
|
||||
user-menu.language: Lingua
|
||||
user-menu.logout: Esci
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Cerca comandi o documenti
|
||||
command-palette.no-results: Nessun risultato trovato
|
||||
command-palette.sections.documents: Documenti
|
||||
command-palette.sections.theme: Tema
|
||||
|
||||
# API errors
|
||||
|
||||
api-errors.document.already_exists: Il documento esiste già
|
||||
api-errors.document.file_too_big: Il file del documento è troppo grande
|
||||
api-errors.intake_email.limit_reached: È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.
|
||||
api-errors.user.max_organization_count_reached: Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.
|
||||
api-errors.default: Si è verificato un errore durante l'elaborazione della richiesta.
|
||||
api-errors.organization.invitation_already_exists: Esiste già un invito per questa email in questa organizzazione.
|
||||
api-errors.user.already_in_organization: Questo utente è già in questa organizzazione.
|
||||
api-errors.user.organization_invitation_limit_reached: È stato raggiunto il numero massimo di inviti per oggi. Riprova domani.
|
||||
api-errors.demo.not_available: Questa funzionalità non è disponibile nella demo
|
||||
api-errors.tags.already_exists: Esiste già un tag con questo nome per questa organizzazione
|
||||
api-errors.internal.error: Si è verificato un errore durante l'elaborazione della richiesta. Riprova.
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Non trovato
|
||||
not-found.description: Spiacenti, la pagina che stai cercando non sembra esistere. Controlla l'URL e riprova.
|
||||
not-found.back-to-home: Torna alla home
|
||||
|
||||
# Demo
|
||||
|
||||
demo.popup.description: Questo è un ambiente demo, tutti i dati vengono salvati nello storage locale del browser.
|
||||
demo.popup.discord: Unisciti a {{ discordLink }} per ricevere supporto, proporre funzionalità o semplicemente fare due chiacchiere.
|
||||
demo.popup.discord-link-label: Server Discord
|
||||
demo.popup.reset: Reimposta dati demo
|
||||
demo.popup.hide: Nascondi
|
||||
|
||||
# Color picker
|
||||
|
||||
color-picker.hue: Tonalità
|
||||
color-picker.saturation: Saturazione
|
||||
color-picker.lightness: Luminosità
|
||||
color-picker.select-color: Seleziona colore
|
||||
color-picker.select-a-color: Seleziona un colore
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Ten użytkownik należy już do tej org
|
||||
api-errors.user.organization_invitation_limit_reached: Osiągnięto maksymalną liczbę zaproszeń na dzisiaj. Spróbuj ponownie jutro.
|
||||
api-errors.demo.not_available: Ta funkcja nie jest dostępna w wersji demo
|
||||
api-errors.tags.already_exists: Tag o tej nazwie już istnieje w tej organizacji
|
||||
api-errors.internal.error: Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Este usuário já faz parte desta organ
|
||||
api-errors.user.organization_invitation_limit_reached: O número máximo de convites por hoje foi atingido. Por favor, tente novamente amanhã.
|
||||
api-errors.demo.not_available: Este recurso não está disponível em ambiente de demonstração
|
||||
api-errors.tags.already_exists: Já existe uma tag com este nome nesta organização
|
||||
api-errors.internal.error: Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Este utilizadpr já faz parte desta org
|
||||
api-errors.user.organization_invitation_limit_reached: O número máximo de convites por hoje foi atingido. Por favor, tente novamente amanhã.
|
||||
api-errors.demo.not_available: Este recurso não está disponível em ambiente de demonstração
|
||||
api-errors.tags.already_exists: Já existe uma etiqueta com este nome nesta organização
|
||||
api-errors.internal.error: Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ api-errors.user.already_in_organization: Acest utilizator este deja în această
|
||||
api-errors.user.organization_invitation_limit_reached: Numărul maxim de invitații a fost atins pentru astazi. Te rugăm să încerci din nou mâine.
|
||||
api-errors.demo.not_available: Această functie nu este disponibila în demo
|
||||
api-errors.tags.already_exists: O etichetă cu acest nume există deja pentru aceasta organizație
|
||||
api-errors.internal.error: A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.
|
||||
|
||||
# Not found
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ export const locales = [
|
||||
{ key: 'pl', name: 'Polski' },
|
||||
{ key: 'ro', name: 'Română' },
|
||||
{ key: 'es', name: 'Español' },
|
||||
{ key: 'it', name: 'Italiano' },
|
||||
] as const;
|
||||
|
||||
@@ -484,6 +484,7 @@ export type LocaleKeys =
|
||||
| 'api-errors.user.organization_invitation_limit_reached'
|
||||
| 'api-errors.demo.not_available'
|
||||
| 'api-errors.tags.already_exists'
|
||||
| 'api-errors.internal.error'
|
||||
| 'not-found.title'
|
||||
| 'not-found.description'
|
||||
| 'not-found.back-to-home'
|
||||
|
||||
@@ -228,6 +228,7 @@ export const TagsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
@@ -252,10 +253,19 @@ export const TagsPage: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteTag({
|
||||
const [, error] = await safely(deleteTag({
|
||||
organizationId: params.organizationId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
createToast({
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_document_activity_log` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`document_id` text NOT NULL,
|
||||
`event` text NOT NULL,
|
||||
`event_data` text,
|
||||
`user_id` text,
|
||||
`tag_id` text,
|
||||
FOREIGN KEY (`document_id`) REFERENCES `documents`(`id`) ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE set null,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE cascade ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_document_activity_log`("id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id") SELECT "id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id" FROM `document_activity_log`;--> statement-breakpoint
|
||||
DROP TABLE `document_activity_log`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_document_activity_log` RENAME TO `document_activity_log`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
1970
apps/papra-server/migrations/meta/0007_snapshot.json
Normal file
1970
apps/papra-server/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1748554484124,
|
||||
"tag": "0006_document-activity-log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1754086182584,
|
||||
"tag": "0007_document-activity-log-on-delete-set-null",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"@aws-sdk/client-s3": "^3.835.0",
|
||||
"@aws-sdk/lib-storage": "^3.835.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@cadence-mq/core": "^0.1.0",
|
||||
"@cadence-mq/driver-memory": "^0.1.0",
|
||||
"@cadence-mq/core": "^0.2.1",
|
||||
"@cadence-mq/driver-memory": "^0.2.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/friendly-ids": "^0.0.1",
|
||||
"@crowlog/async-context-plugin": "^1.2.1",
|
||||
|
||||
@@ -30,6 +30,7 @@ const server = serve(
|
||||
|
||||
if (config.ingestionFolder.isEnabled) {
|
||||
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
|
||||
taskServices,
|
||||
config,
|
||||
db,
|
||||
});
|
||||
|
||||
@@ -66,7 +66,6 @@ describe('api-key e2e', () => {
|
||||
originalSha256Hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
||||
name: 'invoice.txt',
|
||||
mimeType: 'text/plain',
|
||||
content: 'test',
|
||||
});
|
||||
|
||||
const fetchDocumentResponse = await app.request(`/api/organizations/org_222222222222222222222222/documents/${document.id}`, {
|
||||
|
||||
@@ -15,6 +15,6 @@ export const documentActivityLogTable = sqliteTable('document_activity_log', {
|
||||
event: text('event', { enum: DOCUMENT_ACTIVITY_EVENT_LIST as NonEmptyArray<DocumentActivityEvent> }).notNull(),
|
||||
eventData: text('event_data', { mode: 'json' }).$type<Record<string, unknown>>(),
|
||||
|
||||
userId: text('user_id').references(() => usersTable.id, { onDelete: 'no action', onUpdate: 'cascade' }),
|
||||
tagId: text('tag_id').references(() => tagsTable.id, { onDelete: 'no action', onUpdate: 'cascade' }),
|
||||
userId: text('user_id').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }),
|
||||
tagId: text('tag_id').references(() => tagsTable.id, { onDelete: 'set null', onUpdate: 'cascade' }),
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export function registerDocumentsRoutes(context: RouteDefinitionContext) {
|
||||
setupUpdateDocumentRoute(context);
|
||||
}
|
||||
|
||||
function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDefinitionContext) {
|
||||
function setupCreateDocumentRoute({ app, config, db, trackingServices, taskServices }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
'/api/organizations/:organizationId/documents',
|
||||
requireAuthentication({ apiKeyPermissions: ['documents:create'] }),
|
||||
@@ -93,6 +93,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
config,
|
||||
taskServices,
|
||||
trackingServices,
|
||||
ocrLanguages,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Logger } from '@crowlog/logger';
|
||||
import { extractTextFromFile } from '@papra/lecture';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export async function getFileSha256Hash({ file }: { file: File }) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const hash = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
@@ -9,3 +13,23 @@ export async function getFileSha256Hash({ file }: { file: File }) {
|
||||
hash: hashHex,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractDocumentText({
|
||||
file,
|
||||
ocrLanguages,
|
||||
logger = createLogger({ namespace: 'documents:services' }),
|
||||
}: {
|
||||
file: File;
|
||||
ocrLanguages?: string[];
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const { textContent, error, extractorName } = await extractTextFromFile({ file, config: { tesseract: { languages: ocrLanguages } } });
|
||||
|
||||
if (error) {
|
||||
logger.error({ error, extractorName }, 'Error while extracting text from document');
|
||||
}
|
||||
|
||||
return {
|
||||
text: textContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { nextTick } from '../shared/async/defer.test-utils';
|
||||
import { collectReadableStreamToString } from '../shared/streams/readable-stream';
|
||||
import { documentsTagsTable } from '../tags/tags.table';
|
||||
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
|
||||
import { documentActivityLogTable } from './document-activity/document-activity.table';
|
||||
import { createDocumentAlreadyExistsError } from './documents.errors';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
import { documentsTable } from './documents.table';
|
||||
import { createDocumentCreationUsecase } from './documents.usecases';
|
||||
import { createDocumentCreationUsecase, extractAndSaveDocumentFileContent } from './documents.usecases';
|
||||
import { createDocumentStorageService } from './storage/documents.storage.services';
|
||||
|
||||
describe('documents usecases', () => {
|
||||
describe('createDocument', () => {
|
||||
test('creating a document save the file to the storage and registers a record in the db', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
@@ -32,6 +34,7 @@ describe('documents usecases', () => {
|
||||
config,
|
||||
generateDocumentId: () => 'doc_1',
|
||||
documentsStorageService,
|
||||
taskServices,
|
||||
});
|
||||
|
||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||
@@ -70,6 +73,7 @@ describe('documents usecases', () => {
|
||||
});
|
||||
|
||||
test('in the same organization, we should not be able to have two documents with the same content, an error is raised if the document already exists', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
@@ -89,6 +93,7 @@ describe('documents usecases', () => {
|
||||
config,
|
||||
generateDocumentId: () => `doc_${documentIdIndex++}`,
|
||||
documentsStorageService,
|
||||
taskServices,
|
||||
});
|
||||
|
||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||
@@ -178,6 +183,8 @@ describe('documents usecases', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
|
||||
const config = overrideConfig({
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
@@ -186,6 +193,7 @@ describe('documents usecases', () => {
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
config,
|
||||
taskServices,
|
||||
});
|
||||
|
||||
// 3. Re-create the document
|
||||
@@ -219,6 +227,7 @@ describe('documents usecases', () => {
|
||||
});
|
||||
|
||||
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
@@ -243,6 +252,7 @@ describe('documents usecases', () => {
|
||||
throw new Error('Macron, explosion!');
|
||||
},
|
||||
},
|
||||
taskServices,
|
||||
});
|
||||
|
||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||
@@ -267,6 +277,7 @@ describe('documents usecases', () => {
|
||||
});
|
||||
|
||||
test('when a document is created by a user, a document activity log is registered with the user id', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
@@ -283,6 +294,7 @@ describe('documents usecases', () => {
|
||||
db,
|
||||
config,
|
||||
generateDocumentId: () => `doc_${documentIdIndex++}`,
|
||||
taskServices,
|
||||
});
|
||||
|
||||
await createDocument({
|
||||
@@ -317,4 +329,53 @@ describe('documents usecases', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAndSaveDocumentFileContent', () => {
|
||||
test('given a stored document, its content is extracted and saved in the db', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
});
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
|
||||
await db.insert(documentsTable).values({
|
||||
id: 'document-1',
|
||||
organizationId: 'organization-1',
|
||||
originalStorageKey: 'organization-1/originals/document-1.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'file-1.txt',
|
||||
originalName: 'file-1.txt',
|
||||
originalSha256Hash: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
||||
});
|
||||
|
||||
await documentsStorageService.saveFile({
|
||||
file: new File(['hello world'], 'file-1.txt', { type: 'text/plain' }),
|
||||
storageKey: 'organization-1/originals/document-1.txt',
|
||||
});
|
||||
|
||||
await extractAndSaveDocumentFileContent({
|
||||
documentId: 'document-1',
|
||||
organizationId: 'organization-1',
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
});
|
||||
|
||||
const documentRecords = await db.select().from(documentsTable);
|
||||
|
||||
expect(documentRecords.length).to.eql(1);
|
||||
expect(documentRecords[0]).to.deep.include({
|
||||
id: 'document-1',
|
||||
organizationId: 'organization-1',
|
||||
content: 'hello world', // The content is extracted and saved in the db
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Logger } from '../shared/logger/logger';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
||||
import type { TagsRepository } from '../tags/tags.repository';
|
||||
import type { TaskServices } from '../tasks/tasks.services';
|
||||
import type { TrackingServices } from '../tracking/tracking.services';
|
||||
import type { WebhookRepository } from '../webhooks/webhook.repository';
|
||||
import type { DocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||
@@ -12,11 +13,11 @@ import type { DocumentsRepository } from './documents.repository';
|
||||
import type { Document } from './documents.types';
|
||||
import type { DocumentStorageService } from './storage/documents.storage.services';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { extractTextFromFile } from '@papra/lecture';
|
||||
import pLimit from 'p-limit';
|
||||
import { checkIfOrganizationCanCreateNewDocument } from '../organizations/organizations.usecases';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { collectStreamToFile } from '../shared/streams/stream.convertion';
|
||||
import { isDefined } from '../shared/utils';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
||||
@@ -30,23 +31,9 @@ import { deferRegisterDocumentActivityLog } from './document-activity/document-a
|
||||
import { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
|
||||
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
import { getFileSha256Hash } from './documents.services';
|
||||
import { extractDocumentText, getFileSha256Hash } from './documents.services';
|
||||
import { createDocumentStorageService } from './storage/documents.storage.services';
|
||||
|
||||
const logger = createLogger({ namespace: 'documents:usecases' });
|
||||
|
||||
export async function extractDocumentText({ file, ocrLanguages }: { file: File; ocrLanguages?: string[] }) {
|
||||
const { textContent, error, extractorName } = await extractTextFromFile({ file, config: { tesseract: { languages: ocrLanguages } } });
|
||||
|
||||
if (error) {
|
||||
logger.error({ error, extractorName }, 'Error while extracting text from document');
|
||||
}
|
||||
|
||||
return {
|
||||
text: textContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDocument({
|
||||
file,
|
||||
userId,
|
||||
@@ -62,6 +49,7 @@ export async function createDocument({
|
||||
tagsRepository,
|
||||
webhookRepository,
|
||||
documentActivityRepository,
|
||||
taskServices,
|
||||
logger = createLogger({ namespace: 'documents:usecases' }),
|
||||
}: {
|
||||
file: File;
|
||||
@@ -78,6 +66,7 @@ export async function createDocument({
|
||||
tagsRepository: TagsRepository;
|
||||
webhookRepository: WebhookRepository;
|
||||
documentActivityRepository: DocumentActivityRepository;
|
||||
taskServices: TaskServices;
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const {
|
||||
@@ -120,7 +109,6 @@ export async function createDocument({
|
||||
documentsStorageService,
|
||||
generateDocumentId,
|
||||
trackingServices,
|
||||
ocrLanguages,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -146,6 +134,11 @@ export async function createDocument({
|
||||
},
|
||||
});
|
||||
|
||||
await taskServices.scheduleJob({
|
||||
taskName: 'extract-document-file-content',
|
||||
data: { documentId: document.id, organizationId, ocrLanguages },
|
||||
});
|
||||
|
||||
return { document };
|
||||
}
|
||||
|
||||
@@ -155,9 +148,11 @@ export type DocumentUsecaseDependencies = Omit<Parameters<typeof createDocument>
|
||||
export async function createDocumentCreationUsecase({
|
||||
db,
|
||||
config,
|
||||
taskServices,
|
||||
...initialDeps
|
||||
}: {
|
||||
db: Database;
|
||||
taskServices: TaskServices;
|
||||
config: Config;
|
||||
} & Partial<DocumentUsecaseDependencies>) {
|
||||
const deps = {
|
||||
@@ -176,7 +171,7 @@ export async function createDocumentCreationUsecase({
|
||||
logger: initialDeps.logger,
|
||||
};
|
||||
|
||||
return async (args: { file: File; userId?: string; organizationId: string }) => createDocument({ ...args, ...deps });
|
||||
return async (args: { file: File; userId?: string; organizationId: string }) => createDocument({ taskServices, ...args, ...deps });
|
||||
}
|
||||
|
||||
async function handleExistingDocument({
|
||||
@@ -222,7 +217,6 @@ async function createNewDocument({
|
||||
documentsStorageService,
|
||||
generateDocumentId,
|
||||
trackingServices,
|
||||
ocrLanguages,
|
||||
logger,
|
||||
}: {
|
||||
file: File;
|
||||
@@ -236,7 +230,6 @@ async function createNewDocument({
|
||||
documentsStorageService: DocumentStorageService;
|
||||
generateDocumentId: () => string;
|
||||
trackingServices: TrackingServices;
|
||||
ocrLanguages?: string[];
|
||||
logger: Logger;
|
||||
}) {
|
||||
const documentId = generateDocumentId();
|
||||
@@ -252,8 +245,6 @@ async function createNewDocument({
|
||||
storageKey: originalDocumentStorageKey,
|
||||
});
|
||||
|
||||
const { text } = await extractDocumentText({ file, ocrLanguages });
|
||||
|
||||
const [result, error] = await safely(documentsRepository.saveOrganizationDocument({
|
||||
id: documentId,
|
||||
name: fileName,
|
||||
@@ -263,7 +254,6 @@ async function createNewDocument({
|
||||
originalSize: size,
|
||||
originalStorageKey: storageKey,
|
||||
mimeType,
|
||||
content: text,
|
||||
originalSha256Hash: hash,
|
||||
}));
|
||||
|
||||
@@ -412,3 +402,31 @@ export async function deleteAllTrashDocuments({
|
||||
documents.map(async document => limit(async () => hardDeleteDocument({ document, documentsRepository, documentsStorageService }))),
|
||||
);
|
||||
}
|
||||
|
||||
export async function extractAndSaveDocumentFileContent({
|
||||
documentId,
|
||||
organizationId,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
ocrLanguages,
|
||||
}: {
|
||||
documentId: string;
|
||||
ocrLanguages?: string[];
|
||||
organizationId: string;
|
||||
documentsRepository: DocumentsRepository;
|
||||
documentsStorageService: DocumentStorageService;
|
||||
}) {
|
||||
const { document } = await documentsRepository.getDocumentById({ documentId, organizationId });
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
const { fileStream } = await documentsStorageService.getFileStream({ storageKey: document.originalStorageKey });
|
||||
|
||||
const { file } = await collectStreamToFile({ fileStream, fileName: document.name, mimeType: document.mimeType });
|
||||
|
||||
const { text } = await extractDocumentText({ file, ocrLanguages });
|
||||
|
||||
await documentsRepository.updateDocument({ documentId, organizationId, content: text });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Database } from '../../app/database/database.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { TaskServices } from '../../tasks/tasks.services';
|
||||
import { createDocumentsRepository } from '../documents.repository';
|
||||
import { extractAndSaveDocumentFileContent } from '../documents.usecases';
|
||||
import { createDocumentStorageService } from '../storage/documents.storage.services';
|
||||
|
||||
export async function registerExtractDocumentFileContentTask({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
const taskName = 'extract-document-file-content';
|
||||
|
||||
taskServices.registerTask({
|
||||
taskName,
|
||||
handler: async ({ data }) => {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
|
||||
// TODO: remove type cast
|
||||
const { documentId, organizationId, ocrLanguages } = data as { documentId: string; organizationId: string; ocrLanguages: string[] };
|
||||
|
||||
await extractAndSaveDocumentFileContent({
|
||||
documentId,
|
||||
organizationId,
|
||||
ocrLanguages,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { createOrganizationsRepository } from '../organizations/organizations.re
|
||||
import { createInMemoryFsServices } from '../shared/fs/fs.in-memory';
|
||||
import { createFsServices } from '../shared/fs/fs.services';
|
||||
import { createTestLogger } from '../shared/logger/logger.test-utils';
|
||||
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
|
||||
import { createInvalidPostProcessingStrategyError } from './ingestion-folders.errors';
|
||||
import { moveIngestionFile, processFile } from './ingestion-folders.usecases';
|
||||
|
||||
@@ -18,6 +19,7 @@ describe('ingestion-folders usecases', () => {
|
||||
describe('processFile', () => {
|
||||
describe('when a file is added to an organization ingestion folder', () => {
|
||||
test('if the post processing strategy is set to "move", the file is ingested and moved to the done folder', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { logger, getLogs } = createTestLogger();
|
||||
|
||||
const { db } = await createInMemoryDatabase({
|
||||
@@ -53,7 +55,7 @@ describe('ingestion-folders usecases', () => {
|
||||
organizationsRepository,
|
||||
logger,
|
||||
fs: createFsServices({ fs: vol.promises as unknown as FsNative }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId, taskServices }),
|
||||
});
|
||||
|
||||
// Check database
|
||||
@@ -120,6 +122,7 @@ describe('ingestion-folders usecases', () => {
|
||||
});
|
||||
|
||||
test('if the post processing strategy is set to "delete", the file is ingested and deleted', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { logger, getLogs } = createTestLogger();
|
||||
|
||||
const { db } = await createInMemoryDatabase({
|
||||
@@ -154,7 +157,7 @@ describe('ingestion-folders usecases', () => {
|
||||
organizationsRepository,
|
||||
logger,
|
||||
fs: createFsServices({ fs: vol.promises as unknown as FsNative }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId, taskServices }),
|
||||
});
|
||||
|
||||
// Check database
|
||||
@@ -221,6 +224,7 @@ describe('ingestion-folders usecases', () => {
|
||||
});
|
||||
|
||||
test('if the post processing strategy is not implemented, an error is thrown after the file has been ingested', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { logger } = createTestLogger();
|
||||
|
||||
const { db } = await createInMemoryDatabase({
|
||||
@@ -256,7 +260,7 @@ describe('ingestion-folders usecases', () => {
|
||||
organizationsRepository,
|
||||
logger,
|
||||
fs: createFsServices({ fs: vol.promises as unknown as FsNative }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId, taskServices }),
|
||||
}));
|
||||
|
||||
expect(error).to.deep.equal(createInvalidPostProcessingStrategyError({ strategy: 'unknown' }));
|
||||
@@ -293,6 +297,7 @@ describe('ingestion-folders usecases', () => {
|
||||
});
|
||||
|
||||
test('if for some reason the file cannot be read, a log is emitted and the processing stops', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { logger, getLogs } = createTestLogger();
|
||||
|
||||
const { db } = await createInMemoryDatabase({
|
||||
@@ -333,7 +338,7 @@ describe('ingestion-folders usecases', () => {
|
||||
throw new Error('File not found');
|
||||
},
|
||||
},
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId, taskServices }),
|
||||
});
|
||||
|
||||
// Check logs
|
||||
@@ -433,6 +438,7 @@ describe('ingestion-folders usecases', () => {
|
||||
});
|
||||
|
||||
test('when the document already exists in the database, it is not ingested, but the post-processing is still executed', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { logger, getLogs } = createTestLogger();
|
||||
|
||||
// This is the sha256 hash of the "lorem ipsum" text
|
||||
@@ -472,7 +478,7 @@ describe('ingestion-folders usecases', () => {
|
||||
organizationsRepository,
|
||||
logger,
|
||||
fs: createFsServices({ fs: vol.promises as unknown as FsNative }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId }),
|
||||
createDocument: await createDocumentCreationUsecase({ db, config, logger, documentsStorageService, generateDocumentId, taskServices }),
|
||||
});
|
||||
|
||||
// Check database
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CreateDocumentUsecase } from '../documents/documents.usecases';
|
||||
import type { OrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import type { FsServices } from '../shared/fs/fs.services';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import type { TaskServices } from '../tasks/tasks.services';
|
||||
import { isAbsolute, join, parse } from 'node:path';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import chokidar from 'chokidar';
|
||||
@@ -27,10 +28,12 @@ export function createIngestionFolderWatcher({
|
||||
config,
|
||||
logger = createLogger({ namespace: 'ingestion-folder-watcher' }),
|
||||
db,
|
||||
taskServices,
|
||||
}: {
|
||||
config: Config;
|
||||
logger?: Logger;
|
||||
db: Database;
|
||||
taskServices: TaskServices;
|
||||
}) {
|
||||
const { folderRootPath, watcher: { usePolling, pollingInterval }, processingConcurrency } = config.ingestionFolder;
|
||||
|
||||
@@ -41,7 +44,7 @@ export function createIngestionFolderWatcher({
|
||||
return {
|
||||
startWatchingIngestionFolders: async () => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const createDocument = await createDocumentCreationUsecase({ db, config, logger });
|
||||
const createDocument = await createDocumentCreationUsecase({ db, config, logger, taskServices });
|
||||
|
||||
const ignored = await buildPathIgnoreFunction({ config, cwd, organizationsRepository });
|
||||
|
||||
|
||||
@@ -154,14 +154,13 @@ describe('intake-emails e2e', () => {
|
||||
const [document] = documents;
|
||||
|
||||
expect(
|
||||
pick(document, ['organizationId', 'createdBy', 'mimeType', 'originalName', 'originalSize', 'content']),
|
||||
pick(document, ['organizationId', 'createdBy', 'mimeType', 'originalName', 'originalSize']),
|
||||
).to.eql({
|
||||
organizationId: 'org_1',
|
||||
createdBy: null,
|
||||
mimeType: 'text/plain',
|
||||
originalName: 'test.txt',
|
||||
originalSize: 11,
|
||||
content: 'hello world',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ function setupUpdateIntakeEmailRoute({ app, db }: RouteDefinitionContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function setupIngestIntakeEmailRoute({ app, db, config, trackingServices }: RouteDefinitionContext) {
|
||||
function setupIngestIntakeEmailRoute({ app, db, config, trackingServices, taskServices }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
INTAKE_EMAILS_INGEST_ROUTE,
|
||||
validateFormData(z.object({
|
||||
@@ -193,6 +193,7 @@ function setupIngestIntakeEmailRoute({ app, db, config, trackingServices }: Rout
|
||||
db,
|
||||
config,
|
||||
trackingServices,
|
||||
taskServices,
|
||||
});
|
||||
|
||||
await processIntakeEmailIngestion({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createDocumentCreationUsecase } from '../documents/documents.usecases';
|
||||
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
|
||||
import { createIntakeEmailLimitReachedError } from './intake-emails.errors';
|
||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { intakeEmailsTable } from './intake-emails.tables';
|
||||
@@ -19,6 +20,7 @@ describe('intake-emails usecases', () => {
|
||||
describe('ingestEmailForRecipient', () => {
|
||||
describe('when a email is forwarded to papra api, we look for the recipient in the intake emails repository and create a papra document for each attachment', () => {
|
||||
test(`when an intake email is is configured, enabled and match the recipient, and the sender is allowed, a document is created for each attachment`, async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [{ id: 'org-1', name: 'Organization 1' }],
|
||||
intakeEmails: [{ id: 'ie-1', organizationId: 'org-1', allowedOrigins: ['foo@example.fr'], emailAddress: 'email-1@papra.email' }],
|
||||
@@ -28,6 +30,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
taskServices,
|
||||
config: overrideConfig({
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
@@ -48,10 +51,10 @@ describe('intake-emails usecases', () => {
|
||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.name));
|
||||
|
||||
expect(
|
||||
documents.map(doc => pick(doc, ['organizationId', 'name', 'mimeType', 'originalName', 'content'])),
|
||||
documents.map(doc => pick(doc, ['organizationId', 'name', 'mimeType', 'originalName'])),
|
||||
).to.eql([
|
||||
{ organizationId: 'org-1', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt', content: 'content1' },
|
||||
{ organizationId: 'org-1', name: 'file2.txt', mimeType: 'text/plain', originalName: 'file2.txt', content: 'content2' },
|
||||
{ organizationId: 'org-1', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt' },
|
||||
{ organizationId: 'org-1', name: 'file2.txt', mimeType: 'text/plain', originalName: 'file2.txt' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -59,6 +62,7 @@ describe('intake-emails usecases', () => {
|
||||
const loggerTransport = createInMemoryLoggerTransport();
|
||||
const logger = createLogger({ transports: [loggerTransport], namespace: 'test' });
|
||||
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [{ id: 'org-1', name: 'Organization 1' }],
|
||||
intakeEmails: [{ id: 'ie-1', organizationId: 'org-1', isEnabled: false, emailAddress: 'email-1@papra.email' }],
|
||||
@@ -68,6 +72,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
taskServices,
|
||||
config: overrideConfig({
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
@@ -90,6 +95,7 @@ describe('intake-emails usecases', () => {
|
||||
});
|
||||
|
||||
test('when no intake email is found for the recipient, nothing happens, only a log is emitted', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const loggerTransport = createInMemoryLoggerTransport();
|
||||
const logger = createLogger({ transports: [loggerTransport], namespace: 'test' });
|
||||
|
||||
@@ -99,6 +105,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
taskServices,
|
||||
config: overrideConfig({
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
@@ -123,6 +130,7 @@ describe('intake-emails usecases', () => {
|
||||
test(`in order to be processed, the emitter of the email must be allowed for the intake email
|
||||
it should be registered in the intake email allowed origins
|
||||
if not, an error is logged and no document is created`, async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const loggerTransport = createInMemoryLoggerTransport();
|
||||
const logger = createLogger({ transports: [loggerTransport], namespace: 'test' });
|
||||
|
||||
@@ -135,6 +143,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
taskServices,
|
||||
config: overrideConfig({
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
@@ -167,6 +176,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
describe('processIntakeEmailIngestion', () => {
|
||||
test(`when an email is send to multiple intake emails from different organization, the attachments are processed for each of them`, async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org-1', name: 'Organization 1' },
|
||||
@@ -182,6 +192,7 @@ describe('intake-emails usecases', () => {
|
||||
|
||||
const createDocument = await createDocumentCreationUsecase({
|
||||
db,
|
||||
taskServices,
|
||||
config: overrideConfig({
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
@@ -201,10 +212,10 @@ describe('intake-emails usecases', () => {
|
||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.organizationId));
|
||||
|
||||
expect(
|
||||
documents.map(doc => pick(doc, ['organizationId', 'name', 'mimeType', 'originalName', 'content'])),
|
||||
documents.map(doc => pick(doc, ['organizationId', 'name', 'mimeType', 'originalName'])),
|
||||
).to.eql([
|
||||
{ organizationId: 'org-1', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt', content: 'content1' },
|
||||
{ organizationId: 'org-2', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt', content: 'content1' },
|
||||
{ organizationId: 'org-1', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt' },
|
||||
{ organizationId: 'org-2', name: 'file1.txt', mimeType: 'text/plain', originalName: 'file1.txt' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function collectStreamToFile({ fileStream, fileName, mimeType }: { fileStream: ReadableStream; fileName: string; mimeType: string }): Promise<{ file: File }> {
|
||||
const response = new Response(fileStream);
|
||||
const blob = await response.blob();
|
||||
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
|
||||
return { file };
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { TaskServices } from './tasks.services';
|
||||
import { registerExtractDocumentFileContentTask } from '../documents/tasks/extract-document-file-content.task';
|
||||
import { registerHardDeleteExpiredDocumentsTask } from '../documents/tasks/hard-delete-expired-documents.task';
|
||||
import { registerExpireInvitationsTask } from '../organizations/tasks/expire-invitations.task';
|
||||
|
||||
export async function registerTaskDefinitions({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
await registerHardDeleteExpiredDocumentsTask({ taskServices, db, config });
|
||||
await registerExpireInvitationsTask({ taskServices, db, config });
|
||||
await registerExtractDocumentFileContentTask({ taskServices, db, config });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function createTaskServices({ config }: { config: Config }) {
|
||||
const workerId = config.tasks.worker.id ?? 'default';
|
||||
|
||||
const driver = createMemoryDriver();
|
||||
const cadence = createCadence({ driver });
|
||||
const cadence = createCadence({ driver, logger });
|
||||
|
||||
return {
|
||||
...cadence,
|
||||
|
||||
8
apps/papra-server/src/modules/tasks/tasks.test-utils.ts
Normal file
8
apps/papra-server/src/modules/tasks/tasks.test-utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { createTaskServices } from './tasks.services';
|
||||
|
||||
export function createInMemoryTaskServices({ workerId = 'test' }: { workerId?: string } = {}) {
|
||||
const config = overrideConfig({ tasks: { worker: { id: workerId } } });
|
||||
|
||||
return createTaskServices({ config });
|
||||
}
|
||||
74
packages/n8n-nodes/README.md
Normal file
74
packages/n8n-nodes/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# n8n Integration
|
||||
|
||||
A community node package that integrates [Papra](https://papra.app) (the document archiving platform) with [n8n](https://n8n.io), enabling you to automate document management workflows.
|
||||
|
||||
## Installation
|
||||
|
||||
1. In your n8n instance, go to **Settings** → **Community Nodes**
|
||||
2. Click **Install** and enter: `@papra/n8n-nodes-papra`
|
||||
3. Install the package and restart your n8n instance
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create API Credentials
|
||||
Before using this integration, you need to create API credentials in your Papra workspace:
|
||||
|
||||
1. Log in to your Papra instance
|
||||
2. Navigate to **Settings** → **API Keys**
|
||||
3. Click **Create New API Key**
|
||||
4. Copy the generated API key and your Organization ID (from the url)
|
||||
|
||||
For detailed instructions, visit the [Papra API documentation](https://docs.papra.app/resources/api-endpoints/#authentication).
|
||||
|
||||
### 2. Configure n8n Credentials
|
||||
1. In n8n, create a new workflow
|
||||
2. Add a Papra node
|
||||
3. Create new credentials with:
|
||||
- **Papra API URL**: `https://api.papra.app` (or your self-hosted instance URL)
|
||||
- **Organization ID**: Your organization ID from Papra
|
||||
- **API Key**: Your generated API key
|
||||
|
||||
## Available Operations
|
||||
|
||||
| Resource | Operations |
|
||||
|----------|------------|
|
||||
| Document | `create`, `list`, `get`, `update`, `remove`, `get_file`, `get_activity` |
|
||||
| Tag | Standard CRUD operations |
|
||||
| Document Tag | Link/unlink tags to/from documents |
|
||||
| Statistics | Retrieve workspace analytics |
|
||||
| Trash | List deleted documents |
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20.15 or higher
|
||||
- pnpm package manager
|
||||
- n8n instance for testing
|
||||
- you can use `pnpx n8n` or `pnpm i -g n8n` command to install n8n globally
|
||||
|
||||
### Testing the Integration
|
||||
|
||||
#### Option 1: Local n8n Instance
|
||||
1. Build this package:
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
2. Link the package to your local n8n:
|
||||
```bash
|
||||
# Navigate to your n8n nodes directory
|
||||
cd ~/.n8n/nodes
|
||||
|
||||
# Install the package locally
|
||||
npm install /path/to/papra/packages/n8n-nodes
|
||||
```
|
||||
|
||||
3. Start n8n:
|
||||
```bash
|
||||
npx n8n
|
||||
```
|
||||
|
||||
4. In n8n, create a new workflow and search for "Papra" to find the node
|
||||
|
||||
#### Option 2: Docker
|
||||
Build a custom n8n Docker image with the Papra node included. Follow the [n8n documentation](https://docs.n8n.io/integrations/creating-nodes/deploy/install-private-nodes/#install-your-node-in-a-docker-n8n-instance) for detailed instructions.
|
||||
41
packages/n8n-nodes/credentials/PapraApi.credentials.ts
Normal file
41
packages/n8n-nodes/credentials/PapraApi.credentials.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { IAuthenticateGeneric, ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class PapraApi implements ICredentialType {
|
||||
name = 'papraApi';
|
||||
displayName = 'Papra API';
|
||||
documentationUrl = 'https://docs.papra.app/resources/api-endpoints/#authentication';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
name: 'url',
|
||||
displayName: 'Papra API URL',
|
||||
default: 'https://api.papra.app',
|
||||
required: true,
|
||||
type: 'string',
|
||||
validateType: 'url',
|
||||
},
|
||||
{
|
||||
name: 'organization_id',
|
||||
displayName: 'Organization ID',
|
||||
default: '',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
displayName: 'Papra API Key',
|
||||
default: '',
|
||||
required: true,
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
24
packages/n8n-nodes/eslint.config.js
Normal file
24
packages/n8n-nodes/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
|
||||
export default antfu({
|
||||
stylistic: {
|
||||
semi: true,
|
||||
},
|
||||
|
||||
// TODO: include the n8n rules package when it's eslint-9 ready
|
||||
// https://github.com/ivov/eslint-plugin-n8n-nodes-base/issues/196
|
||||
|
||||
rules: {
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
'vitest/consistent-test-it': ['error', { fn: 'test' }],
|
||||
'ts/consistent-type-definitions': ['error', 'type'],
|
||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
});
|
||||
16
packages/n8n-nodes/gulpfile.js
Normal file
16
packages/n8n-nodes/gulpfile.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const path = require('node:path');
|
||||
const { task, src, dest } = require('gulp');
|
||||
|
||||
task('build:icons', copyIcons);
|
||||
|
||||
function copyIcons() {
|
||||
const nodeSource = path.resolve('nodes', '**', '*.{png,svg}');
|
||||
const nodeDestination = path.resolve('dist', 'nodes');
|
||||
|
||||
src(nodeSource).pipe(dest(nodeDestination));
|
||||
|
||||
const credSource = path.resolve('credentials', '**', '*.{png,svg}');
|
||||
const credDestination = path.resolve('dist', 'credentials');
|
||||
|
||||
return src(credSource).pipe(dest(credDestination));
|
||||
}
|
||||
0
packages/n8n-nodes/index.js
Normal file
0
packages/n8n-nodes/index.js
Normal file
18
packages/n8n-nodes/nodes/Papra.node.json
Normal file
18
packages/n8n-nodes/nodes/Papra.node.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.papra",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.papra.app/resources/api-endpoints/#authentication"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.papra.app/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
24
packages/n8n-nodes/nodes/Papra.node.ts
Normal file
24
packages/n8n-nodes/nodes/Papra.node.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { PapraV1 } from './v1/PapraV1.node';
|
||||
|
||||
export class Papra extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Papra',
|
||||
name: 'papra',
|
||||
icon: 'file:papra.svg',
|
||||
group: ['input'],
|
||||
description: 'Read, update, write and delete data from Papra',
|
||||
defaultVersion: 1,
|
||||
usableAsTool: true,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new PapraV1(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
31
packages/n8n-nodes/nodes/v1/PapraV1.node.ts
Normal file
31
packages/n8n-nodes/nodes/v1/PapraV1.node.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import * as version from './actions/version';
|
||||
import { listSearch } from './methods';
|
||||
|
||||
export class PapraV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...version.description,
|
||||
usableAsTool: true,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import FormData from 'form-data';
|
||||
import { apiRequest } from '../../transport/index.js';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Binary Field',
|
||||
name: 'binary_property_name',
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
hint: 'The name of the input field containing the file data to be processed',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additional_fields',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
placeholder: 'Add Field',
|
||||
options: [
|
||||
{
|
||||
displayName: 'OCR languages',
|
||||
name: 'ocr_languages',
|
||||
default: '',
|
||||
description: 'The languages of the document',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = `/documents`;
|
||||
const formData = new FormData();
|
||||
|
||||
const binaryPropertyName = this.getNodeParameter('binary_property_name', itemIndex) as string;
|
||||
const binaryData = this.helpers.assertBinaryData(itemIndex, binaryPropertyName);
|
||||
const data = binaryData.id
|
||||
? await this.helpers.getBinaryStream(binaryData.id)
|
||||
: Buffer.from(binaryData.data, 'base64');
|
||||
|
||||
formData.append('file', data, {
|
||||
filename: binaryData.fileName,
|
||||
contentType: binaryData.mimeType,
|
||||
});
|
||||
|
||||
const additionalFields = this.getNodeParameter('additional_fields', itemIndex) as any;
|
||||
Object.entries({
|
||||
ocrLanguages: additionalFields.ocr_languages,
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
itemIndex,
|
||||
'POST',
|
||||
endpoint,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: formData.getHeaders(), formData },
|
||||
)) as any;
|
||||
|
||||
return { json: { results: [response] } };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as get_activity from './get_activity.operation';
|
||||
import * as get_file from './get_file.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as remove from './remove.operation';
|
||||
import * as update from './update.operation';
|
||||
|
||||
export {
|
||||
create,
|
||||
get,
|
||||
get_activity,
|
||||
get_file,
|
||||
list,
|
||||
remove,
|
||||
update,
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
default: 'list',
|
||||
displayOptions: {
|
||||
show: { resource: ['document'] },
|
||||
},
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Create a document',
|
||||
value: 'create',
|
||||
action: 'Create a new document',
|
||||
},
|
||||
{
|
||||
name: 'List documents',
|
||||
value: 'list',
|
||||
action: 'List all documents',
|
||||
},
|
||||
{
|
||||
name: 'Update a document',
|
||||
value: 'update',
|
||||
action: 'Update a document',
|
||||
},
|
||||
{
|
||||
name: 'Get a document',
|
||||
value: 'get',
|
||||
action: 'Get a document',
|
||||
},
|
||||
{
|
||||
name: 'Get the document file',
|
||||
value: 'get_file',
|
||||
action: 'Get the file of the document',
|
||||
},
|
||||
{
|
||||
name: 'Delete a document',
|
||||
value: 'remove',
|
||||
action: 'Delete a document',
|
||||
},
|
||||
{
|
||||
name: 'Get the document activity log',
|
||||
value: 'get_activity',
|
||||
action: 'Get the activity log of a document',
|
||||
},
|
||||
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...create.description,
|
||||
...list.description,
|
||||
...update.description,
|
||||
...get.description,
|
||||
...get_file.description,
|
||||
...get_activity.description,
|
||||
...remove.description,
|
||||
];
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['get'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
|
||||
const endpoint = `/documents/${id}`;
|
||||
const response = (await apiRequest.call(this, itemIndex, 'GET', endpoint)) as any;
|
||||
|
||||
return { json: { results: [response] } };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequestPaginated } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['get_activity'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${id}/activity`;
|
||||
const responses = (await apiRequestPaginated.call(this, itemIndex, 'GET', endpoint)) as any[];
|
||||
|
||||
const statusCode = responses.reduce((acc, response) => acc + response.statusCode, 0) / responses.length;
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The documents you are requesting could not be found`,
|
||||
{
|
||||
description: JSON.stringify(
|
||||
responses.map(response => response?.body?.details ?? response?.statusMessage),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
json: { results: responses.flatMap(response => response.body.activities) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['get_file'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${id}`;
|
||||
const preview = (await apiRequest.call(
|
||||
this,
|
||||
itemIndex,
|
||||
'GET',
|
||||
`${endpoint}/file`,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
json: false,
|
||||
encoding: null,
|
||||
resolveWithFullResponse: true,
|
||||
},
|
||||
)) as any;
|
||||
|
||||
// TODO: fix
|
||||
const filename = preview.headers['content-disposition']
|
||||
?.match(/filename="(?:b['"])?([^"]+)['"]?"/)?.[1]
|
||||
?.replace(/^['"]|['"]$/g, '') ?? `${id}.pdf`;
|
||||
|
||||
const mimeType = preview.headers['content-type'];
|
||||
|
||||
return {
|
||||
json: {},
|
||||
binary: {
|
||||
data: await this.helpers.prepareBinaryData(
|
||||
Buffer.from(preview.body),
|
||||
filename,
|
||||
mimeType,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequestPaginated } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = '/documents';
|
||||
const responses = (await apiRequestPaginated.call(this, itemIndex, 'GET', endpoint)) as any[];
|
||||
|
||||
const statusCode = responses.reduce((acc, response) => acc + response.statusCode, 0) / responses.length;
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The documents you are requesting could not be found`,
|
||||
{
|
||||
description: JSON.stringify(
|
||||
responses.map(response => response?.body?.error?.message ?? response?.error?.code),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
json: { results: responses.flatMap(response => response.body.documents) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['remove'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${id}`;
|
||||
await apiRequest.call(this, itemIndex, 'DELETE', endpoint);
|
||||
|
||||
return { json: { results: [true] } };
|
||||
}
|
||||
130
packages/n8n-nodes/nodes/v1/actions/document/update.operation.ts
Normal file
130
packages/n8n-nodes/nodes/v1/actions/document/update.operation.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
description: 'ID of the document',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
hint: 'The ID of the document',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
{
|
||||
displayName: 'Update fields',
|
||||
name: 'update_fields',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document'],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
default: '',
|
||||
description: 'The name of the document',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
displayName: 'Content',
|
||||
name: 'content',
|
||||
default: '',
|
||||
description: 'The content of the document, for search purposes',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
placeholder: 'Add Field',
|
||||
type: 'collection',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${id}`;
|
||||
|
||||
const updateFields = this.getNodeParameter('update_fields', itemIndex, {}) as any;
|
||||
|
||||
const body: { [key: string]: any } = {};
|
||||
|
||||
for (const key of Object.keys(updateFields)) {
|
||||
if (updateFields[key] !== null && updateFields[key] !== undefined) {
|
||||
body[key] = updateFields[key];
|
||||
}
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
itemIndex,
|
||||
'PATCH',
|
||||
endpoint,
|
||||
body,
|
||||
)) as any;
|
||||
|
||||
return { json: { results: [response] } };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeParameterResourceLocator, INodeProperties } from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document_tag'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
{
|
||||
displayName: 'Tag ID',
|
||||
name: 'tag_id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document_tag'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Tag...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tagSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Tag ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9]+$',
|
||||
errorMessage: 'The ID must be an alphanumeric string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the tag',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const document_id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const tag_id = (this.getNodeParameter('tag_id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${document_id}/tags`;
|
||||
|
||||
const body = {
|
||||
tagId: tag_id,
|
||||
};
|
||||
|
||||
await apiRequest.call(this, itemIndex, 'POST', endpoint, body);
|
||||
|
||||
return {
|
||||
json: { results: [true] },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as remove from './remove.operation';
|
||||
|
||||
export { create, remove };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
default: 'list',
|
||||
displayOptions: {
|
||||
show: { resource: ['document_tag'] },
|
||||
},
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Add a tag to a document',
|
||||
value: 'create',
|
||||
action: 'Add a tag to a document',
|
||||
},
|
||||
{
|
||||
name: 'Remove a tag from a document',
|
||||
value: 'remove',
|
||||
action: 'Remove a tag from a document',
|
||||
},
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...create.description,
|
||||
...remove.description,
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document_tag'],
|
||||
operation: ['remove'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Document...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'documentSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Document ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
placeholder: `Enter Document URL...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
errorMessage: 'The URL must be a valid Papra document URL (e.g. https://papra.example.com/organizations/org_xxx/documents/doc_xxx?tab=info)',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '^(?:http|https)://(?:.+?)/documents/([a-zA-Z0-9_]+)/?(?:\\?.*)?$',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the document',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
{
|
||||
displayName: 'Tag ID',
|
||||
name: 'tag_id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['document_tag'],
|
||||
operation: ['remove'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Tag...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tagSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Tag ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9]+$',
|
||||
errorMessage: 'The ID must be an alphanumeric string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the tag',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const document_id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const tag_id = (this.getNodeParameter('tag_id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/documents/${document_id}/tags/${tag_id}`;
|
||||
|
||||
await apiRequest.call(this, itemIndex, 'DELETE', endpoint);
|
||||
|
||||
return { json: { results: [true] } };
|
||||
}
|
||||
9
packages/n8n-nodes/nodes/v1/actions/node.type.ts
Normal file
9
packages/n8n-nodes/nodes/v1/actions/node.type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
export type PapraType = AllEntities<{
|
||||
statistics: 'get';
|
||||
document: 'create' | 'list' | 'update' | 'get' | 'get_file' | 'get_activity' | 'remove';
|
||||
document_tag: 'create' | 'remove';
|
||||
tag: 'create' | 'list' | 'update' | 'remove';
|
||||
trash: 'list';
|
||||
}>;
|
||||
54
packages/n8n-nodes/nodes/v1/actions/router.ts
Normal file
54
packages/n8n-nodes/nodes/v1/actions/router.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import type { PapraType } from './node.type.ts';
|
||||
|
||||
import * as document from './document/document.resource';
|
||||
import * as document_tag from './document_tag/document_tag.resource';
|
||||
import * as statistics from './statistics/statistics.resource';
|
||||
import * as tag from './tag/tag.resource';
|
||||
import * as trash from './trash/trash.resource';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) {
|
||||
const resource = this.getNodeParameter<PapraType>('resource', itemIndex);
|
||||
const operation = this.getNodeParameter('operation', itemIndex);
|
||||
const papraNodeData = { resource, operation } as PapraType;
|
||||
|
||||
try {
|
||||
switch (papraNodeData.resource) {
|
||||
case 'statistics':
|
||||
returnData.push(await statistics[papraNodeData.operation].execute.call(this, itemIndex));
|
||||
break;
|
||||
case 'document':
|
||||
returnData.push(
|
||||
await document[papraNodeData.operation].execute.call(this, itemIndex),
|
||||
);
|
||||
break;
|
||||
case 'document_tag':
|
||||
returnData.push(
|
||||
await document_tag[papraNodeData.operation].execute.call(this, itemIndex),
|
||||
);
|
||||
break;
|
||||
case 'tag':
|
||||
returnData.push(
|
||||
await tag[papraNodeData.operation].execute.call(this, itemIndex),
|
||||
);
|
||||
break;
|
||||
case 'trash':
|
||||
returnData.push(
|
||||
await trash[papraNodeData.operation].execute.call(this, itemIndex),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.description?.includes('cannot accept the provided value')) {
|
||||
error.description += '. Consider using \'Typecast\' option';
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = `/documents/statistics`;
|
||||
const response = (await apiRequest.call(this, itemIndex, 'GET', endpoint)) as any;
|
||||
|
||||
return {
|
||||
json: response,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as get from './get.operation';
|
||||
|
||||
export { get };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
default: 'get',
|
||||
displayOptions: {
|
||||
show: { resource: ['statistics'] },
|
||||
},
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Get statistics of the organization',
|
||||
value: 'get',
|
||||
action: 'Get statistics',
|
||||
},
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...get.description,
|
||||
];
|
||||
60
packages/n8n-nodes/nodes/v1/actions/tag/create.operation.ts
Normal file
60
packages/n8n-nodes/nodes/v1/actions/tag/create.operation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
placeholder: 'Name of the tag',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Color',
|
||||
name: 'color',
|
||||
default: '#000000',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
type: 'color',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
placeholder: 'Description of the tag',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = `/tags`;
|
||||
const body = {
|
||||
name: this.getNodeParameter('name', itemIndex),
|
||||
color: this.getNodeParameter('color', itemIndex)?.toString().toLowerCase(),
|
||||
description: this.getNodeParameter('description', itemIndex, ''),
|
||||
};
|
||||
|
||||
const response = (await apiRequest.call(this, itemIndex, 'POST', endpoint, body)) as any;
|
||||
|
||||
return { json: { results: [response] } };
|
||||
}
|
||||
20
packages/n8n-nodes/nodes/v1/actions/tag/list.operation.ts
Normal file
20
packages/n8n-nodes/nodes/v1/actions/tag/list.operation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = '/tags';
|
||||
const response = (await apiRequest.call(this, itemIndex, 'GET', endpoint)) as any;
|
||||
|
||||
return {
|
||||
json: { results: response.tags },
|
||||
};
|
||||
}
|
||||
63
packages/n8n-nodes/nodes/v1/actions/tag/remove.operation.ts
Normal file
63
packages/n8n-nodes/nodes/v1/actions/tag/remove.operation.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['remove'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Tag...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tagSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Tag ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the tag',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/tags/${id}`;
|
||||
await apiRequest.call(this, itemIndex, 'DELETE', endpoint);
|
||||
|
||||
return { json: { results: [true] } };
|
||||
}
|
||||
47
packages/n8n-nodes/nodes/v1/actions/tag/tag.resource.ts
Normal file
47
packages/n8n-nodes/nodes/v1/actions/tag/tag.resource.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as remove from './remove.operation';
|
||||
import * as update from './update.operation';
|
||||
|
||||
export { create, list, remove, update };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
default: 'list',
|
||||
displayOptions: {
|
||||
show: { resource: ['tag'] },
|
||||
},
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Create a tag',
|
||||
value: 'create',
|
||||
action: 'Create a new tag',
|
||||
},
|
||||
{
|
||||
name: 'Delete a tag',
|
||||
value: 'remove',
|
||||
action: 'Delete a tag',
|
||||
},
|
||||
{
|
||||
name: 'List tags',
|
||||
value: 'list',
|
||||
action: 'List all tags',
|
||||
},
|
||||
{
|
||||
name: 'Update a tag',
|
||||
value: 'update',
|
||||
action: 'Update a tag',
|
||||
},
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...create.description,
|
||||
...list.description,
|
||||
...remove.description,
|
||||
...update.description,
|
||||
];
|
||||
111
packages/n8n-nodes/nodes/v1/actions/tag/update.operation.ts
Normal file
111
packages/n8n-nodes/nodes/v1/actions/tag/update.operation.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
default: { mode: 'list', value: '' },
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
placeholder: `Select a Tag...`,
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tagSearch',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
placeholder: `Enter Tag ID...`,
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[a-zA-Z0-9_]+$',
|
||||
errorMessage: 'The ID must be valid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
placeholder: 'ID of the tag',
|
||||
required: true,
|
||||
type: 'resourceLocator',
|
||||
},
|
||||
{
|
||||
displayName: 'Update fields',
|
||||
name: 'update_fields',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tag'],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
placeholder: 'Name of the tag',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Color',
|
||||
name: 'color',
|
||||
default: '#000000',
|
||||
type: 'color',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
default: '',
|
||||
placeholder: 'Description of the tag',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
placeholder: 'Add Field',
|
||||
type: 'collection',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const id = (this.getNodeParameter('id', itemIndex) as INodeParameterResourceLocator).value;
|
||||
const endpoint = `/tags/${id}`;
|
||||
|
||||
const updateFields = this.getNodeParameter('update_fields', itemIndex, {}) as {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const body: { [key: string]: any } = {};
|
||||
|
||||
for (const key of Object.keys(updateFields)) {
|
||||
if (updateFields[key] !== null && updateFields[key] !== undefined) {
|
||||
body[key] = key === 'color' ? updateFields[key].toString().toLowerCase() : updateFields[key];
|
||||
}
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(this, itemIndex, 'PUT', endpoint, body)) as any;
|
||||
|
||||
return { json: { results: [response] } };
|
||||
}
|
||||
37
packages/n8n-nodes/nodes/v1/actions/trash/list.operation.ts
Normal file
37
packages/n8n-nodes/nodes/v1/actions/trash/list.operation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequestPaginated } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const endpoint = `/documents/deleted`;
|
||||
const responses = (await apiRequestPaginated.call(this, itemIndex, 'GET', endpoint)) as any[];
|
||||
|
||||
const statusCode = responses.reduce((acc, response) => acc + response.statusCode, 0) / responses.length;
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The trash you are requesting could not be found`,
|
||||
{
|
||||
description: JSON.stringify(
|
||||
responses.map(response => response?.body?.details ?? response?.statusMessage),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
json: { results: responses.flatMap(response => response.body.documents) },
|
||||
};
|
||||
}
|
||||
26
packages/n8n-nodes/nodes/v1/actions/trash/trash.resource.ts
Normal file
26
packages/n8n-nodes/nodes/v1/actions/trash/trash.resource.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as list from './list.operation';
|
||||
|
||||
export { list };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
default: 'list',
|
||||
displayOptions: {
|
||||
show: { resource: ['trash'] },
|
||||
},
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'List trash',
|
||||
value: 'list',
|
||||
action: 'List all trash',
|
||||
},
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...list.description,
|
||||
];
|
||||
66
packages/n8n-nodes/nodes/v1/actions/version.ts
Normal file
66
packages/n8n-nodes/nodes/v1/actions/version.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import * as document from './document/document.resource';
|
||||
import * as document_tag from './document_tag/document_tag.resource';
|
||||
import * as statistics from './statistics/statistics.resource';
|
||||
import * as tag from './tag/tag.resource';
|
||||
import * as trash from './trash/trash.resource';
|
||||
|
||||
export const description: INodeTypeDescription = {
|
||||
displayName: 'Papra',
|
||||
name: 'papra',
|
||||
icon: 'file:papra.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{ $parameter.operation + ": " + $parameter.resource }}',
|
||||
description: 'Consume documents and metadata from Papra API',
|
||||
defaults: { name: 'Papra' },
|
||||
|
||||
credentials: [{ name: 'papraApi', required: true }],
|
||||
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
default: 'document',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Statistics',
|
||||
value: 'statistics',
|
||||
description: 'Statistics about the documents',
|
||||
},
|
||||
{
|
||||
name: 'Document',
|
||||
value: 'document',
|
||||
description: 'Scanned document or file saved in Papra',
|
||||
},
|
||||
{
|
||||
name: 'Document tag',
|
||||
value: 'document_tag',
|
||||
description: 'Associate a tag to a document',
|
||||
},
|
||||
{
|
||||
name: 'Tag',
|
||||
value: 'tag',
|
||||
description: 'Label for documents',
|
||||
},
|
||||
{
|
||||
name: 'Trash',
|
||||
value: 'trash',
|
||||
description: 'All deleted documents',
|
||||
},
|
||||
],
|
||||
type: 'options',
|
||||
},
|
||||
...document.description,
|
||||
...tag.description,
|
||||
...trash.description,
|
||||
...statistics.description,
|
||||
...document_tag.description,
|
||||
],
|
||||
};
|
||||
1
packages/n8n-nodes/nodes/v1/methods/index.ts
Normal file
1
packages/n8n-nodes/nodes/v1/methods/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as listSearch from './listSearch';
|
||||
65
packages/n8n-nodes/nodes/v1/methods/listSearch.ts
Normal file
65
packages/n8n-nodes/nodes/v1/methods/listSearch.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import { apiRequest, apiRequestPaginated } from '../transport';
|
||||
|
||||
export async function documentSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
if (filter && filter.trim().length >= 3) {
|
||||
const endpoint = `/documents/search`;
|
||||
const query = { searchQuery: filter };
|
||||
|
||||
const responses = (await apiRequestPaginated.call(
|
||||
this,
|
||||
0,
|
||||
'GET',
|
||||
endpoint,
|
||||
undefined,
|
||||
query,
|
||||
)) as {
|
||||
body: {
|
||||
documents: { id: number; name: string }[];
|
||||
};
|
||||
}[];
|
||||
|
||||
const [result] = responses;
|
||||
return {
|
||||
results: result
|
||||
? result.body.documents.map(item => ({
|
||||
name: item.name,
|
||||
value: item.id,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
const endpoint = `/documents`;
|
||||
const response = (await apiRequest.call(this, 0, 'GET', endpoint, {}, { pageSize: 30, pageIndex: 0 })) as { documents: { id: number; name: string }[] };
|
||||
|
||||
return {
|
||||
results: response.documents.map(item => ({
|
||||
name: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function tagSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const endpoint = `/tags`;
|
||||
const response = (await apiRequest.call(this, 0, 'GET', endpoint)) as { tags: { id: number; name: string }[] };
|
||||
|
||||
return {
|
||||
results: response.tags
|
||||
.filter(item => !filter || item.name.includes(filter))
|
||||
.map(item => ({
|
||||
name: item.name.trim().length > 80 ? `${item.name.trim().slice(0, 80)}...` : item.name.trim(),
|
||||
value: item.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
1
packages/n8n-nodes/nodes/v1/papra.svg
Normal file
1
packages/n8n-nodes/nodes/v1/papra.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#403a3a" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/></g></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
96
packages/n8n-nodes/nodes/v1/transport/index.ts
Normal file
96
packages/n8n-nodes/nodes/v1/transport/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
IRequestOptions,
|
||||
PaginationOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
itemIndex: number,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query?: IDataObject,
|
||||
option: IRequestOptions = {},
|
||||
): Promise<unknown> {
|
||||
const queryParams = query || {};
|
||||
|
||||
const credentials = await this.getCredentials('papraApi');
|
||||
const options: IRequestOptions = {
|
||||
headers: {},
|
||||
method,
|
||||
body,
|
||||
qs: queryParams,
|
||||
uri: `${credentials.url}/api/organizations/${credentials.organization_id}${endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (Object.keys(option).length) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
if (!Object.keys(body).length) {
|
||||
options.body = undefined;
|
||||
}
|
||||
|
||||
return this.helpers.requestWithAuthentication.call(
|
||||
this,
|
||||
'papraApi',
|
||||
options,
|
||||
undefined,
|
||||
itemIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiRequestPaginated(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
itemIndex: number,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query?: IDataObject,
|
||||
option: IRequestOptions = {},
|
||||
): Promise<unknown[]> {
|
||||
query = query || {};
|
||||
|
||||
const credentials = await this.getCredentials('papraApi');
|
||||
const options: IRequestOptions = {
|
||||
headers: {},
|
||||
method,
|
||||
body,
|
||||
qs: query,
|
||||
uri: `${credentials.url}/api/organizations/${credentials.organization_id}${endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (Object.keys(option).length) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
if (!Object.keys(body).length) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
const paginationOptions: PaginationOptions = {
|
||||
// TODO: make continue condition generic
|
||||
continue: '={{ $response.body.documents && $response.body.documents.length > 0 }}',
|
||||
request: {
|
||||
qs: {
|
||||
pageSize: '={{ $request.qs.pageSize || 50 }}',
|
||||
pageIndex: '={{ $pageCount }}',
|
||||
},
|
||||
},
|
||||
requestInterval: 100,
|
||||
};
|
||||
|
||||
return this.helpers.requestWithAuthenticationPaginated.call(
|
||||
this,
|
||||
options,
|
||||
itemIndex,
|
||||
paginationOptions,
|
||||
'papraApi',
|
||||
);
|
||||
}
|
||||
61
packages/n8n-nodes/package.json
Normal file
61
packages/n8n-nodes/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@papra/n8n-nodes-papra",
|
||||
"version": "0.1.0",
|
||||
"description": "n8n nodes for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/papra-hq/papra",
|
||||
"directory": "packages/n8n-nodes"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/papra-hq/papra/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"n8n-community-node-package",
|
||||
"n8n",
|
||||
"papra",
|
||||
"document",
|
||||
"archiving",
|
||||
"self-hosted"
|
||||
],
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.15"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"build": "pnpm build:clean && tsc && gulp build:icons",
|
||||
"build:clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"dist/credentials/PapraApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/Papra.node.js"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"form-data": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-n8n-nodes-base": "^1.16.3",
|
||||
"gulp": "^5.0.0",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
30
packages/n8n-nodes/tsconfig.json
Normal file
30
packages/n8n-nodes/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"target": "es2019",
|
||||
"lib": ["es2019", "es2020", "es2022.error"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"declaration": true,
|
||||
"outDir": "./dist/",
|
||||
"preserveConstEnums": true,
|
||||
"removeComments": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"credentials/**/*",
|
||||
"nodes/**/*",
|
||||
"nodes/**/*.json",
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
1479
pnpm-lock.yaml
generated
1479
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user