Compare commits

...

15 Commits

Author SHA1 Message Date
Corentin Thomasset
f5d951cc82 chore(n8n): added scope in package name 2025-08-04 20:35:35 +02:00
Corentin Thomasset
47f9c5b186 refactor(n8n): updated changeset 2025-08-04 13:54:15 +02:00
Corentin Thomasset
0b97e58785 chore(n8n): added workflow file for n8n nodes 2025-08-04 13:50:43 +02:00
Corentin Thomasset
d51779aeb8 refactor(n8n): auto lint 2025-08-04 13:25:33 +02:00
Marco Mihai Condrache
8f30ec0281 feat(n8n): initial setup of n8n node package (#443)
* feat: n8n package implementation

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: typo

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: wrong requests

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: pagination

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: search

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* feat: use correct regex

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: general fixes

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: use color type

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: specs

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: result

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: file download

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* chore: changeset

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* feat: add readme

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

* fix: typo

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-08-04 13:23:42 +02:00
Corentin Thomasset
5868800bce fix(tags): fixed the impossibility to delete a tag that have been affected to a document (#448)
* fix(tags): fixed the impossibility to delete a tag that have been affected to a document

- Added user feedback for errors encountered during tag deletion in the client.
- Updated localization files to include new error messages for internal processing issues across multiple languages.
- Modified the document activity log migration to set foreign keys to null on delete for user and tag references.

* Update .changeset/green-teeth-fall.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .changeset/cuddly-shoes-watch.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-02 00:41:43 +02:00
Corentin Thomasset
b5ccc135ba refactor(documents): document content extraction is now async (#447)
* refactor(documents): implement asynchronous document content extraction

- Updated dependencies for `@cadence-mq/core` and `@cadence-mq/driver-memory` to versions 0.2.1 and 0.2.0 respectively.
- Introduced a new task for extracting document file content asynchronously.
- Refactored document creation use case to schedule the extraction task.
- Added utility functions for stream conversion and text extraction from files.
- Updated relevant tests to accommodate the new asynchronous behavior and task services integration.

* Update .changeset/cyan-pots-begin.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-01 21:44:29 +00:00
Manuel Zavatta
5e46bb9e6a feat(i18n): added Italian translation
* Create it.yml

cloned from en.yml

* Update it.yml

italian translation

* Update i18n.constants.ts

* fix(i18n): lint and auto order

* chore(versioning): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-31 21:15:43 +00:00
Corentin Thomasset
41a113334a refactor(tasks): integrated cadence task services (#436) 2025-07-28 18:30:11 +00:00
Corentin Thomasset
6723baf98a feat(webhooks): add document update and tag events (#432) 2025-07-25 16:46:05 +02:00
Corentin Thomasset
bbe5fe74e2 test(lecture): added fixture test timeout (#431) 2025-07-25 12:56:46 +00:00
Corentin Thomasset
a8cff8cedc refactor(webhooks): updated webhooks signatures and payload to match standard-webhook spec (#430) 2025-07-25 11:29:26 +02:00
Corentin Thomasset
67b3b14cdf feat(lecture): added ocr support for scanned pdf (#429) 2025-07-24 22:21:10 +02:00
Osaf Ali Sayed
ffdae8db56 feat(intake-emails): redesigned intake email list (#412)
* feat(intake-emails): redesigned intake email list

* fix(intake-emails): fix linting

* fix(intake-emails): set drop down menu trigger size same as icon

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-14 13:28:48 +00:00
Edward205
7768840aa4 refactor(i18n): improved Romanian translation (#419)
* added diacritics and improved wording

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-14 11:36:10 +00:00
118 changed files with 7887 additions and 1362 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added diacritics and improved wording for Romanian translation

View File

@@ -0,0 +1,5 @@
---
"@papra/webhooks": minor
---
Breaking change: updated webhooks signatures and payload format to match standard-webhook spec

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added feedback when an error occurs while deleting a tag

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": minor
---
The file content extraction (like OCR) is now done asynchronously by the task runner

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Simplified the organization intake email list

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": minor
---
Fixed the impossibility to delete a tag that has been assigned to a document

View 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

View File

@@ -0,0 +1,7 @@
---
"@papra/app-client": minor
"@papra/app-server": minor
"@papra/webhooks": minor
---
Webhooks invocation is now defered

View File

@@ -0,0 +1,5 @@
---
"@papra/lecture": minor
---
Added support for scanned pdf content extraction

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added Italian (it) language support

View File

@@ -0,0 +1,5 @@
---
"n8n-nodes-papra": major
---
Added n8n nodes package for Papra

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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'

View File

@@ -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>
);
};

View File

@@ -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],

View File

@@ -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>

View File

@@ -4,6 +4,9 @@ export const WEBHOOK_EVENTS = [
events: [
'document:created',
'document:deleted',
'document:updated',
'document:tag:added',
'document:tag:removed',
],
},

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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",

View File

@@ -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);

View File

@@ -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}`, {

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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' }),
});

View File

@@ -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',

View File

@@ -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 ?? '',
};
}

View File

@@ -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
});
});
});
});

View File

@@ -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 });
}

View File

@@ -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,
});
},
});
}

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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 });

View File

@@ -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',
});
});
});

View File

@@ -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({

View File

@@ -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' },
]);
});
});

View File

@@ -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');
}

View File

@@ -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 };
}

View 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,
});

View File

@@ -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());

View File

@@ -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',

View File

@@ -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');
});
},
},
};
}

View File

@@ -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',

View File

@@ -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,
];

View 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 });
}

View File

@@ -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),
};
}

View 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;
},
};
}

View 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 });
}

View File

@@ -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,

View File

@@ -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.

View File

@@ -0,0 +1,7 @@
import type { PartialExtractorConfig } from '../../src/types';
export const config: PartialExtractorConfig = {
tesseract: {
languages: ['fra'],
},
};

View File

@@ -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 !

View File

@@ -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:",

View File

@@ -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$/));

View File

@@ -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 };
},
});

View File

@@ -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') };
},
});

View 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.

View 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}}',
},
},
};
}

View 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: '^_',
}],
},
});

View 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));
}

View File

View 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/"
}
]
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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] } };
}

View File

@@ -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,
];

View File

@@ -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] } };
}

View File

@@ -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) },
};
}

View File

@@ -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,
),
},
};
}

View 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';
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) },
};
}

View File

@@ -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] } };
}

View 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] } };
}

View File

@@ -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] },
};
}

View File

@@ -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,
];

View File

@@ -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] } };
}

View 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';
}>;

View 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];
}

View 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 = `/documents/statistics`;
const response = (await apiRequest.call(this, itemIndex, 'GET', endpoint)) as any;
return {
json: response,
};
}

View File

@@ -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,
];

View 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] } };
}

View 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 },
};
}

View 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] } };
}

View 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,
];

View 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] } };
}

View 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) },
};
}

View 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