mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-20 03:50:37 -06:00
Compare commits
15 Commits
@papra/app
...
n8n-nodes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d951cc82 | ||
|
|
47f9c5b186 | ||
|
|
0b97e58785 | ||
|
|
d51779aeb8 | ||
|
|
8f30ec0281 | ||
|
|
5868800bce | ||
|
|
b5ccc135ba | ||
|
|
5e46bb9e6a | ||
|
|
41a113334a | ||
|
|
6723baf98a | ||
|
|
bbe5fe74e2 | ||
|
|
a8cff8cedc | ||
|
|
67b3b14cdf | ||
|
|
ffdae8db56 | ||
|
|
7768840aa4 |
5
.changeset/beige-houses-confess.md
Normal file
5
.changeset/beige-houses-confess.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added diacritics and improved wording for Romanian translation
|
||||
5
.changeset/bumpy-aliens-juggle.md
Normal file
5
.changeset/bumpy-aliens-juggle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Breaking change: updated webhooks signatures and payload format to match standard-webhook spec
|
||||
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/few-pugs-wink.md
Normal file
5
.changeset/few-pugs-wink.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Simplified the organization intake email list
|
||||
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
|
||||
7
.changeset/heavy-chairs-look.md
Normal file
7
.changeset/heavy-chairs-look.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Added new webhook events: document:updated, document:tag:added, document:tag:removed
|
||||
7
.changeset/itchy-candies-marry.md
Normal file
7
.changeset/itchy-candies-marry.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Webhooks invocation is now defered
|
||||
5
.changeset/shaggy-olives-speak.md
Normal file
5
.changeset/shaggy-olives-speak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/lecture": minor
|
||||
---
|
||||
|
||||
Added support for scanned pdf content extraction
|
||||
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
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook lösch
|
||||
webhooks.delete.confirm.confirm-button: Löschen
|
||||
webhooks.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
webhooks.events.documents.title: Dokumente Ereignisse
|
||||
webhooks.events.documents.document:created.description: Dokument erstellt
|
||||
webhooks.events.documents.document:deleted.description: Dokument gelöscht
|
||||
webhooks.events.documents.document:updated.description: Dokument aktualisiert
|
||||
webhooks.events.documents.document:tag:added.description: Ein Tag wurde zu einem Dokument hinzugefügt
|
||||
webhooks.events.documents.document:tag:removed.description: Ein Tag wurde von einem Dokument entfernt
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
|
||||
webhooks.delete.confirm.confirm-button: Delete
|
||||
webhooks.delete.confirm.cancel-button: Cancel
|
||||
|
||||
webhooks.events.documents.title: Documents events
|
||||
webhooks.events.documents.document:created.description: Document created
|
||||
webhooks.events.documents.document:deleted.description: Document deleted
|
||||
webhooks.events.documents.document:updated.description: Document updated
|
||||
webhooks.events.documents.document:tag:added.description: A tag is added to a document
|
||||
webhooks.events.documents.document:tag:removed.description: A tag is removed from a document
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: ¿Estás seguro de que deseas eliminar este web
|
||||
webhooks.delete.confirm.confirm-button: Eliminar
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento creado
|
||||
webhooks.events.documents.document:deleted.description: Documento eliminado
|
||||
webhooks.events.documents.document:updated.description: Documento actualizado
|
||||
webhooks.events.documents.document:tag:added.description: Una etiqueta se ha añadido a un documento
|
||||
webhooks.events.documents.document:tag:removed.description: Una etiqueta se ha eliminado de un documento
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook
|
||||
webhooks.delete.confirm.confirm-button: Supprimer
|
||||
webhooks.delete.confirm.cancel-button: Annuler
|
||||
|
||||
webhooks.events.documents.title: Événements de documents
|
||||
webhooks.events.documents.document:created.description: Document créé
|
||||
webhooks.events.documents.document:deleted.description: Document supprimé
|
||||
webhooks.events.documents.document:updated.description: Document mis à jour
|
||||
webhooks.events.documents.document:tag:added.description: Un tag est ajouté à un document
|
||||
webhooks.events.documents.document:tag:removed.description: Un tag est retiré d'un document
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Czy na pewno chcesz usunąć ten webhook?
|
||||
webhooks.delete.confirm.confirm-button: Usuń
|
||||
webhooks.delete.confirm.cancel-button: Anuluj
|
||||
|
||||
webhooks.events.documents.title: Zdarzenia dokumentów
|
||||
webhooks.events.documents.document:created.description: Utworzono dokument
|
||||
webhooks.events.documents.document:deleted.description: Usunięto dokument
|
||||
webhooks.events.documents.document:updated.description: Dokument został zaktualizowany
|
||||
webhooks.events.documents.document:tag:added.description: Tag został dodany do dokumentu
|
||||
webhooks.events.documents.document:tag:removed.description: Tag został usunięty z dokumentu
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Tem certeza de que deseja excluir este webhook?
|
||||
webhooks.delete.confirm.confirm-button: Excluir
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento criado
|
||||
webhooks.events.documents.document:deleted.description: Documento excluído
|
||||
webhooks.events.documents.document:updated.description: Documento atualizado
|
||||
webhooks.events.documents.document:tag:added.description: Uma tag foi adicionada a um documento
|
||||
webhooks.events.documents.document:tag:removed.description: Uma tag foi removida de um documento
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -541,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
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ auth.legal-links.description: Ao continuar, reconhece que compreende e concorda
|
||||
auth.legal-links.terms: Termos de Serviço
|
||||
auth.legal-links.privacy: Política de Privacidade
|
||||
|
||||
# auth.no-auth-provider.title: No authentication provider
|
||||
# auth.no-auth-provider.description: There are no authentication providers enabled on this instance of Papra. Please contact the administrator of this instance to enable them.
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Definições do utilizador
|
||||
@@ -486,8 +489,12 @@ webhooks.delete.confirm.message: Tem a certeza de que deseja eliminar este webho
|
||||
webhooks.delete.confirm.confirm-button: Eliminar
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento criado
|
||||
webhooks.events.documents.document:deleted.description: Documento eliminado
|
||||
webhooks.events.documents.document:updated.description: Documento atualizado
|
||||
webhooks.events.documents.document:tag:added.description: Uma etiqueta foi adicionada a um documento
|
||||
webhooks.events.documents.document:tag:removed.description: Uma etiqueta foi removida de um documento
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -538,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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -38,7 +38,8 @@ describe('locales', () => {
|
||||
const dynamicKeysMatchers = [
|
||||
/^api-errors\./, // api-errors.document.already_exists
|
||||
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
|
||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
||||
/^webhooks\.events\.[a-z0-9]+\.[a-z0-9:]+.description$/, // webhooks.events.documents.document:created.description
|
||||
/^webhooks\.events\.[a-z0-9]+\.title$/, // webhooks.events.documents.title
|
||||
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
|
||||
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
|
||||
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
|
||||
|
||||
@@ -440,8 +440,12 @@ export type LocaleKeys =
|
||||
| 'webhooks.delete.confirm.message'
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.title'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| 'webhooks.events.documents.document:updated.description'
|
||||
| 'webhooks.events.documents.document:tag:added.description'
|
||||
| 'webhooks.events.documents.document:tag:removed.description'
|
||||
| 'layout.menu.home'
|
||||
| 'layout.menu.documents'
|
||||
| 'layout.menu.tags'
|
||||
@@ -480,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'
|
||||
|
||||
@@ -17,16 +17,26 @@ import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
const AllowedOriginsDialog: Component<{
|
||||
children: (props: DialogTriggerProps) => JSX.Element;
|
||||
intakeEmails: IntakeEmail;
|
||||
open?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal(props.intakeEmails?.allowedOrigins || []);
|
||||
const { t } = useI18n();
|
||||
|
||||
const update = async () => {
|
||||
if (!props.intakeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateIntakeEmail({
|
||||
organizationId: props.intakeEmails.organizationId,
|
||||
intakeEmailId: props.intakeEmails.id,
|
||||
@@ -58,13 +68,29 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
});
|
||||
|
||||
async function invalidateQuery() {
|
||||
if (!props.intakeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', props.intakeEmails.organizationId, 'intake-emails'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.intakeEmails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={isOpen => !isOpen && invalidateQuery()}>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
invalidateQuery();
|
||||
}
|
||||
props.onOpenChange?.(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger as={props.children} />
|
||||
|
||||
<DialogContent>
|
||||
@@ -129,6 +155,8 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
export const IntakeEmailsPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { t, te } = useI18n();
|
||||
const [selectedIntakeEmail, setSelectedIntakeEmail] = createSignal<IntakeEmail | null>(null);
|
||||
const [openDropdownId, setOpenDropdownId] = createSignal<string | null>(null);
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
@@ -225,6 +253,11 @@ export const IntakeEmailsPage: Component = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openAllowedOriginsDialog = (intakeEmail: IntakeEmail) => {
|
||||
setOpenDropdownId(null);
|
||||
setSelectedIntakeEmail(intakeEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||
@@ -313,39 +346,46 @@ export const IntakeEmailsPage: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||
<DropdownMenu
|
||||
open={openDropdownId() === intakeEmail.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenDropdownId(isOpen ? intakeEmail.id : null);
|
||||
}}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</Button>
|
||||
|
||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||
{(props: DialogTriggerProps) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Edit intake email"
|
||||
{...props}
|
||||
class="flex items-center gap-2 leading-none"
|
||||
<DropdownMenuTrigger as={Button} variant="outline" aria-label="More actions" size="icon">
|
||||
<div class="i-tabler-dots-vertical size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpenDropdownId(null);
|
||||
updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled });
|
||||
}}
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</Button>
|
||||
)}
|
||||
</AllowedOriginsDialog>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => deleteEmail({ intakeEmailId: intakeEmail.id })}
|
||||
aria-label="Delete intake email"
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('intake-emails.actions.delete')}
|
||||
</Button>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openAllowedOriginsDialog(intakeEmail)}
|
||||
>
|
||||
<div class="i-tabler-edit size-4 mr-2" />
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpenDropdownId(null);
|
||||
deleteEmail({ intakeEmailId: intakeEmail.id });
|
||||
}}
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('intake-emails.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -355,6 +395,22 @@ export const IntakeEmailsPage: Component = () => {
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<Show when={selectedIntakeEmail()}>
|
||||
{intakeEmail => (
|
||||
<AllowedOriginsDialog
|
||||
intakeEmails={intakeEmail()}
|
||||
open={true}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSelectedIntakeEmail(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{() => <div />}
|
||||
</AllowedOriginsDialog>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -46,7 +46,8 @@ export const WebhookEventsPicker: Component<{ events: WebhookEvent[]; onChange:
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
{/* <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> */}
|
||||
<For each={getEventsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,9 @@ export const WEBHOOK_EVENTS = [
|
||||
events: [
|
||||
'document:created',
|
||||
'document:deleted',
|
||||
'document:updated',
|
||||
'document:tag:added',
|
||||
'document:tag:removed',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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,6 +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.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",
|
||||
|
||||
@@ -7,8 +7,8 @@ import { createServer } from './modules/app/server';
|
||||
import { parseConfig } from './modules/config/config';
|
||||
import { createIngestionFolderWatcher } from './modules/ingestion-folders/ingestion-folders.usecases';
|
||||
import { createLogger } from './modules/shared/logger/logger';
|
||||
import { createTaskScheduler } from './modules/tasks/task-scheduler';
|
||||
import { taskDefinitions } from './modules/tasks/tasks.defiitions';
|
||||
import { registerTaskDefinitions } from './modules/tasks/tasks.definitions';
|
||||
import { createTaskServices } from './modules/tasks/tasks.services';
|
||||
|
||||
const logger = createLogger({ namespace: 'app-server' });
|
||||
|
||||
@@ -17,8 +17,8 @@ const { config } = await parseConfig({ env });
|
||||
await ensureLocalDatabaseDirectoryExists({ config });
|
||||
const { db, client } = setupDatabase(config.database);
|
||||
|
||||
const { app } = await createServer({ config, db });
|
||||
const { taskScheduler } = createTaskScheduler({ config, taskDefinitions, tasksArgs: { db } });
|
||||
const taskServices = createTaskServices({ config });
|
||||
const { app } = await createServer({ config, db, taskServices });
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
@@ -30,6 +30,7 @@ const server = serve(
|
||||
|
||||
if (config.ingestionFolder.isEnabled) {
|
||||
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
|
||||
taskServices,
|
||||
config,
|
||||
db,
|
||||
});
|
||||
@@ -37,11 +38,12 @@ if (config.ingestionFolder.isEnabled) {
|
||||
await startWatchingIngestionFolders();
|
||||
}
|
||||
|
||||
taskScheduler.start();
|
||||
await registerTaskDefinitions({ taskServices, db, config });
|
||||
|
||||
taskServices.start();
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
server.close();
|
||||
taskScheduler.stop();
|
||||
client.close();
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseConfig } from '../config/config';
|
||||
import { createEmailsServices } from '../emails/emails.services';
|
||||
import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
|
||||
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import { createTaskServices } from '../tasks/tasks.services';
|
||||
import { createTrackingServices } from '../tracking/tracking.services';
|
||||
import { createAuthEmailsServices } from './auth/auth.emails.services';
|
||||
import { getAuth } from './auth/auth.services';
|
||||
@@ -23,6 +24,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
const trackingServices = createTrackingServices({ config });
|
||||
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }), trackingServices }).auth;
|
||||
const subscriptionsServices = createSubscriptionsServices({ config });
|
||||
const taskServices = partialDeps.taskServices ?? createTaskServices({ config });
|
||||
|
||||
return {
|
||||
config,
|
||||
@@ -31,6 +33,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
emailsServices,
|
||||
subscriptionsServices,
|
||||
trackingServices,
|
||||
taskServices,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { EmailsServices } from '../emails/emails.services';
|
||||
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import type { TaskServices } from '../tasks/tasks.services';
|
||||
import type { TrackingServices } from '../tracking/tracking.services';
|
||||
import type { Auth } from './auth/auth.services';
|
||||
import type { Session } from './auth/auth.types';
|
||||
@@ -28,6 +29,7 @@ export type GlobalDependencies = {
|
||||
emailsServices: EmailsServices;
|
||||
subscriptionsServices: SubscriptionsServices;
|
||||
trackingServices: TrackingServices;
|
||||
taskServices: TaskServices;
|
||||
};
|
||||
|
||||
export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies;
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createError } from '../shared/errors/errors';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { validateFormData, validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
|
||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -244,7 +245,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
|
||||
|
||||
await triggerWebhooks({
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:deleted',
|
||||
@@ -479,6 +480,7 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
||||
@@ -489,6 +491,13 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
...updateData,
|
||||
});
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:updated',
|
||||
payload: { documentId, organizationId, ...updateData },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'updated',
|
||||
|
||||
@@ -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';
|
||||
@@ -24,29 +25,15 @@ import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
|
||||
import { createTagsRepository } from '../tags/tags.repository';
|
||||
import { createTrackingServices } from '../tracking/tracking.services';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -133,7 +121,7 @@ export async function createDocument({
|
||||
|
||||
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
|
||||
|
||||
await triggerWebhooks({
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:created',
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,39 @@
|
||||
import { defineTask } from '../../tasks/tasks.models';
|
||||
import type { Database } from '../../app/database/database.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { TaskServices } from '../../tasks/tasks.services';
|
||||
import { createLogger } from '../../shared/logger/logger';
|
||||
import { createDocumentsRepository } from '../documents.repository';
|
||||
import { deleteExpiredDocuments } from '../documents.usecases';
|
||||
import { createDocumentStorageService } from '../storage/documents.storage.services';
|
||||
|
||||
export const hardDeleteExpiredDocumentsTaskDefinition = defineTask({
|
||||
name: 'hard-delete-expired-documents',
|
||||
isEnabled: ({ config }) => config.tasks.hardDeleteExpiredDocuments.enabled,
|
||||
cronSchedule: ({ config }) => config.tasks.hardDeleteExpiredDocuments.cron,
|
||||
runOnStartup: ({ config }) => config.tasks.hardDeleteExpiredDocuments.runOnStartup,
|
||||
handler: async ({ db, config, now, logger }) => {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
const logger = createLogger({ namespace: 'documents:tasks:hardDeleteExpiredDocuments' });
|
||||
|
||||
const { deletedDocumentsCount } = await deleteExpiredDocuments({
|
||||
config,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
now,
|
||||
});
|
||||
export async function registerHardDeleteExpiredDocumentsTask({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
const taskName = 'hard-delete-expired-documents';
|
||||
const { cron, runOnStartup } = config.tasks.hardDeleteExpiredDocuments;
|
||||
|
||||
logger.info({ deletedDocumentsCount }, 'Expired documents deleted');
|
||||
},
|
||||
});
|
||||
taskServices.registerTask({
|
||||
taskName,
|
||||
handler: async () => {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
|
||||
const { deletedDocumentsCount } = await deleteExpiredDocuments({
|
||||
config,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
});
|
||||
|
||||
logger.info({ deletedDocumentsCount }, 'Expired documents deleted');
|
||||
},
|
||||
});
|
||||
|
||||
await taskServices.schedulePeriodicJob({
|
||||
scheduleId: `periodic-${taskName}`,
|
||||
taskName,
|
||||
cron,
|
||||
immediate: runOnStartup,
|
||||
});
|
||||
|
||||
logger.info({ taskName, cron, runOnStartup }, 'Hard delete expired documents task registered');
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { defineTask } from '../../tasks/tasks.models';
|
||||
import type { Database } from '../../app/database/database.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { TaskServices } from '../../tasks/tasks.services';
|
||||
import { createLogger } from '../../shared/logger/logger';
|
||||
import { createOrganizationsRepository } from '../organizations.repository';
|
||||
|
||||
export const expireInvitationsTaskDefinition = defineTask({
|
||||
name: 'expire-invitations',
|
||||
isEnabled: ({ config }) => config.tasks.expireInvitations.enabled,
|
||||
cronSchedule: ({ config }) => config.tasks.expireInvitations.cron,
|
||||
runOnStartup: ({ config }) => config.tasks.expireInvitations.runOnStartup,
|
||||
handler: async ({ db, now }) => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const logger = createLogger({ namespace: 'organizations:tasks:expireInvitations' });
|
||||
|
||||
await organizationsRepository.updateExpiredPendingInvitationsStatus({ now });
|
||||
},
|
||||
});
|
||||
export async function registerExpireInvitationsTask({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
const taskName = 'expire-invitations';
|
||||
const { cron, runOnStartup } = config.tasks.expireInvitations;
|
||||
|
||||
taskServices.registerTask({
|
||||
taskName,
|
||||
handler: async () => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
await organizationsRepository.updateExpiredPendingInvitationsStatus();
|
||||
|
||||
logger.info('Updated expired pending invitations status');
|
||||
},
|
||||
});
|
||||
|
||||
await taskServices.schedulePeriodicJob({
|
||||
scheduleId: `periodic-${taskName}`,
|
||||
taskName,
|
||||
cron,
|
||||
immediate: runOnStartup,
|
||||
});
|
||||
|
||||
logger.info({ taskName, cron, runOnStartup }, 'Update expired pending invitations status task registered');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -11,3 +11,9 @@ export const createTagAlreadyExistsError = createErrorFactory({
|
||||
code: 'tags.already_exists',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
export const createTagNotFoundError = createErrorFactory({
|
||||
message: 'Tag not found',
|
||||
code: 'tags.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createTagsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationTags,
|
||||
getTagById,
|
||||
createTag,
|
||||
deleteTag,
|
||||
updateTag,
|
||||
@@ -50,6 +51,20 @@ async function getOrganizationTags({ organizationId, db }: { organizationId: str
|
||||
return { tags };
|
||||
}
|
||||
|
||||
async function getTagById({ tagId, organizationId, db }: { tagId: string; organizationId: string; db: Database }) {
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tagsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(tagsTable.id, tagId),
|
||||
eq(tagsTable.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
|
||||
return { tag };
|
||||
}
|
||||
|
||||
async function createTag({ tag, db }: { tag: DbInsertableTag; db: Database }) {
|
||||
const [result, error] = await safely(db.insert(tagsTable).values(tag).returning());
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@ import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { createDocumentActivityRepository } from '../documents/document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from '../documents/document-activity/document-activity.usecases';
|
||||
import { createDocumentNotFoundError } from '../documents/documents.errors';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { documentIdSchema } from '../documents/documents.schemas';
|
||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { TagColorRegex } from './tags.constants';
|
||||
import { createTagNotFoundError } from './tags.errors';
|
||||
import { createTagsRepository } from './tags.repository';
|
||||
import { tagIdSchema } from './tags.schemas';
|
||||
|
||||
@@ -161,12 +166,34 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.addTagToDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:added',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'tagged',
|
||||
@@ -197,12 +224,34 @@ function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.removeTagFromDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:removed',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'untagged',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { TaskDefinition } from './tasks.models';
|
||||
import cron from 'node-cron';
|
||||
import { createLogger, wrapWithLoggerContext } from '../shared/logger/logger';
|
||||
import { generateId } from '../shared/random/ids';
|
||||
|
||||
export { createTaskScheduler };
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:scheduler' });
|
||||
|
||||
function createTaskScheduler({
|
||||
config,
|
||||
taskDefinitions,
|
||||
tasksArgs,
|
||||
}: {
|
||||
config: Config;
|
||||
taskDefinitions: TaskDefinition[];
|
||||
tasksArgs: { db: Database };
|
||||
}) {
|
||||
const scheduledTasks = taskDefinitions.map((taskDefinition) => {
|
||||
const isEnabled = taskDefinition.getIsEnabled({ config });
|
||||
const cronSchedule = taskDefinition.getCronSchedule({ config });
|
||||
const runOnStartup = taskDefinition.getRunOnStartup({ config });
|
||||
|
||||
if (!isEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronSchedule,
|
||||
async () => wrapWithLoggerContext(
|
||||
{
|
||||
taskId: generateId({ prefix: 'task' }),
|
||||
taskName: taskDefinition.taskName,
|
||||
},
|
||||
async () => taskDefinition.run({ ...tasksArgs, config }),
|
||||
),
|
||||
{
|
||||
scheduled: false,
|
||||
runOnInit: runOnStartup,
|
||||
},
|
||||
);
|
||||
|
||||
return { job: task, taskName: taskDefinition.taskName };
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
taskScheduler: {
|
||||
scheduledTasks,
|
||||
start() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.start();
|
||||
logger.debug({ taskName }, 'Task scheduled');
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.stop();
|
||||
logger.debug({ taskName }, 'Task unscheduled');
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
|
||||
export const tasksConfig = {
|
||||
persistence: {
|
||||
driver: {
|
||||
doc: 'The driver to use for the tasks persistence',
|
||||
schema: z.enum(['memory']),
|
||||
default: 'memory',
|
||||
env: 'TASKS_PERSISTENCE_DRIVER',
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
id: {
|
||||
doc: 'The id of the task worker, used to identify the worker in the Cadence cluster in case of multiple workers',
|
||||
schema: z.string().optional(),
|
||||
env: 'TASKS_WORKER_ID',
|
||||
},
|
||||
},
|
||||
hardDeleteExpiredDocuments: {
|
||||
enabled: {
|
||||
doc: 'Whether the task to hard delete expired "soft deleted" documents is enabled',
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { hardDeleteExpiredDocumentsTaskDefinition } from '../documents/tasks/hard-delete-expired-documents.task';
|
||||
import { expireInvitationsTaskDefinition } from '../organizations/tasks/expire-invitations.task';
|
||||
|
||||
export const taskDefinitions = [
|
||||
hardDeleteExpiredDocumentsTaskDefinition,
|
||||
expireInvitationsTaskDefinition,
|
||||
];
|
||||
12
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal file
12
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal file
@@ -0,0 +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 });
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export { defineTask };
|
||||
|
||||
export type TaskDefinition = ReturnType<typeof defineTask>;
|
||||
|
||||
function defineTask({
|
||||
name: taskName,
|
||||
cronSchedule,
|
||||
isEnabled,
|
||||
runOnStartup = false,
|
||||
handler,
|
||||
logger: taskLogger = createLogger({ namespace: `tasks:${taskName}` }),
|
||||
}: {
|
||||
name: string;
|
||||
isEnabled: boolean | ((args: { config: Config }) => boolean);
|
||||
cronSchedule: string | ((args: { config: Config }) => string);
|
||||
runOnStartup?: boolean | ((args: { config: Config }) => boolean);
|
||||
handler: (handlerArgs: { db: Database; config: Config; logger: Logger; now: Date }) => Promise<void>;
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const run = async ({
|
||||
getNow = () => new Date(),
|
||||
logger = taskLogger,
|
||||
...handlerArgs
|
||||
}: {
|
||||
db: Database;
|
||||
config: Config;
|
||||
getNow?: () => Date;
|
||||
logger?: Logger;
|
||||
}) => {
|
||||
const startedAt = getNow();
|
||||
|
||||
try {
|
||||
logger.debug({ taskName, startedAt }, 'Task started');
|
||||
|
||||
await handler({ ...handlerArgs, logger, now: getNow() });
|
||||
|
||||
const durationMs = getNow().getTime() - startedAt.getTime();
|
||||
logger.info({ taskName, durationMs, startedAt }, 'Task completed');
|
||||
} catch (error) {
|
||||
logger.error({ error, taskName, startedAt }, 'Task failed');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
taskName,
|
||||
run,
|
||||
getIsEnabled: (args: { config: Config }) => (isFunction(isEnabled) ? isEnabled(args) : isEnabled),
|
||||
getCronSchedule: (args: { config: Config }) => (isFunction(cronSchedule) ? cronSchedule(args) : cronSchedule),
|
||||
getRunOnStartup: (args: { config: Config }) => (isFunction(runOnStartup) ? runOnStartup(args) : runOnStartup),
|
||||
};
|
||||
}
|
||||
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import { createCadence } from '@cadence-mq/core';
|
||||
import { createMemoryDriver } from '@cadence-mq/driver-memory';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export type TaskServices = ReturnType<typeof createTaskServices>;
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:services' });
|
||||
|
||||
export function createTaskServices({ config }: { config: Config }) {
|
||||
const workerId = config.tasks.worker.id ?? 'default';
|
||||
|
||||
const driver = createMemoryDriver();
|
||||
const cadence = createCadence({ driver, logger });
|
||||
|
||||
return {
|
||||
...cadence,
|
||||
start: () => {
|
||||
const worker = cadence.createWorker({ workerId });
|
||||
|
||||
worker.start();
|
||||
|
||||
logger.info({ workerId }, 'Task worker started');
|
||||
|
||||
return worker;
|
||||
},
|
||||
};
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { WebhookRepository } from './webhook.repository';
|
||||
import type { Webhook } from './webhooks.types';
|
||||
import { triggerWebhook as triggerWebhookServiceImpl } from '@papra/webhooks';
|
||||
import pLimit from 'p-limit';
|
||||
import { createDeferable } from '../shared/async/defer';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { createWebhookNotFoundError } from './webhook.errors';
|
||||
|
||||
@@ -107,6 +108,8 @@ export async function triggerWebhooks({
|
||||
);
|
||||
}
|
||||
|
||||
export const deferTriggerWebhooks = createDeferable(triggerWebhooks);
|
||||
|
||||
export async function triggerWebhook({
|
||||
webhook,
|
||||
webhookRepository,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Volutpat massa enim mi lectys auisque faucibus sapien parturigng
|
||||
aliquet. Pulvinar vehicula cura nostra ultricies aptent sollicitugin
|
||||
egestas posuere justo, Hendrerit sollicitudin mus amet condimentum
|
||||
feugiat maecenas sit iacyis himenaeos. Tacit ultrices purgs posuere
|
||||
lacinia porta nisi varius placerat Porta. Sagitts ligula in vel egestas
|
||||
natoque feugiat ligula omare soos.
|
||||
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
import type { PartialExtractorConfig } from '../../src/types';
|
||||
|
||||
export const config: PartialExtractorConfig = {
|
||||
tesseract: {
|
||||
languages: ['fra'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
EF: Look Scanned
|
||||
Comment Utiliser Look Scanned pour
|
||||
Numériser vos Documents
|
||||
Look Scanned vous permet de transformer facilement vos
|
||||
documents en versions numérisées d'aspect professionnel. Voici
|
||||
comment procéder :
|
||||
Importez votre Fichier
|
||||
Cliquez sur le bouton "Importer un Fichier" ou glissez-déposez
|
||||
directement votre document sur la page. Look Scanned prend en
|
||||
charge de nombreux formats : PDF, images (JPG, PNG), DOCX, PPTX,
|
||||
Excel, Markdown, HTML et TXT. Dès que votre fichier est importé, un
|
||||
aperçu s'affiche instantanément pour vous permettre d'ajuster les
|
||||
effets.
|
||||
Personnalisez l'Effet de Numérisation
|
||||
Une fois votre fichier importé, vous pouvez personnaliser les effets
|
||||
selon vos besoins. Ajustez l'angle d'inclinaison, la luminosité, le
|
||||
contraste et le niveau de flou pour obtenir l'aspect d'un véritable
|
||||
document numérisé. Chaque modification est visible en temps réel
|
||||
dans l'aperçu, vous permettant d'obtenir exactement le résultat
|
||||
souhaité.
|
||||
Look Scanned traite les documents de plusieurs pages en maintenant
|
||||
une apparence cohérente sur l'ensemble du document.
|
||||
Téléchargez votre Document
|
||||
Une fois satisfait du résultat, cliquez sur "Générer le Document
|
||||
Numérisé”. Le traitement ne prend que quelques secondes. Vous
|
||||
pourrez ensuite télécharger votre fichier en cliquant sur
|
||||
"Télécharger". Tout le processus s'effectue localement sur votre
|
||||
|
||||
appareil et nous ne conservons aucun contenu, garantissant ainsi la
|
||||
confidentialité de vos documents.
|
||||
|
||||
Conseils d'Utilisation
|
||||
|
||||
Look Scanned offre une solution rapide et efficace pour créer des
|
||||
documents à l'aspect authentiquement numérisé, sans installation de
|
||||
logiciel. Rendez-vous sur lookscanned.io pour donner un aspect
|
||||
professionnel à vos documents !
|
||||
Binary file not shown.
@@ -46,8 +46,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"sharp": "^0.32.6",
|
||||
"tesseract.js": "^6.0.0",
|
||||
"unpdf": "^0.12.1"
|
||||
"unpdf": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('extractors usecases', () => {
|
||||
|
||||
for (const fixture of fixturesDir) {
|
||||
// use test.concurrent to run the tests in parallel -> need to use the provided expect
|
||||
test.concurrent(`fixture ${fixture}`, async ({ expect }) => {
|
||||
test(`fixture ${fixture}`, { timeout: 10_000, concurrent: true }, async ({ expect }) => {
|
||||
const fixtureFilesPaths = await glob([`${fixture}/*`]);
|
||||
const inputFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.input\.\w+$/));
|
||||
const configFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.config\.ts$/));
|
||||
|
||||
@@ -2,6 +2,17 @@ import { Buffer } from 'node:buffer';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
|
||||
export async function extractTextFromImage(maybeArrayBuffer: ArrayBuffer | Buffer, { languages }: { languages: string[] }) {
|
||||
const buffer = maybeArrayBuffer instanceof ArrayBuffer ? Buffer.from(maybeArrayBuffer) : maybeArrayBuffer;
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export const imageExtractorDefinition = defineTextExtractor({
|
||||
name: 'image',
|
||||
mimeTypes: [
|
||||
@@ -13,13 +24,8 @@ export const imageExtractorDefinition = defineTextExtractor({
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const content = await extractTextFromImage(arrayBuffer, { languages });
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return { content: text };
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { extractText } from 'unpdf';
|
||||
import sharp from 'sharp';
|
||||
import { extractImages, extractText, getDocumentProxy } from 'unpdf';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
import { extractTextFromImage } from './img.extractor';
|
||||
|
||||
export const pdfExtractorDefinition = defineTextExtractor({
|
||||
name: 'pdf',
|
||||
mimeTypes: ['application/pdf'],
|
||||
extract: async ({ arrayBuffer }) => {
|
||||
const { text } = await extractText(arrayBuffer, { mergePages: true });
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
return { content: text };
|
||||
const pdf = await getDocumentProxy(arrayBuffer);
|
||||
|
||||
const { text, totalPages } = await extractText(pdf, { mergePages: true });
|
||||
|
||||
if (text && text.trim().length > 0) {
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
const imageTexts = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const images = await extractImages(pdf, i);
|
||||
|
||||
for (const image of images) {
|
||||
const imageBuffer = await sharp(image.data, {
|
||||
raw: { width: image.width, height: image.height, channels: image.channels },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const imageText = await extractTextFromImage(imageBuffer, { languages });
|
||||
imageTexts.push(imageText);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: imageTexts.join('\n') };
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user