Compare commits

..

11 Commits

Author SHA1 Message Date
Corentin Thomasset
e2f0b83863 refactor(client): switched from ModularForms to Formisch lib 2025-07-31 00:15:47 +02: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
Corentin Thomasset
dd3862e50c chore(release): update versions (#418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-13 22:45:48 +02:00
Corentin Thomasset
a82ff3a755 chore(docker): add lecture package.json to Dockerfiles (#417) 2025-07-13 20:42:55 +00:00
Corentin Thomasset
d5b00307da chore(dependencies): put unbuild in pnpm catalog (#416) 2025-07-13 20:19:12 +00:00
81 changed files with 1958 additions and 1718 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
---
Simplified the organization intake email list

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

@@ -1,5 +1,11 @@
# @papra/app-client
## 0.7.0
### Minor Changes
- [#417](https://github.com/papra-hq/papra/pull/417) [`a82ff3a`](https://github.com/papra-hq/papra/commit/a82ff3a755fa1164b4d8ff09b591ed6482af0ccc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - v0.7 release
## 0.6.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.6.4",
"version": "0.7.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
@@ -31,9 +31,9 @@
},
"dependencies": {
"@corentinth/chisels": "^1.3.1",
"@formisch/solid": "^0.2.0",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.1",
"@pdfslick/solid": "^2.3.0",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.14.10",

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

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

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

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

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

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

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

View File

@@ -1,469 +1,469 @@
# Authentication
auth.request-password-reset.title: Reseteaza parola
auth.request-password-reset.description: Introduceti adresa de email pentru a reseta parola.
auth.request-password-reset.requested: Daca exista un cont pentru acest email, am trimis un email cu linkul de resetare.
auth.request-password-reset.back-to-login: Inapoi la login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.title: Resetează parola
auth.request-password-reset.description: Introdu adresa de e-mail pentru a reseta parola.
auth.request-password-reset.requested: Dacă există un cont pentru acest e-mail, am trimis un e-mail pentru resetarea parolei.
auth.request-password-reset.back-to-login: Înapoi la autentificare
auth.request-password-reset.form.email.label: E-mail
auth.request-password-reset.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.request-password-reset.form.email.required: Introduceti adresa de email
auth.request-password-reset.form.email.invalid: Adresa email este invalida
auth.request-password-reset.form.email.required: Introdu adresa de e-mail
auth.request-password-reset.form.email.invalid: Adresa de e-mail este invalidă
auth.request-password-reset.form.submit: Trimite cererea de resetare a parolei
auth.reset-password.title: Reseteaza parola
auth.reset-password.description: Introdu o parola noua pentru a o reseta pe cea veche.
auth.reset-password.reset: Parola ta a fost resetata cu success.
auth.reset-password.back-to-login: Inapoi la login
auth.reset-password.form.new-password.label: Parola noua
auth.reset-password.title: Resetează parola
auth.reset-password.description: Introdu o parolă noua pentră a o reseta pe cea veche.
auth.reset-password.reset: Parola a fost resetată cu success.
auth.reset-password.back-to-login: Înapoi la autentificare
auth.reset-password.form.new-password.label: Parolă nouă
auth.reset-password.form.new-password.placeholder: 'Exemplu: **********'
auth.reset-password.form.new-password.required: Introdu noua parola
auth.reset-password.form.new-password.min-length: Parola trebuie sa fie de minim {{ minLength }} de caractere
auth.reset-password.form.new-password.max-length: Parola trebuie sa fie de maxim {{ maxLength }} de caractere
auth.reset-password.form.submit: Reseteaza parola
auth.reset-password.form.new-password.required: Introdu parola nouă
auth.reset-password.form.new-password.min-length: Parola trebuie să fie de minim {{ minLength }} caractere
auth.reset-password.form.new-password.max-length: Parola trebuie să fie de maxim {{ maxLength }} de caractere
auth.reset-password.form.submit: Resetează parola
auth.email-provider.open: Deschide {{ provider }}
auth.login.title: Inregistreaza-te pe Papra
auth.login.description: Introdu email ul pentru a accesa papra.
auth.login.login-with-provider: Inregistreaza-te cu {{ provider }}
auth.login.no-account: Nu ai un cont?
auth.login.register: Logheaza-te
auth.login.form.email.label: email
auth.login.title: Autentificare la Papra
auth.login.description: Introdu e-mailul sau folosește autentificarea cu cont social pentru a accesa contul Papra.
auth.login.login-with-provider: Autentificare cu {{ provider }}
auth.login.no-account: Nu ai cont?
auth.login.register: Înregistrare
auth.login.form.email.label: E-mail
auth.login.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.login.form.email.required: Introduceti adresa de email
auth.login.form.email.invalid: Adresa email este invalida
auth.login.form.email.required: Introdu adresa de e-mail
auth.login.form.email.invalid: Adresa e-mail este invalidă
auth.login.form.password.label: Parola
auth.login.form.password.placeholder: Seteaza o parola noua
auth.login.form.password.required: Introduceti parola noua
auth.login.form.remember-me.label: Nu ma uita
auth.login.form.password.placeholder: Setează o parola noua
auth.login.form.password.required: Introdu parola noua
auth.login.form.remember-me.label: Ține-mă minte
auth.login.form.forgot-password.label: Ai uitat parola?
auth.login.form.submit: Logheaza-te
auth.login.form.submit: Autentificare
auth.register.title: Inregistreaza-te pe Papra
auth.register.description: Introdu email ul pentru a accesa papra.
auth.register.register-with-email: Inregistreaza-te cu email
auth.register.title: Înregistrare la Papra
auth.register.description: Introdu e-mailul pentru a accesa Papra.
auth.register.register-with-email: înregistrează-te cu e-mail
auth.register.register-with-provider: Inregistreaza-te cu {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Ai deja un cont?
auth.register.login: Logheaza-te
auth.register.registration-disabled.title: Inregistrarea este dezactivata
auth.register.registration-disabled.description: Crearea de conturi noi este momentan dezactivata pe aceasta instanta de Papra. Doar utilizatorii cu conturi existente pot logheaza. Daca crezi ca e o greseala, contacteaza administratorul acestei instante.
auth.register.form.email.label: Email
auth.register.login: Autentificare
auth.register.registration-disabled.title: Înregistrarea este dezactivată
auth.register.registration-disabled.description: Crearea de conturi noi este momentan dezactivată pe această instanță de Papra. Doar utilizatorii cu conturi existente se pot autentifica. Dacă aceasta pare a fi o greșeală, contactează administratorul acestei instanțe.
auth.register.form.email.label: E-mail
auth.register.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.register.form.email.required: Introduceti adresa de email
auth.register.form.email.invalid: Adresa email este invalida
auth.register.form.email.required: Introdu adresa de e-mail
auth.register.form.email.invalid: Adresa e-mail este invalida
auth.register.form.password.label: Parola
auth.register.form.password.placeholder: Seteaza parola
auth.register.form.password.required: Te rugam sa introduci parola
auth.register.form.password.min-length: Parola trebuie sa fie de minim {{ minLength }} de caractere
auth.register.form.password.max-length: Parola trebuie sa fie de minim {{ maxLength }} de caractere
auth.register.form.password.placeholder: Setează parola
auth.register.form.password.required: Te rugăm să introduci parola
auth.register.form.password.min-length: Parola trebuie să fie de minim {{ minLength }} caractere
auth.register.form.password.max-length: Parola trebuie să fie de maxim {{ maxLength }} de caractere
auth.register.form.name.label: Nume
auth.register.form.name.placeholder: 'Exemplu: Andrei Popescu'
auth.register.form.name.required: Introduce-ti numele
auth.register.form.name.max-length: Numele trebuie sa fie de minim {{ maxLength }} de caractere
auth.register.form.submit: Inregistreaza-te
auth.register.form.name.required: Introdu numele
auth.register.form.name.max-length: Numele trebuie să fie de minim {{ maxLength }} caractere
auth.register.form.submit: Înregistrare
auth.email-validation-required.title: Verifica-ti email-ul
auth.email-validation-required.description: A fost trimis un email de verificare la adresa de email introdusa. Verificati email-ul dumneavoastra si click pe link-ul din email.
auth.email-validation-required.title: Verifică-ți email-ul
auth.email-validation-required.description: A fost trimis un e-mail de verificare la adresa ta de e-mail. Te rugăm să îți verifici adresa de e-mail dând click pe linkul din e-mail.
auth.legal-links.description: Continuand, confirmati ca intelegeti si sunteti de acord cu {{ terms }} si {{ privacy }}.
auth.legal-links.terms: Termenii si conditiile
auth.legal-links.description: Continuând, confirmați că întelegeți și sunteti de acord cu {{ terms }} și {{ privacy }}.
auth.legal-links.terms: Termenii și condițiile
auth.legal-links.privacy: Politica de confidențialitate
auth.no-auth-provider.title: Niciun provider de autentificare nu este adaugat
auth.no-auth-provider.description: Nu exista nicio metoda de autentificare configurata. Contactati administratorul acestei instante pentru a adauga o metoda de autentificare.
auth.no-auth-provider.title: Niciun furnizor de autentificare
auth.no-auth-provider.description: Nu este niciun furnizor de autentificare activat pe această instanță de Papra. Te rugăm să contactezi administratorul aceste instanțe pentru a le activa.
# User settings
user.settings.title: Setarile tale
user.settings.description: Configureaza-ti setarile tale.
user.settings.title: Setările utilizatorului
user.settings.description: Configurează setările contului aici.
user.settings.email.title: Adresa email
user.settings.email.description: Adresa ta de email nu poate fi schimbata.
user.settings.email.label: Adresa email
user.settings.email.title: Adresa de e-mail
user.settings.email.description: Adresa de e-mail nu poate fi schimbată.
user.settings.email.label: Adresa de e-mail
user.settings.name.title: Numele dvs.
user.settings.name.description: Numele dvs. va fi distribuit cu persoanele din organizatia dvs.
user.settings.name.label: Numele dvs.
user.settings.name.title: Numele complet
user.settings.name.description: Numele complet este afișat altor membri din organizație.
user.settings.name.label: Numele complet
user.settings.name.placeholder: Ex. Andrei Popescu
user.settings.name.update: Schimba-ti numele
user.settings.name.updated: Numele tau s-a schimbat
user.settings.name.update: Schimbă numele
user.settings.name.updated: Numele a fost schimbat
user.settings.logout.title: Iesi din cont
user.settings.logout.description: Iesi din cont
user.settings.logout.button: Iesi din cont
user.settings.logout.title: Deconectare
user.settings.logout.description: Vei fi deconectat din cont. Te poți conecta înapoi ulterior.
user.settings.logout.button: Deconectare
# Organizations
organizations.list.title: Organizatiile dvs.
organizations.list.description: Organizatiile sunt o modalitate de a grupa documentele si de a le gestiona accesul la acestea. Poti crea multiple organizatii si invita membrii echipei tale sa colabora.
organizations.list.create-new: Creeaza o noua organizatie
organizations.list.title: Organizațiile tale
organizations.list.description: Organizațiile sunt o modalitate de a grupa documentele și de a gestiona accesul la acestea. Poți crea multiple organizații și invita membrii echipei tale să colaboreze.
organizations.list.create-new: Creează o organizație nouă
organizations.details.no-documents.title: Niciun document
organizations.details.no-documents.description: Nu sunt documente in aceasta organizatie. Incepe prin uploadarea unei documente.
organizations.details.upload-documents: Incarca documente
organizations.details.no-documents.description: Încă nu există documente în această organizație. Încarcă niște documente pentru a începe.
organizations.details.upload-documents: Încarcă documente
organizations.details.documents-count: documente in total
organizations.details.total-size: marime totala
organizations.details.latest-documents: Ultimele documente incarcate
organizations.details.total-size: mărime totala
organizations.details.latest-documents: Ultimele documente încarcate
organizations.create.title: Creeaza o noua organizatie
organizations.create.description: Documentele dvs. sunt grupate pe organizatie. Puteti crea mai multe organizatii pentru documente diferite, de exemplu, pentru uz personal si profesional.
organizations.create.back: Inapoi
organizations.create.error.max-count-reached: Ai ajuns la numarul maxim de organizatii pe care le poti crea, daca ai nevoie de mai multe, contacteaza support ul.
organizations.create.form.name.label: Numle organizatiei
organizations.create.title: Creează o organizație nouă
organizations.create.description: Documentele sunt grupate în funcție de organizație. Pi crea mai multe organizații pentru documente diferite, de exemplu, pentru uz personal și profesional.
organizations.create.back: Înapoi
organizations.create.error.max-count-reached: Ai ajuns la numărul maxim de organizații pe care le poți crea. Dacă ai nevoie de mai multe, contactează asistența.
organizations.create.form.name.label: Numle organizației
organizations.create.form.name.placeholder: Ex. Acme SRL.
organizations.create.form.name.required: Introdu numele organizatiei
organizations.create.form.submit: Creeaza organizatia
organizations.create.success: Organizatia a fost creata cu success
organizations.create.form.name.required: Introdu numele organizației
organizations.create.form.submit: Creează organizația
organizations.create.success: Organizația a fost creată cu succes
organizations.create-first.title: Creeaza organizatia
organizations.create-first.description: Documentele dvs. sunt grupate pe organizatie. Puteti crea mai multe organizatii pentru documente diferite, de exemplu, pentru uz personal si profesional.
organizations.create-first.default-name: Organizatia mea
organizations.create-first.user-name: 'Organizatia {{ name }}'
organizations.create-first.title: Creează organizația
organizations.create-first.description: Documentele sunt grupate în funcție de organizație. Pi crea mai multe organizații pentru documente diferite, de exemplu, pentru uz personal și profesional.
organizations.create-first.default-name: Organizația mea
organizations.create-first.user-name: 'Organizația lui {{ name }}'
organization.settings.title: Setarile organizatiei
organization.settings.page.title: Setarile organizatiei
organization.settings.page.description: Gestioneaza setarile organizatiei tale aici.
organization.settings.name.title: Numele organizatiei
organization.settings.name.update: Actualizeaza numele
organization.settings.title: Setările organizației
organization.settings.page.title: Setările organizației
organization.settings.page.description: Gestionează setarile organizației aici.
organization.settings.name.title: Numele organizației
organization.settings.name.update: Actualizează numele
organization.settings.name.placeholder: Ex. Acme SRL.
organization.settings.name.updated: Numele organizatiei a fost actualizat
organization.settings.subscription.title: Subscriptie
organization.settings.subscription.description: Gestioneaza facturile, facturi si metodele de plata.
organization.settings.subscription.manage: Gestioneaza-ti subscriptia
organization.settings.subscription.error: Eroare la obtinerea URL-ului portalului client
organization.settings.delete.title: Sterge organizatie
organization.settings.delete.description: Stergerea acestei organizatii va elimina permanent toate datele asociate cu aceasta.
organization.settings.delete.confirm.title: Sterge organizatie
organization.settings.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta organizatie? Aceasta operatie nu poate fi anulata si toate datele asociate cu aceasta vor fi eliminate permanent.
organization.settings.delete.confirm.confirm-button: Sterge organizatie
organization.settings.delete.confirm.cancel-button: Anuleaza
organization.settings.delete.success: Organizatie stearsa cu success
organization.settings.name.updated: Numele organizației a fost actualizat
organization.settings.subscription.title: Abonament
organization.settings.subscription.description: Gestionează facturile și metodele de plată.
organization.settings.subscription.manage: Gestionează-ți abonamentul
organization.settings.subscription.error: Eroare la obținerea URL-ului portalului de client
organization.settings.delete.title: Șterge organizația
organization.settings.delete.description: Ștergerea acestei organizații va elimina definitiv toate datele asociate cu aceasta.
organization.settings.delete.confirm.title: Șterge organizatia
organization.settings.delete.confirm.message: Ești sigur că vrei să ștergi această organizație? Aceasta operatie nu poate fi anulată si toate datele asociate cu aceasta vor fi eliminate definitiv.
organization.settings.delete.confirm.confirm-button: Șterge organizație
organization.settings.delete.confirm.cancel-button: Anulează
organization.settings.delete.success: Organizație ștearsă cu succes
organizations.members.title: Membri
organizations.members.description: Gestioneaza membrii organizatiei tale
organizations.members.invite-member: Invita membru
organizations.members.invite-member-disabled-tooltip: Doar adminii sau proprietarii pot invita membrii la organizatie
organizations.members.remove-from-organization: Elimina din organizatie
organizations.members.description: Gestionează membrii organizației tale
organizations.members.invite-member: Invită membru
organizations.members.invite-member-disabled-tooltip: Doar administratorii sau proprietarii pot invita membrii la organizație
organizations.members.remove-from-organization: Elimina din organizație
organizations.members.role: Rol
organizations.members.roles.owner: Proprietar
organizations.members.roles.admin: Admin
organizations.members.roles.member: membru
organizations.members.delete.confirm.title: Eliminati membrul
organizations.members.delete.confirm.message: Esti sigur ca vrei sa stergi acest membru din organizatie?
organizations.members.delete.confirm.confirm-button: Elimina
organizations.members.delete.confirm.cancel-button: Anuleaza
organizations.members.delete.success: membru sters cu succes
organizations.members.roles.member: Membru
organizations.members.delete.confirm.title: Elimină membrul
organizations.members.delete.confirm.message: Ești sigur că vrei să elimini acest membru din organizație?
organizations.members.delete.confirm.confirm-button: Elimină
organizations.members.delete.confirm.cancel-button: Anulează
organizations.members.delete.success: Membru eliminat cu succes
organizations.members.update-role.success: Rolul membrului a fost actualizat
organizations.members.table.headers.name: Nume
organizations.members.table.headers.email: Email
organizations.members.table.headers.email: E-mail
organizations.members.table.headers.role: Rol
organizations.members.table.headers.created: Creat
organizations.members.table.headers.actions: Actiuni
organizations.members.table.headers.actions: Acțiuni
organizations.invite-member.title: Invita membru
organizations.invite-member.description: Invita un membru la organizatie
organizations.invite-member.form.email.label: Email
organizations.invite-member.title: Invită membru
organizations.invite-member.description: Invită un membru la organizație
organizations.invite-member.form.email.label: E-mail
organizations.invite-member.form.email.placeholder: 'Exemplu: ada@papra.app'
organizations.invite-member.form.email.required: Introduceti o adresa de email valida
organizations.invite-member.form.email.required: Introdu o adresă de e-mail validă
organizations.invite-member.form.role.label: Rol
organizations.invite-member.form.submit: Invita membru
organizations.invite-member.success.message: membru invitat
organizations.invite-member.success.description: Adresa de email a fost invitata la organizatie.
organizations.invite-member.error.message: Eroare la invitatia membrului
organizations.invite-member.form.submit: Invită membru
organizations.invite-member.success.message: Membru invitat
organizations.invite-member.success.description: Adresă de e-mail a fost invitată la organizație.
organizations.invite-member.error.message: Eroare la invitarea membrului
organizations.invitations.title: Invitatii
organizations.invitations.description: Gestioneaza invitatii la organizatie
organizations.invitations.list.cta: Invita membru
organizations.invitations.list.empty.title: Niciun invitat
organizations.invitations.list.empty.description: Nu ai fost invitat la nicio organizatie inca.
organizations.invitations.status.pending: In asteptare
organizations.invitations.status.accepted: Acceptat
organizations.invitations.status.rejected: Refuzat
organizations.invitations.status.expired: Expirat
organizations.invitations.status.cancelled: Anulat
organizations.invitations.resend: Retrimite invitatia
organizations.invitations.cancel.title: Anuleaza invitatia
organizations.invitations.cancel.description: Esti sigur ca vrei sa anulezi aceasta invitatie?
organizations.invitations.cancel.confirm: Anuleaza invitatia
organizations.invitations.cancel.cancel: Anuleaza
organizations.invitations.resend.title: Retrimite invitatia
organizations.invitations.resend.description: Esti sigur ca vrei sa retrimiteti aceasta invitatie? Acest lucru va trimite un nou email destinatarului.
organizations.invitations.resend.confirm: Retrimite invitatia
organizations.invitations.resend.cancel: Anuleaza
organizations.invitations.title: Invitații
organizations.invitations.description: Gestionează invitațiile la organizație
organizations.invitations.list.cta: Invită membru
organizations.invitations.list.empty.title: Nicio invitație în așteptare
organizations.invitations.list.empty.description: Încă nu ai fost invitat la nicio organizație.
organizations.invitations.status.pending: În așteptare
organizations.invitations.status.accepted: Acceptată
organizations.invitations.status.rejected: Respinsă
organizations.invitations.status.expired: Expirată
organizations.invitations.status.cancelled: Anulată
organizations.invitations.resend: Retrimite invitația
organizations.invitations.cancel.title: Anulează invitația
organizations.invitations.cancel.description: Ești sigur că vrei să anulezi această invitație?
organizations.invitations.cancel.confirm: Anulează invitația
organizations.invitations.cancel.cancel: Anulează
organizations.invitations.resend.title: Retrimite invitația
organizations.invitations.resend.description: Ești sigur că vrei să retrimiți această invitație? Se va trimite un nou e-mail destinatarului.
organizations.invitations.resend.confirm: Retrimite invitația
organizations.invitations.resend.cancel: Anulează
invitations.list.title: Invitatii
invitations.list.description: Gestioneaza invitatii la organizatie
invitations.list.empty.title: Niciun invitat
invitations.list.empty.description: Nu ai fost invitat la nicio organizatie inca.
invitations.list.headers.organization: Organizatie
invitations.list.title: Invitații
invitations.list.description: Gestionează invitații la organizație
invitations.list.empty.title: Nicio invitație în așteptare
invitations.list.empty.description: Încă nu ai fost invitat la nicio organizație.
invitations.list.headers.organization: Organizație
invitations.list.headers.status: Status
invitations.list.headers.created: Creat la
invitations.list.headers.actions: Actiuni
invitations.list.actions.accept: Accepta
invitations.list.actions.reject: Refuza
invitations.list.actions.accept.success.message: Invitatie acceptata
invitations.list.actions.accept.success.description: Invitatie a fost acceptata.
invitations.list.actions.reject.success.message: Invitatie refuzata
invitations.list.actions.reject.success.description: Invitatie a fost refuzata.
invitations.list.headers.actions: Acțiuni
invitations.list.actions.accept: Acceptă
invitations.list.actions.reject: Refuză
invitations.list.actions.accept.success.message: Invitație acceptată
invitations.list.actions.accept.success.description: Invitația a fost acceptată.
invitations.list.actions.reject.success.message: Invitație refuzată
invitations.list.actions.reject.success.description: Invitația a fost refuzată.
# Documents
documents.list.title: Documente
documents.list.no-documents.title: Niciun document
documents.list.no-documents.description: Nu exista documente in aceasta organizatie inca. Incepe prin a incarca cateva documente.
documents.list.no-results: Niciun document gasit
documents.list.no-documents.description: Încă nu există documente în aceasta organizație. Începe prin a încarca câteva documente.
documents.list.no-results: Nu au fost găsite documente
documents.tabs.info: Info
documents.tabs.content: Continut
documents.tabs.content: Conținut
documents.tabs.activity: Activitate
documents.deleted.message: Acest document a fost sters si va fi eliminat permanent in {{ days }} zile.
documents.actions.download: Descarca
documents.actions.open-in-new-tab: Deschide in fila noua
documents.actions.restore: Restaureaza
documents.actions.delete: Sterge
documents.actions.edit: Editeaza
documents.actions.cancel: Anuleaza
documents.actions.save: Salveaza
documents.actions.saving: Se salveaza...
documents.content.alert: Continutul documentului este extras automat din document la incarcare. Este folosit doar pentru cautare si indexare.
documents.deleted.message: Acest document a fost șters și va fi eliminat definitiv după {{ days }} zile.
documents.actions.download: Descarcă
documents.actions.open-in-new-tab: Deschide în filă nouă
documents.actions.restore: Restaurează
documents.actions.delete: Șterge
documents.actions.edit: Editează
documents.actions.cancel: Anulează
documents.actions.save: Salvează
documents.actions.saving: Se salvează...
documents.content.alert: Conținutul documentului este extras automat din document la încarcare. Este folosit doar pentru căutare și indexare.
documents.info.id: ID
documents.info.name: Nume
documents.info.type: Tip
documents.info.size: Dimensiune
documents.info.created-at: Creat la
documents.info.updated-at: Actualizat la
documents.info.never: Niciodata
documents.info.never: Niciodată
documents.rename.title: Redenumeste documentul
documents.rename.title: Redenumește documentul
documents.rename.form.name.label: Nume
documents.rename.form.name.placeholder: 'Exemplu: Factura 2024'
documents.rename.form.name.required: Va rugam sa introduceti un nume pentru document
documents.rename.form.name.max-length: Numele trebuie sa aiba mai putin de 255 de caractere
documents.rename.form.submit: Redenumeste documentul
documents.rename.form.name.required: Te rugăm să introduci un nume pentru document
documents.rename.form.name.max-length: Numele trebuie să aibă mai puțin de 255 de caractere
documents.rename.form.submit: Redenumește documentul
documents.rename.success: Document redenumit cu succes
documents.rename.cancel: Anuleaza
documents.rename.cancel: Anulează
import-documents.title.error: '{{ count }} documente au esuat'
import-documents.title.error: '{{ count }} documente au eșuat'
import-documents.title.success: '{{ count }} documente importate'
import-documents.title.pending: '{{ count }} / {{ total }} documente importate'
import-documents.title.none: Importa documente
import-documents.no-import-in-progress: Niciun import de documente in curs
import-documents.title.none: Importă documente
import-documents.no-import-in-progress: Niciun import de documente în curs
documents.deleted.title: Documente sterse
documents.deleted.empty.title: Niciun document sters
documents.deleted.empty.description: Nu aveti documente sterse. Documentele care sunt sterse vor fi mutate in cosul de gunoi pentru {{ days }} zile.
documents.deleted.retention-notice: Toate documentele sterse sunt stocate in cosul de gunoi pentru {{ days }} zile. Dupa acest interval, documentele vor fi sterse permanent si nu le veti putea restaura.
documents.deleted.deleted-at: Sterse la
documents.deleted.restoring: Se restaureaza...
documents.deleted.deleting: Se sterge...
documents.deleted.title: Documente șterse
documents.deleted.empty.title: Niciun document șters
documents.deleted.empty.description: Nu ai niciun document șters. Documentele care sunt șterse vor fi mutate în coșul de gunoi timp de {{ days }} zile.
documents.deleted.retention-notice: Toate documentele șterse sunt stocate în coșul de gunoi timp de {{ days }} zile. După acest interval, documentele vor fi șterse definitiv și nu le vei mai putea restaura.
documents.deleted.deleted-at: Șterse la
documents.deleted.restoring: Se restaurează...
documents.deleted.deleting: Se șterge...
documents.preview.unknown-file-type: Nicio previzualizare disponibila pentru acest tip de fisier
documents.preview.binary-file: Acesta pare a fi un fisier binar si nu poate fi afisat ca text
documents.preview.unknown-file-type: Nicio previzualizare disponibilă pentru acest tip de fișier
documents.preview.binary-file: Acesta pare a fi un fișier binar și nu poate fi afișat ca text
trash.delete-all.button: Sterge tot
trash.delete-all.confirm.title: Stergeti permanent toate documentele?
trash.delete-all.confirm.description: Sunteti sigur ca doriti sa stergeti permanent toate documentele din cosul de gunoi? Aceasta actiune nu poate fi anulata.
trash.delete-all.confirm.label: Sterge
trash.delete-all.confirm.cancel: Anuleaza
trash.delete.button: Sterge
trash.delete.confirm.title: Stergeti permanent documentul?
trash.delete.confirm.description: Sunteti sigur ca doriti sa stergeti permanent acest document din cosul de gunoi? Aceasta actiune nu poate fi anulata.
trash.delete.confirm.label: Sterge
trash.delete.confirm.cancel: Anuleaza
trash.deleted.success.title: Document sters
trash.deleted.success.description: Documentul a fost sters permanent.
trash.delete-all.button: Șterge tot
trash.delete-all.confirm.title: Ștergi definitiv toate documentele?
trash.delete-all.confirm.description: ti sigur că dorti să ștergi definitiv toate documentele din coșul de gunoi? Această acțiune nu poate fi anulată.
trash.delete-all.confirm.label: Șterge
trash.delete-all.confirm.cancel: Anulează
trash.delete.button: Șterge
trash.delete.confirm.title: Ștergi definitiv documentul?
trash.delete.confirm.description: Sunteti sigur ca doriti să stergeti definitiv acest document din cosul de gunoi? Această actiune nu poate fi anulată.
trash.delete.confirm.label: Șterge
trash.delete.confirm.cancel: Anulează
trash.deleted.success.title: Document șters
trash.deleted.success.description: Documentul a fost șters definitiv.
activity.document.created: Documentul a fost creat
activity.document.updated.single: Campul {{ field }} a fost actualizat
activity.document.updated.multiple: Campurile {{ fields }} au fost actualizate
activity.document.updated.single: Câmpul {{ field }} a fost actualizat
activity.document.updated.multiple: Câmpurile {{ fields }} au fost actualizate
activity.document.updated: Documentul a fost actualizat
activity.document.deleted: Documentul a fost sters
activity.document.deleted: Documentul a fost șters
activity.document.restored: Documentul a fost restaurat
activity.document.tagged: Eticheta {{ tag }} a fost adaugata
activity.document.untagged: Eticheta {{ tag }} a fost eliminata
activity.document.tagged: Eticheta {{ tag }} a fost adaugată
activity.document.untagged: Eticheta {{ tag }} a fost eliminată
activity.document.user.name: de {{ name }}
activity.load-more: Incarca mai mult
activity.no-more-activities: Nu mai sunt activitati pentru acest document
activity.load-more: Încarcă mai multe
activity.no-more-activities: Nu mai sunt activități pentru acest document
# Tags
tags.no-tags.title: Inca nu exista etichete
tags.no-tags.description: Aceasta organizatie nu are inca etichete. Etichetele sunt folosite pentru a clasifica documentele. Puteti adauga etichete la documentele dvs. pentru a le gasi si organiza mai usor.
tags.no-tags.create-tag: Creeaza eticheta
tags.no-tags.title: Încă nu există etichete
tags.no-tags.description: Această organizație nu are încă etichete. Etichetele sunt folosite pentru a clasifica documentele. Pi adăuga etichete la documente pentru a le găsi și organiza mai ușor.
tags.no-tags.create-tag: Creează eticheta
tags.title: Etichete documente
tags.description: Etichetele sunt folosite pentru a clasifica documentele. Puteti adauga etichete la documentele dvs. pentru a le gasi si organiza mai usor.
tags.create: Creeaza eticheta
tags.update: Actualizeaza eticheta
tags.delete: Sterge eticheta
tags.delete.confirm.title: Sterge eticheta
tags.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta eticheta? Stergerea unei etichete o va elimina din toate documentele.
tags.delete.confirm.confirm-button: Sterge
tags.delete.confirm.cancel-button: Anuleaza
tags.delete.success: Eticheta a fost stearsa cu succes
tags.create.success: Eticheta "{{ name }}" a fost creata cu succes.
tags.update.success: Eticheta "{{ name }}" a fost actualizata cu succes.
tags.description: Etichetele sunt folosite pentru a clasifica documentele. Pi adăuga etichete la documente pentru a le găsi și organiza mai ușor.
tags.create: Creează eticheta
tags.update: Actualizează eticheta
tags.delete: Șterge eticheta
tags.delete.confirm.title: Șterge eticheta
tags.delete.confirm.message: Ești sigur că vrei să ștergi aceasta eticheta? Stergerea unei etichete o va elimina din toate documentele.
tags.delete.confirm.confirm-button: Șterge
tags.delete.confirm.cancel-button: Anulează
tags.delete.success: Eticheta a fost ștearsă cu succes
tags.create.success: Eticheta "{{ name }}" a fost creată cu succes.
tags.update.success: Eticheta "{{ name }}" a fost actualizată cu succes.
tags.form.name.label: Nume
tags.form.name.placeholder: Ex. Contracte
tags.form.name.required: Va rugam sa introduceti un nume de eticheta
tags.form.name.max-length: Numele etichetei trebuie sa aiba mai putin de 64 de caractere
tags.form.name.required: Te rugăm să introduci un nume pentru etichetă
tags.form.name.max-length: Numele etichetei trebuie să aibă mai puțin de 64 de caractere
tags.form.color.label: Culoare
tags.form.color.required: Va rugam sa introduceti o culoare
tags.form.color.invalid: Culoarea hex este formatata gresit.
tags.form.color.required: Te rugăm să introduci o culoare
tags.form.color.invalid: Culoarea hex este formatată greșit.
tags.form.description.label: Descriere
tags.form.description.optional: (optional)
tags.form.description.placeholder: Ex. Toate contractele semnate de companie
tags.form.description.max-length: Descrierea trebuie sa aiba mai putin de 256 de caractere
tags.form.description.max-length: Descrierea trebuie să aibă mai puțin de 256 de caractere
tags.form.no-description: Nicio descriere
tags.table.headers.tag: Eticheta
tags.table.headers.tag: Etichetă
tags.table.headers.description: Descriere
tags.table.headers.documents: Documente
tags.table.headers.created: Creat la
tags.table.headers.actions: Actiuni
tags.table.headers.actions: Acțiuni
# Tagging rules
tagging-rules.field.name: nume document
tagging-rules.field.content: continut document
tagging-rules.operator.equals: este egal cu
tagging-rules.field.content: conținut document
tagging-rules.operator.equals: egal cu
tagging-rules.operator.not-equals: nu este egal cu
tagging-rules.operator.contains: contine
tagging-rules.operator.not-contains: nu contine
tagging-rules.operator.starts-with: incepe cu
tagging-rules.operator.ends-with: se termina cu
tagging-rules.operator.contains: conține
tagging-rules.operator.not-contains: nu conține
tagging-rules.operator.starts-with: începe cu
tagging-rules.operator.ends-with: se termină cu
tagging-rules.list.title: Reguli de etichetare
tagging-rules.list.description: Gestioneaza regulile de etichetare ale organizatiei tale, pentru a eticheta automat documentele pe baza conditiilor pe care le definesti.
tagging-rules.list.demo-warning: 'Nota: Deoarece acesta este un mediu demonstrativ (fara server), regulile de etichetare nu vor fi aplicate documentelor nou adaugate.'
tagging-rules.list.no-tagging-rules.title: Nicio regula de etichetare
tagging-rules.list.no-tagging-rules.description: Creati o regula de etichetare pentru a eticheta automat documentele adaugate pe baza conditiilor pe care le definiti.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Creeaza regula de etichetare
tagging-rules.list.card.no-conditions: Nicio conditie
tagging-rules.list.card.one-condition: 1 conditie
tagging-rules.list.card.conditions: '{{ count }} conditii'
tagging-rules.list.card.delete: Sterge regula
tagging-rules.list.card.edit: Editeaza regula
tagging-rules.create.title: Creeaza regula de etichetare
tagging-rules.create.success: Regula de etichetare a fost creata cu succes
tagging-rules.list.description: Gestionează regulile de etichetare ale organizației pentru a eticheta automat documentele pe baza unor condiții definite.
tagging-rules.list.demo-warning: 'Notă: Deoarece acesta este un mediu demonstrativ (fără server), regulile de etichetare nu vor fi aplicate documentelor nou adăugate.'
tagging-rules.list.no-tagging-rules.title: Nicio regulă de etichetare
tagging-rules.list.no-tagging-rules.description: Creează o regulă de etichetare pentru a eticheta automat documentele adăugate pe baza unor condiții definite.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Creează regula de etichetare
tagging-rules.list.card.no-conditions: Nicio condiție
tagging-rules.list.card.one-condition: O condiție
tagging-rules.list.card.conditions: '{{ count }} condiții'
tagging-rules.list.card.delete: Șterge regula
tagging-rules.list.card.edit: Editează regula
tagging-rules.create.title: Creează regula de etichetare
tagging-rules.create.success: Regula de etichetare a fost creată cu succes
tagging-rules.create.error: Nu s-a putut crea regula de etichetare
tagging-rules.create.submit: Creeaza regula
tagging-rules.create.submit: Creează regula
tagging-rules.form.name.label: Nume
tagging-rules.form.name.placeholder: 'Exemplu: Eticheteaza facturile'
tagging-rules.form.name.min-length: Va rugam sa introduceti un nume pentru regula
tagging-rules.form.name.max-length: Numele trebuie sa aiba mai putin de 64 de caractere
tagging-rules.form.name.placeholder: 'Exemplu: Etichetează facturile'
tagging-rules.form.name.min-length: Te rugăm să introduci numele regulii
tagging-rules.form.name.max-length: Numele trebuie să aibă mai puțin de 64 de caractere
tagging-rules.form.description.label: Descriere
tagging-rules.form.description.placeholder: "Exemplu: Eticheteaza documentele cu 'factura' in nume"
tagging-rules.form.description.max-length: Descrierea trebuie sa aiba mai putin de 256 de caractere
tagging-rules.form.conditions.label: Conditii
tagging-rules.form.conditions.description: Definiti conditiile care trebuie indeplinite pentru ca regula sa se aplice. Toate conditiile trebuie indeplinite pentru ca regula sa se aplice.
tagging-rules.form.conditions.add-condition: Adauga conditie
tagging-rules.form.conditions.no-conditions.title: Nicio conditie
tagging-rules.form.conditions.no-conditions.description: Nu ati adaugat nicio conditie acestei reguli. Aceasta regula va aplica etichetele sale tuturor documentelor.
tagging-rules.form.conditions.no-conditions.confirm: Aplica regula fara conditii
tagging-rules.form.conditions.no-conditions.cancel: Anuleaza
tagging-rules.form.conditions.value.placeholder: 'Exemplu: factura'
tagging-rules.form.conditions.value.min-length: Va rugam sa introduceti o valoare pentru conditie
tagging-rules.form.description.placeholder: "Exemplu: Etichetează documentele cu 'factură' în nume"
tagging-rules.form.description.max-length: Descrierea trebuie să aibă mai puțin de 256 de caractere
tagging-rules.form.conditions.label: Condiții
tagging-rules.form.conditions.description: Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Toate condițiile trebuie îndeplinite pentru ca regula să se aplice.
tagging-rules.form.conditions.add-condition: Adaugă condiție
tagging-rules.form.conditions.no-conditions.title: Nicio condiție
tagging-rules.form.conditions.no-conditions.description: Nu ai adăugat nicio condiție acestei reguli. Această regula va aplica etichetele sale tuturor documentelor.
tagging-rules.form.conditions.no-conditions.confirm: Aplică regula fara condiții
tagging-rules.form.conditions.no-conditions.cancel: Anulează
tagging-rules.form.conditions.value.placeholder: 'Exemplu: factură'
tagging-rules.form.conditions.value.min-length: Te rugăm să introduci o valoare pentru condiție
tagging-rules.form.tags.label: Etichete
tagging-rules.form.tags.description: Selecteaza etichetele de aplicat documentelor adaugate care corespund conditiilor
tagging-rules.form.tags.min-length: Este necesara cel putin o eticheta de aplicat
tagging-rules.form.tags.add-tag: Creeaza eticheta
tagging-rules.form.submit: Creeaza regula
tagging-rules.update.title: Actualizeaza regula de etichetare
tagging-rules.form.tags.description: Selectează etichetele de aplicat documentelor adăugate care corespund condițiilor
tagging-rules.form.tags.min-length: Este necesară cel puțin o etichetă de aplicat
tagging-rules.form.tags.add-tag: Creează eticheta
tagging-rules.form.submit: Creează regula
tagging-rules.update.title: Actualizează regula de etichetare
tagging-rules.update.error: Nu s-a putut actualiza regula de etichetare
tagging-rules.update.submit: Actualizeaza regula
tagging-rules.update.cancel: Anuleaza
tagging-rules.update.submit: Actualizează regula
tagging-rules.update.cancel: Anulează
# Intake emails
intake-emails.title: Email-uri de preluare
intake-emails.description: Adresele de email de preluare sunt folosite pentru a introduce automat email-uri in Papra. Doar trimiteti email-uri catre adresa de email de preluare, iar atasamentele lor vor fi adaugate la documentele organizatiei dvs.
intake-emails.disabled.title: Email-urile de preluare sunt dezactivate
intake-emails.disabled.description: Email-urile de preluare sunt dezactivate pe aceasta instanta. Va rugam sa contactati administratorul pentru a le activa. Consultati {{ documentation }} pentru mai multe informatii.
intake-emails.disabled.documentation: documentatie
intake-emails.info: Doar email-urile de preluare activate de la origini permise vor fi procesate. Puteti activa sau dezactiva un email de preluare oricand.
intake-emails.empty.title: Niciun email de preluare
intake-emails.empty.description: Generati o adresa de preluare pentru a ingera cu usurinta atasamentele de email.
intake-emails.empty.generate: Genereaza email de preluare
intake-emails.count: '{{ count }} email{{ plural }} de preluare pentru aceasta organizatie'
intake-emails.new: Email nou de preluare
intake-emails.title: E-mailuri de primire
intake-emails.description: Adresele de e-mail de primire sunt folosite pentru a introduce automat email-uri în Papra. Doar redirecționează e-mailuri către adresa de primire, iar fișierele atașate vor fi adăugate automat în documentele organizației tale.
intake-emails.disabled.title: Email-urile de primire sunt dezactivate
intake-emails.disabled.description: Email-urile de primire sunt dezactivate pe aceasta instanță. Te rugăm să contactezi administratorul pentru a le activa. Consultă {{ documentation }} pentru mai multe informații.
intake-emails.disabled.documentation: documentația
intake-emails.info: Vor fi procesate numai e-mailurile de primire activate de la originile permise. Poți activa sau dezactiva un e-mail de primire în orice moment.
intake-emails.empty.title: Niciun e-mail de primire
intake-emails.empty.description: Generează o adresă de primire pentru a primi cu ușurință fișiere atașate din e-mail.
intake-emails.empty.generate: Generează e-mail de primire
intake-emails.count: '{{ count }} email{{ plural }} de primire pentru această organizație'
intake-emails.new: E-mail nou de primire
intake-emails.disabled-label: (Dezactivat)
intake-emails.no-origins: Nicio origine de email permisa
intake-emails.no-origins: Nicio origine de e-mail permisă
intake-emails.allowed-origins: Permis de la {{ count }} adrese{{ plural }}
intake-emails.actions.enable: Activeaza
intake-emails.actions.disable: Dezactiveaza
intake-emails.actions.manage-origins: Gestioneaza adresele de origine
intake-emails.actions.delete: Sterge
intake-emails.delete.confirm.title: Sterge email-ul de preluare?
intake-emails.delete.confirm.message: Esti sigur ca vrei sa stergi acest email de preluare? Aceasta actiune nu poate fi anulata.
intake-emails.delete.confirm.confirm-button: Sterge email-ul de preluare
intake-emails.delete.confirm.cancel-button: Anuleaza
intake-emails.delete.success: Email de preluare sters
intake-emails.create.success: Email de preluare creat
intake-emails.update.success.enabled: Email de preluare activat
intake-emails.update.success.disabled: Email de preluare dezactivat
intake-emails.actions.enable: Activează
intake-emails.actions.disable: Dezactivează
intake-emails.actions.manage-origins: Gestionează adresele de origine
intake-emails.actions.delete: Șterge
intake-emails.delete.confirm.title: Ștergi email-ul de primire?
intake-emails.delete.confirm.message: Ești sigur că vrei să ștergi acest e-mail de primire? Această acțiune nu poate fi anulată.
intake-emails.delete.confirm.confirm-button: Șterge email-ul de primire
intake-emails.delete.confirm.cancel-button: Anulează
intake-emails.delete.success: E-mail de primire șters
intake-emails.create.success: E-mail de primire creat
intake-emails.update.success.enabled: E-mail de primire activat
intake-emails.update.success.disabled: E-mail de primire dezactivat
intake-emails.allowed-origins.title: Origini permise
intake-emails.allowed-origins.description: Doar email-urile trimise la {{ email }} de la aceste origini vor fi procesate. Daca nu sunt specificate origini, toate email-urile vor fi ignorate.
intake-emails.allowed-origins.add.label: Adauga adresa de email de origine permisa
intake-emails.allowed-origins.description: Doar email-urile trimise la {{ e-mail }} de la aceste origini vor fi procesate. Dacă nu sunt specificate origini, toate email-urile vor fi ignorate.
intake-emails.allowed-origins.add.label: Adaugă adresa de e-mail de origine permisă
intake-emails.allowed-origins.add.placeholder: Ex. ada@papra.app
intake-emails.allowed-origins.add.button: Adauga
intake-emails.allowed-origins.add.error.exists: Acest email este deja in originile permise pentru acest email de preluare
intake-emails.allowed-origins.add.button: Adaugă
intake-emails.allowed-origins.add.error.exists: Acest e-mail este deja în originile permise pentru acest e-mail de primire
# API keys
api-keys.permissions.documents.title: Documente
api-keys.permissions.documents.documents:create: Creaza documente
api-keys.permissions.documents.documents:read: Citeste documente
api-keys.permissions.documents.documents:update: Actualizeaza documente
api-keys.permissions.documents.documents:delete: Sterge documente
api-keys.permissions.documents.documents:create: Creează documente
api-keys.permissions.documents.documents:read: Citește documente
api-keys.permissions.documents.documents:update: Actualizează documente
api-keys.permissions.documents.documents:delete: Șterge documente
api-keys.permissions.tags.title: Etichete
api-keys.permissions.tags.tags:create: Creaza etichete
api-keys.permissions.tags.tags:read: Citeste etichete
api-keys.permissions.tags.tags:update: Actualizeaza etichete
api-keys.permissions.tags.tags:delete: Sterge etichete
api-keys.create.title: Creeaza cheie API
api-keys.create.description: Creeaza o noua cheie API pentru a accesa API-ul Papra.
api-keys.create.success: Cheia API a fost creata cu succes.
api-keys.create.back: Inapoi la cheile API
api-keys.permissions.tags.tags:create: Creează etichete
api-keys.permissions.tags.tags:read: Citește etichete
api-keys.permissions.tags.tags:update: Actualizează etichete
api-keys.permissions.tags.tags:delete: Șterge etichete
api-keys.create.title: Creează cheie API
api-keys.create.description: Creează o nouă cheie API pentru a accesa API-ul Papra.
api-keys.create.success: Cheia API a fost creată cu succes.
api-keys.create.back: Înapoi la cheile API
api-keys.create.form.name.label: Nume
api-keys.create.form.name.placeholder: 'Exemplu: Cheia mea API'
api-keys.create.form.name.required: Va rugam sa introduceti un nume pentru cheia API
api-keys.create.form.name.required: Te rugăm să introduci un nume pentru cheia API
api-keys.create.form.permissions.label: Permisiuni
api-keys.create.form.permissions.required: Va rugam sa selectati cel putin o permisiune
api-keys.create.form.submit: Creeaza cheie API
api-keys.create.created.title: Cheie API creata
api-keys.create.created.description: Cheia API a fost creata cu succes. Salvati-o intr-o locatie sigura, deoarece nu va mai fi afisata.
api-keys.create.form.permissions.required: Te rugăm să selectezi cel puțin o permisiune
api-keys.create.form.submit: Creează cheie API
api-keys.create.created.title: Cheie API creată
api-keys.create.created.description: Cheia API a fost creată cu succes. Salveaz-o într-un loc sigur, deoarece nu va fi afișată din nou.
api-keys.list.title: Chei API
api-keys.list.description: Gestioneaza-ti cheile API aici.
api-keys.list.create: Creeaza cheie API
api-keys.list.description: Gestionează-ți cheile API aici.
api-keys.list.create: Creează cheie API
api-keys.list.empty.title: Nicio cheie API
api-keys.list.empty.description: Creeaza o cheie API pentru a accesa API-ul Papra.
api-keys.list.empty.description: Creează o cheie API pentru a accesa API-ul Papra.
api-keys.list.card.last-used: Ultima utilizare
api-keys.list.card.never: Niciodata
api-keys.list.card.never: Niciodată
api-keys.list.card.created: Creat la
api-keys.delete.success: Cheia API a fost stearsa cu succes
api-keys.delete.confirm.title: Sterge cheia API
api-keys.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta cheie API? Aceasta actiune nu poate fi anulata.
api-keys.delete.confirm.confirm-button: Sterge
api-keys.delete.confirm.cancel-button: Anuleaza
api-keys.delete.success: Cheia API a fost ștearsă cu succes
api-keys.delete.confirm.title: Șterge cheia API
api-keys.delete.confirm.message: Ești sigur ca vrei să ștergi aceasta cheie API? Această acțiune nu poate fi anulată.
api-keys.delete.confirm.confirm-button: Șterge
api-keys.delete.confirm.cancel-button: Anulează
# Webhooks
webhooks.list.title: Webhook-uri
webhooks.list.description: Gestioneaza webhook-urile organizatiei tale
webhooks.list.description: Gestionează webhook-urile organizației tale
webhooks.list.empty.title: Niciun webhook
webhooks.list.empty.description: Creeaza primul tau webhook pentru a incepe sa primesti evenimente
webhooks.list.create: Creeaza webhook
webhooks.list.card.last-triggered: Ultima declansare
webhooks.list.card.never: Niciodata
webhooks.list.empty.description: Creează primul webhook pentru a începe să primesti evenimente
webhooks.list.create: Creează webhook
webhooks.list.card.last-triggered: Ultima declanșare
webhooks.list.card.never: Niciodată
webhooks.list.card.created: Creat la
webhooks.create.title: Creeaza webhook
webhooks.create.description: Creeaza un nou webhook pentru a primi evenimente
webhooks.create.title: Creează webhook
webhooks.create.description: Creează un nou webhook pentru a primi evenimente
webhooks.create.success: Webhook creat cu succes
webhooks.create.back: Inapoi
webhooks.create.form.submit: Creeaza webhook
webhooks.create.back: Înapoi
webhooks.create.form.submit: Creează webhook
webhooks.create.form.name.label: Nume webhook
webhooks.create.form.name.placeholder: Introdu numele webhook-ului
webhooks.create.form.name.required: Numele este obligatoriu
@@ -474,92 +474,96 @@ webhooks.create.form.url.invalid: URL-ul este invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Introdu secretul webhook-ului
webhooks.create.form.events.label: Evenimente
webhooks.create.form.events.required: Este necesar cel putin un eveniment
webhooks.update.title: Editeaza webhook
webhooks.update.description: Actualizeaza detaliile webhook-ului tau
webhooks.create.form.events.required: Este necesar cel puțin un eveniment
webhooks.update.title: Editează webhook
webhooks.update.description: Actualizează detaliile webhook-ului
webhooks.update.success: Webhook actualizat cu succes
webhooks.update.submit: Actualizeaza webhook
webhooks.update.cancel: Anuleaza
webhooks.update.form.secret.placeholder: Introdu un nou secret
webhooks.update.form.secret.placeholder-redacted: '[Secret redactat]'
webhooks.update.form.rotate-secret.button: Roteste secretul
webhooks.delete.success: Webhook sters cu succes
webhooks.delete.confirm.title: Sterge webhook
webhooks.delete.confirm.message: Esti sigur ca vrei sa stergi acest webhook?
webhooks.delete.confirm.confirm-button: Sterge
webhooks.delete.confirm.cancel-button: Anuleaza
webhooks.update.submit: Actualizează webhook
webhooks.update.cancel: Anulează
webhooks.update.form.secret.placeholder: Introdu un secret nou
webhooks.update.form.secret.placeholder-redacted: '[Secret protejat]'
webhooks.update.form.rotate-secret.button: Rotește secretul
webhooks.delete.success: Webhook șters cu succes
webhooks.delete.confirm.title: Șterge webhook
webhooks.delete.confirm.message: Ești sigur ca vrei să ștergi acest webhook?
webhooks.delete.confirm.confirm-button: Șterge
webhooks.delete.confirm.cancel-button: Anulează
webhooks.events.documents.title: Evenimente documente
webhooks.events.documents.document:created.description: Document creat
webhooks.events.documents.document:deleted.description: Document sters
webhooks.events.documents.document:deleted.description: Document șters
webhooks.events.documents.document:updated.description: Document actualizat
webhooks.events.documents.document:tag:added.description: O etichetă a fost adăugată la un document
webhooks.events.documents.document:tag:removed.description: O etichetă a fost eliminată dintr-un document
# Navigation
layout.menu.home: Acasa
layout.menu.home: Acasă
layout.menu.documents: Documente
layout.menu.tags: Etichete
layout.menu.tagging-rules: Reguli de etichetare
layout.menu.deleted-documents: Documente sterse
layout.menu.organization-settings: Setari organizatie
layout.menu.deleted-documents: Documente șterse
layout.menu.organization-settings: Setări organizație
layout.menu.api-keys: Chei API
layout.menu.settings: Setari
layout.menu.settings: Setări
layout.menu.account: Cont
layout.menu.general-settings: Setari generale
layout.menu.intake-emails: Email-uri de preluare
layout.menu.general-settings: Setări generale
layout.menu.intake-emails: Email-uri de primire
layout.menu.webhooks: Webhook-uri
layout.menu.members: Membri
layout.menu.invitations: Invitatii
layout.menu.invitations: Invitații
layout.theme.light: Mod luminos
layout.theme.dark: Mod intunecat
layout.theme.system: Mod sistem
layout.theme.system: Modul sistemului
layout.search.placeholder: Cauta...
layout.menu.import-document: Importa un document
layout.search.placeholder: Căutare...
layout.menu.import-document: Importă un document
user-menu.account-settings: Setari cont
user-menu.account-settings: Setări cont
user-menu.api-keys: Chei API
user-menu.invitations: Invitatii
user-menu.language: Limba
user-menu.invitations: Invitații
user-menu.language: Limbă
user-menu.logout: Deconectare
# Command palette
command-palette.search.placeholder: Cauta comenzi sau documente
command-palette.search.placeholder: Caută comenzi sau documente
command-palette.no-results: Niciun rezultat gasit
command-palette.sections.documents: Documente
command-palette.sections.theme: Tema
command-palette.sections.theme: Temă
# API errors
api-errors.document.already_exists: Documentul exista deja
api-errors.document.file_too_big: Fisierul documentului este prea mare
api-errors.intake_email.limit_reached: Numarul maxim de email-uri de preluare pentru aceasta organizatie a fost atins. Va rugam sa va imbunatatiti planul pentru a crea mai multe email-uri de preluare.
api-errors.user.max_organization_count_reached: Ai atins numarul maxim de organizatii pe care le poti crea, daca ai nevoie sa creezi mai multe, te rugam sa contactezi suportul.
api-errors.default: A aparut o eroare la procesarea cererii tale.
api-errors.organization.invitation_already_exists: O invitatie pentru acest email exista deja in aceasta organizatie.
api-errors.user.already_in_organization: Acest utilizator este deja in aceasta organizatie.
api-errors.user.organization_invitation_limit_reached: Numarul maxim de invitatii a fost atins pentru astazi. Va rugam sa incercati din nou maine.
api-errors.demo.not_available: Aceasta functie nu este disponibila in demo
api-errors.tags.already_exists: O eticheta cu acest nume exista deja pentru aceasta organizatie
api-errors.document.already_exists: Documentul există deja
api-errors.document.file_too_big: Fișierul documentului este prea mare
api-errors.intake_email.limit_reached: Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.
api-errors.user.max_organization_count_reached: Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.
api-errors.default: A apărut o eroare la procesarea cererii.
api-errors.organization.invitation_already_exists: O invitatie pentru acest e-mail există deja în această organizație.
api-errors.user.already_in_organization: Acest utilizator este deja în această organizație.
api-errors.user.organization_invitation_limit_reached: Numărul maxim de invitații a fost atins pentru astazi. Te rugăm să încerci din nou mâine.
api-errors.demo.not_available: Această functie nu este disponibila în demo
api-errors.tags.already_exists: O etichetă cu acest nume există deja pentru aceasta organizație
# Not found
not-found.title: 404 - Nu a fost gasit
not-found.description: Ne pare rau, pagina pe care o cautati nu pare sa existe. Va rugam sa verificati URL-ul si sa incercati din nou.
not-found.back-to-home: Inapoi la pagina principala
not-found.description: Ne pare rău, pagina pe care o cauți nu pare să existe. Te rugăm să verifici URL-ul și să încerci din nou.
not-found.back-to-home: Înapoi la pagina principală
# Demo
demo.popup.description: Acesta este un mediu demonstrativ, toate datele sunt salvate in stocarea locala a browserului dumneavoastra.
demo.popup.discord: Alaturati-va {{ discordLink }} pentru a obtine suport, a propune functionalitati sau doar pentru a discuta.
demo.popup.discord-link-label: server Discord
demo.popup.reset: Reseteaza datele demo
demo.popup.description: Acesta este un mediu demonstrativ, toate datele sunt salvate in stocarea locală a browserului.
demo.popup.discord: Alătură-te {{ discordLink }} pentru a obtine asistență, a propune funcționalități sau doar pentru a discuta.
demo.popup.discord-link-label: serverului de Discord
demo.popup.reset: Resetează datele demo
demo.popup.hide: Ascunde
# Color picker
color-picker.hue: Nuanta
color-picker.saturation: Saturatie
color-picker.hue: Nuanță
color-picker.saturation: Saturație
color-picker.lightness: Luminozitate
color-picker.select-color: Selecteaza culoarea
color-picker.select-a-color: Selecteaza o culoare
color-picker.select-color: Selectează culoarea
color-picker.select-a-color: Selectează o culoare

View File

@@ -1,5 +1,5 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { setInput } from '@formisch/solid';
import { A } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
@@ -79,35 +79,35 @@ export const CreateApiKeyPage: Component = () => {
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="permissions" type="string[]">
<Field path={['permissions']}>
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
<ApiKeyPermissionsPicker permissions={(field.input as string[]) ?? []} onChange={permissions => setInput(form, { path: ['permissions'], input: permissions })} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
<Button type="submit" isLoading={form.isSubmitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>

View File

@@ -31,7 +31,7 @@ export const EmailLoginForm: Component = () => {
}
if (error) {
throw error;
throw new Error(error.message);
}
},
schema: v.object({
@@ -54,32 +54,32 @@ export const EmailLoginForm: Component = () => {
return (
<Form>
<Field name="email">
{(field, inputProps) => (
<Field path={['email']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">{t('auth.login.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...field.props} value={field.input} autoFocus aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="password">
{(field, inputProps) => (
<Field path={['password']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">{t('auth.login.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="flex justify-between items-center mb-4">
<Field name="rememberMe" type="boolean">
{(field, inputProps) => (
<Checkbox class="flex items-center gap-2" defaultChecked={field.value}>
<CheckboxControl inputProps={inputProps} />
<Field path={['rememberMe']}>
{field => (
<Checkbox class="flex items-center gap-2" defaultChecked={field.input as boolean}>
<CheckboxControl inputProps={field.props} />
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('auth.login.form.remember-me.label')}
</CheckboxLabel>
@@ -94,9 +94,9 @@ export const EmailLoginForm: Component = () => {
</Show>
</div>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>{t('auth.login.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
</Form>
);

View File

@@ -30,7 +30,7 @@ export const EmailRegisterForm: Component = () => {
});
if (error) {
throw error;
throw new Error(error.message);
}
if (config.auth.isEmailVerificationRequired) {
@@ -63,41 +63,42 @@ export const EmailRegisterForm: Component = () => {
return (
<Form>
<Field name="email">
{(field, inputProps) => (
<Field path={['email']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">{t('auth.register.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="name">{t('auth.register.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="password">
{(field, inputProps) => (
<Field path={['password']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">{t('auth.register.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
{t('auth.register.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
</Form>
);
};

View File

@@ -29,21 +29,21 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string })
return (
<Form>
<Field name="email">
{(field, inputProps) => (
<Field path={['email']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">{t('auth.request-password-reset.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
{t('auth.request-password-reset.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
<div class="text-red-500 text-sm mt-2">{form.errors?.[0]}</div>
</Form>
);

View File

@@ -27,21 +27,21 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
return (
<Form>
<Field name="newPassword">
{(field, inputProps) => (
<Field path={['newPassword']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="newPassword">{t('auth.reset-password.form.new-password.label')}</TextFieldLabel>
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
{t('auth.reset-password.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
<div class="text-red-500 text-sm mt-2">{form.errors?.[0]}</div>
</Form>
);

View File

@@ -1,5 +1,5 @@
import type { Component, ParentComponent } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { setInput } from '@formisch/solid';
import { useMutation } from '@tanstack/solid-query';
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
import * as v from 'valibot';
@@ -58,7 +58,7 @@ export const RenameDocumentDialog: Component<{
});
createEffect(() => {
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
setInput(form, { path: ['name'], input: getDocumentNameWithoutExtension({ name: props.documentName }) });
});
return (
@@ -69,21 +69,21 @@ export const RenameDocumentDialog: Component<{
</DialogHeader>
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot>
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField {...field.props} value={field.input} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="flex justify-end mt-4 gap-2">
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)} disabled={form.isSubmitting}>
{t('documents.rename.cancel')}
</Button>
<Button type="submit">{t('documents.rename.form.submit')}</Button>
<Button type="submit" isLoading={form.isSubmitting}>{t('documents.rename.form.submit')}</Button>
</div>
</Form>
</DialogContent>

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'

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>
@@ -76,21 +102,21 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
</DialogHeader>
<Form>
<Field name="email">
{(field, inputProps) => (
<Field path={['email']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
<TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
<div class="flex items-center gap-2">
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<Button type="submit">
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
<Button type="submit" isLoading={form.isSubmitting}>
<div class="i-tabler-plus size-4 mr-2" />
{t('intake-emails.allowed-origins.add.button')}
</Button>
</div>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
{field.error && <div class="text-red-500 text-sm">{field.error }</div>}
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
@@ -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

@@ -37,23 +37,23 @@ export const CreateOrganizationForm: Component<{
return (
<div>
<Form>
<Field name="organizationName">
{(field, inputProps) => (
<Field path={['organizationName']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-6">
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="flex justify-end">
<Button type="submit" isLoading={form.submitting} class="w-full">
<Button type="submit" isLoading={form.isSubmitting} class="w-full">
{t('organizations.create.form.submit')}
</Button>
</div>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
</Form>
</div>
);

View File

@@ -1,5 +1,5 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { setInput } from '@formisch/solid';
import { useNavigate, useParams } from '@solidjs/router';
import { useMutation } from '@tanstack/solid-query';
import { onMount, Show } from 'solid-js';
@@ -101,8 +101,8 @@ export const InviteMemberPage: Component = () => {
<div class="mt-10 max-w-xs mx-auto">
<Form>
<Field name="email">
{(field, inputProps) => (
<Field path={['email']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="email">
{t('organizations.invite-member.form.email.label')}
@@ -113,16 +113,16 @@ export const InviteMemberPage: Component = () => {
placeholder={t(
'organizations.invite-member.form.email.placeholder',
)}
{...inputProps}
{...field.props}
/>
{field.error && (
<div class="text-red-500 text-sm">{field.error}</div>
{field.errors && (
<div class="text-red-500 text-sm">{field.errors[0]}</div>
)}
</TextFieldRoot>
)}
</Field>
<Field name="role">
<Field path={['role']}>
{field => (
<div>
<label for="role" class="text-sm font-medium mb-1 block">
@@ -139,9 +139,9 @@ export const InviteMemberPage: Component = () => {
{tRole(props.item.rawValue)}
</SelectItem>
)}
value={field.value}
value={field.input as InvitableRole}
onChange={value =>
setValue(form, 'role', value as InvitableRole)}
setInput(form, { path: ['role'], input: value as InvitableRole })}
>
<SelectTrigger>
<SelectValue<string>>

View File

@@ -138,25 +138,25 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
<Form>
<CardContent class="pt-6 ">
<Field name="organizationName">
{(field, inputProps) => (
<Field path={['organizationName']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="organizationName" class="sr-only">
{t('organization.settings.name.title')}
</TextFieldLabel>
<div class="flex gap-2 flex-col sm:flex-row">
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
<Button type="submit" isLoading={form.submitting} class="flex-shrink-0" disabled={field.value?.trim() === props.organization.name}>
<Button type="submit" isLoading={form.isSubmitting} class="flex-shrink-0" disabled={(field.input as string)?.trim() === props.organization.name}>
{t('organization.settings.name.update')}
</Button>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="text-red-500 text-sm">{form.response.message}</div>
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
</CardContent>
</Form>
</Card>

View File

@@ -1,15 +1,18 @@
import type { FormErrors, FormProps, PartialValues } from '@modular-forms/solid';
import type { FieldArrayProps, FieldProps, FormProps } from '@formisch/solid';
import type * as v from 'valibot';
import { createForm as createModularForm, FormError, valiForm } from '@modular-forms/solid';
import { createForm as createFormishForm, Field, FieldArray, Form } from '@formisch/solid';
import { createHook } from '../hooks/hooks';
// Extracted from the library to avoid type errors
type FormishDeepPartial<TValue> = TValue extends readonly unknown[] ? number extends TValue['length'] ? TValue : { [Key in keyof TValue]?: FormishDeepPartial<TValue[Key]> | undefined } : TValue extends Record<PropertyKey, unknown> ? { [Key in keyof TValue]?: FormishDeepPartial<TValue[Key]> | undefined } : TValue | undefined;
export function createForm<Schema extends v.ObjectSchema<any, any>>({
schema,
initialValues,
onSubmit,
}: {
schema: Schema;
initialValues?: PartialValues<v.InferInput<Schema>>;
initialValues?: FormishDeepPartial<v.InferInput<Schema>>;
onSubmit?: (values: v.InferInput<Schema>) => Promise<void>;
}) {
const submitHook = createHook<v.InferInput<Schema>>();
@@ -18,18 +21,18 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
submitHook.on(onSubmit);
}
const [form, { Form, Field, FieldArray }] = createModularForm<v.InferInput<Schema>>({
validate: valiForm(schema),
initialValues,
const form = createFormishForm({
schema,
initialInput: initialValues,
});
return {
form,
Form: (props: Omit<FormProps<v.InferInput<Schema>, undefined>, 'of'>) => Form({ ...props, onSubmit: submitHook.trigger }),
Field,
FieldArray,
onSubmit: submitHook.on,
Form: (props: Omit<FormProps<Schema>, 'of' | 'onSubmit'>) => Form({ of: form, ...props, onSubmit: async (args) => {
await submitHook.trigger(args);
} }),
Field: (props: Omit<FieldProps<Schema>, 'of'>) => Field({ of: form, ...props }),
FieldArray: (props: Omit<FieldArrayProps<Schema>, 'of'>) => FieldArray({ of: form, ...props }),
submit: submitHook.trigger,
createFormError: ({ message, fields }: { message: string; fields?: FormErrors<v.InferInput<Schema>> }) => new FormError<v.InferInput<Schema>>(message, fields),
};
}

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { insert, remove, setValue } from '@modular-forms/solid';
import { insert, remove, setInput } from '@formisch/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
@@ -45,24 +45,26 @@ export const TaggingRuleForm: Component<{
}
}
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
props.onSubmit({ taggingRule: { name, conditions, tagIds, description: description ?? '' } });
},
schema: v.object({
name: v.pipe(
v.string(),
v.string(t('tagging-rules.form.name.min-length')),
v.minLength(1, t('tagging-rules.form.name.min-length')),
v.maxLength(64, t('tagging-rules.form.name.max-length')),
),
description: v.pipe(
v.string(),
v.maxLength(256, t('tagging-rules.form.description.max-length')),
description: v.optional(
v.pipe(
v.string(),
v.maxLength(256, t('tagging-rules.form.description.max-length')),
),
),
conditions: v.optional(
v.array(v.object({
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
operator: v.picklist(Object.values(TAGGING_RULE_OPERATORS)),
value: v.pipe(
v.string(),
v.string(t('tagging-rules.form.conditions.value.min-length')),
v.minLength(1, t('tagging-rules.form.conditions.value.min-length')),
),
})),
@@ -90,33 +92,33 @@ export const TaggingRuleForm: Component<{
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="name">{t('tagging-rules.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('tagging-rules.form.name.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="description">
{(field, inputProps) => (
<Field path={['description']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mt-6">
<TextFieldLabel for="description">{t('tagging-rules.form.description.label')}</TextFieldLabel>
<TextArea
id="description"
placeholder={t('tagging-rules.form.description.placeholder')}
{...inputProps}
value={field.value}
{...field.props}
value={field.input}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
@@ -126,7 +128,7 @@ export const TaggingRuleForm: Component<{
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
<FieldArray name="conditions">
<FieldArray path={['conditions']}>
{fieldArray => (
<div>
<For each={fieldArray.items}>
@@ -134,12 +136,12 @@ export const TaggingRuleForm: Component<{
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
<div>When</div>
<Field name={`conditions.${index()}.field`}>
<Field path={['conditions', index(), 'field']}>
{field => (
<Select
id="field"
defaultValue={field.value}
onChange={value => value && setValue(form, `conditions.${index()}.field`, value)}
defaultValue={field.input as string}
onChange={value => value && setInput(form, { path: ['conditions', index(), 'field'], input: value })}
options={Object.values(TAGGING_RULE_FIELDS)}
itemComponent={props => (
<SelectItem item={props.item}>{getFieldLabel(props.item.rawValue)}</SelectItem>
@@ -153,12 +155,12 @@ export const TaggingRuleForm: Component<{
)}
</Field>
<Field name={`conditions.${index()}.operator`}>
<Field path={['conditions', index(), 'operator']}>
{field => (
<Select
id="operator"
defaultValue={field.value}
onChange={value => value && setValue(form, `conditions.${index()}.operator`, value)}
defaultValue={field.input as string}
onChange={value => value && setInput(form, { path: ['conditions', index(), 'operator'], input: value })}
options={Object.values(TAGGING_RULE_OPERATORS)}
itemComponent={props => (
<SelectItem item={props.item}>{getOperatorLabel(props.item.rawValue)}</SelectItem>
@@ -172,36 +174,36 @@ export const TaggingRuleForm: Component<{
)}
</Field>
<Field name={`conditions.${index()}.value`}>
{(field, inputProps) => (
<Field path={['conditions', index(), 'value']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 flex-1">
<TextField
id="value"
{...inputProps}
value={field.value}
{...field.props}
value={field.input}
placeholder={t('tagging-rules.form.conditions.value.placeholder')}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Button variant="outline" size="icon" onClick={() => remove(form, 'conditions', { at: index() })}>
<Button variant="outline" size="icon" onClick={() => remove(form, { path: ['conditions'], at: index() })}>
<div class="i-tabler-x size-4"></div>
</Button>
</div>
)}
</For>
{fieldArray.error && <div class="text-red-500 text-sm">{fieldArray.error}</div>}
{fieldArray.errors && <div class="text-red-500 text-sm">{fieldArray.errors[0]}</div>}
</div>
)}
</FieldArray>
<Button
variant="outline"
onClick={() => insert(form, 'conditions', { value: { field: 'name', operator: 'contains', value: '' } })}
onClick={() => insert(form, { path: ['conditions'], input: { field: 'name', operator: 'contains', value: '' } })}
class="gap-2 mt-2"
>
<div class="i-tabler-plus size-4"></div>
@@ -213,7 +215,7 @@ export const TaggingRuleForm: Component<{
<p class="mb-1 font-medium">{t('tagging-rules.form.tags.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.tags.description')}</p>
<Field name="tagIds" type="string[]">
<Field path={['tagIds']}>
{field => (
<>
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
@@ -221,8 +223,8 @@ export const TaggingRuleForm: Component<{
<DocumentTagPicker
organizationId={props.organizationId}
tagIds={field.value ?? []}
onTagsChange={({ tags }) => setValue(form, 'tagIds', tags.map(tag => tag.id))}
tagIds={(field.input as string[]) ?? []}
onTagsChange={({ tags }) => setInput(form, { path: ['tagIds'], input: tags.map(tag => tag.id) })}
/>
</div>
@@ -235,7 +237,7 @@ export const TaggingRuleForm: Component<{
)}
</CreateTagModal>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</>
)}
</Field>

View File

@@ -2,7 +2,7 @@ import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { safely } from '@corentinth/chisels';
import { getValues, setValue } from '@modular-forms/solid';
import { getInput, setInput } from '@formisch/solid';
import { A, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
@@ -45,7 +45,7 @@ const TagColorPicker: Component<{
};
const TagForm: Component<{
onSubmit: (values: { name: string; color: string; description: string }) => Promise<void>;
onSubmit: (values: { name: string; color: string; description?: string }) => Promise<void>;
initialValues?: { name?: string; color?: string; description?: string | null };
submitLabel?: string;
}> = (props) => {
@@ -54,21 +54,23 @@ const TagForm: Component<{
onSubmit: props.onSubmit,
schema: v.object({
name: v.pipe(
v.string(),
v.string(t('tags.form.name.required')),
v.trim(),
v.nonEmpty(t('tags.form.name.required')),
v.maxLength(64, t('tags.form.name.max-length')),
),
color: v.pipe(
v.string(),
v.string(t('tags.form.color.required')),
v.trim(),
v.nonEmpty(t('tags.form.color.required')),
v.hexColor(t('tags.form.color.invalid')),
),
description: v.pipe(
v.string(),
v.trim(),
v.maxLength(256, t('tags.form.description.max-length')),
description: v.optional(
v.pipe(
v.string(),
v.trim(),
v.maxLength(256, t('tags.form.description.max-length')),
),
'',
),
}),
initialValues: {
@@ -77,39 +79,39 @@ const TagForm: Component<{
},
});
const getFormValues = () => getValues(form);
const getFormValues = () => getInput(form);
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="name">{t('tags.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.name.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextField type="text" id="name" {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} placeholder={t('tags.form.name.placeholder')} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="color">
<Field path={['color']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
<TagColorPicker color={field.value ?? ''} onChange={color => setValue(form, 'color', color)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TagColorPicker color={(field.input as string) ?? ''} onChange={color => setInput(form, { path: ['color'], input: color })} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="description">
{(field, inputProps) => (
<Field path={['description']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="description">
{t('tags.form.description.label')}
<span class="font-normal ml-1 text-muted-foreground">{t('tags.form.description.optional')}</span>
</TextFieldLabel>
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.description.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
<TextArea id="description" {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} placeholder={t('tags.form.description.placeholder')} />
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
@@ -137,7 +139,7 @@ export const CreateTagModal: Component<{
const { t } = useI18n();
const { getErrorMessage } = useI18nApiErrors({ t });
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
const onSubmit = async ({ name, color, description }: { name: string; color: string; description?: string }) => {
const [,error] = await safely(createTag({
name,
color: color.toLowerCase(),
@@ -188,7 +190,7 @@ const UpdateTagModal: Component<{
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
const { t } = useI18n();
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
const onSubmit = async ({ name, color, description }: { name: string; color: string; description?: string }) => {
await updateTag({
name,
color: color.toLowerCase(),

View File

@@ -14,7 +14,7 @@ export async function fetchTags({ organizationId }: { organizationId: string })
};
}
export async function createTag({ organizationId, name, color, description }: { organizationId: string; name: string; color: string; description: string }) {
export async function createTag({ organizationId, name, color, description = '' }: { organizationId: string; name: string; color: string; description?: string }) {
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags`,
method: 'POST',
@@ -26,7 +26,7 @@ export async function createTag({ organizationId, name, color, description }: {
};
}
export async function updateTag({ organizationId, tagId, name, color, description }: { organizationId: string; tagId: string; name: string; color: string; description: string }) {
export async function updateTag({ organizationId, tagId, name, color, description = '' }: { organizationId: string; tagId: string; name: string; color: string; description?: string }) {
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags/${tagId}`,
method: 'PUT',

View File

@@ -90,8 +90,8 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
<Form>
<CardContent class="pt-6">
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="name" class="sr-only">
{t('user.settings.name.label')}
@@ -101,25 +101,25 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
type="text"
id="name"
placeholder={t('user.settings.name.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
<Button
type="submit"
isLoading={form.submitting}
isLoading={form.isSubmitting}
class="flex-shrink-0"
disabled={field.value?.trim() === props.name}
disabled={(field.input as string)?.trim() === props.name}
>
{t('user.settings.name.update')}
</Button>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="text-red-500 text-sm">{form.response.message}</div>
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
</CardContent>
</Form>
</Card>

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

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import type { WebhookEvent } from '../webhooks.types';
import { setInput } from '@formisch/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -39,11 +40,11 @@ export const CreateWebhookPage: Component = () => {
},
schema: v.object({
name: v.pipe(
v.string(),
v.string(t('webhooks.create.form.name.required')),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.string(t('webhooks.create.form.url.required')),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
@@ -71,68 +72,68 @@ export const CreateWebhookPage: Component = () => {
</div>
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('webhooks.create.form.name.placeholder')}
{...inputProps}
{...field.props}
autoFocus
value={field.value}
aria-invalid={Boolean(field.error)}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<Field path={['url']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
<TextField
type="url"
id="url"
placeholder={t('webhooks.create.form.url.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="secret">
{(field, inputProps) => (
<Field path={['secret']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
<TextField
type="password"
id="secret"
placeholder={t('webhooks.create.form.secret.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="events" type="string[]">
<Field path={['events']}>
{field => (
<div>
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
<WebhookEventsPicker events={(field.input as WebhookEvent[]) ?? []} onChange={events => setInput(form, { path: ['events'], input: events })} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</div>
)}
</Field>
@@ -141,10 +142,12 @@ export const CreateWebhookPage: Component = () => {
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
{t('webhooks.create.back')}
</Button>
<Button type="submit" class="ml-2" isLoading={form.submitting}>
<Button type="submit" class="ml-2" isLoading={form.isSubmitting}>
{t('webhooks.create.form.submit')}
</Button>
</div>
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
</Form>
</div>
);

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { setValue } from '@modular-forms/solid';
import type { Webhook, WebhookEvent } from '../webhooks.types';
import { setInput } from '@formisch/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
@@ -53,11 +53,11 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
},
schema: v.object({
name: v.pipe(
v.string(),
v.string(t('webhooks.create.form.name.required')),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.string(t('webhooks.create.form.url.required')),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
@@ -79,44 +79,44 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<Field path={['name']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('webhooks.create.form.name.placeholder')}
{...inputProps}
{...field.props}
autoFocus
value={field.value}
aria-invalid={Boolean(field.error)}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<Field path={['url']}>
{field => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
<TextField
type="url"
id="url"
placeholder={t('webhooks.create.form.url.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
<div class="mb-6">
<Field name="secret">
{(field, inputProps) => (
<Field path={['secret']}>
{field => (
<TextFieldRoot class="flex flex-col mt-4">
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
<div class="flex items-center gap-2">
@@ -124,9 +124,9 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
type="password"
id="secret"
placeholder={rotateSecret() ? t('webhooks.update.form.secret.placeholder') : t('webhooks.update.form.secret.placeholder-redacted')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
{...field.props}
value={field.input}
aria-invalid={Boolean(field.errors)}
disabled={!rotateSecret()}
/>
<Show when={!rotateSecret()}>
@@ -135,22 +135,22 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
</Button>
</Show>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</TextFieldRoot>
)}
</Field>
</div>
<Field name="events" type="string[]">
<Field path={['events']}>
{field => (
<div>
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
<WebhookEventsPicker events={(field.input as WebhookEvent[]) ?? []} onChange={events => setInput(form, { path: ['events'], input: events })} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
</div>
)}
</Field>
@@ -159,7 +159,7 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
{t('webhooks.update.cancel')}
</Button>
<Button type="submit" class="ml-2" isLoading={form.submitting}>
<Button type="submit" class="ml-2" isLoading={form.isSubmitting}>
{t('webhooks.update.submit')}
</Button>
</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

@@ -1,5 +1,11 @@
# @papra/app-server
## 0.7.0
### Minor Changes
- [#417](https://github.com/papra-hq/papra/pull/417) [`a82ff3a`](https://github.com/papra-hq/papra/commit/a82ff3a755fa1164b4d8ff09b591ed6482af0ccc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - v0.7 release
## 0.6.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.6.4",
"version": "0.7.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
@@ -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.1.0",
"@cadence-mq/driver-memory": "^0.1.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(
{
@@ -37,11 +37,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

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

@@ -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';
@@ -244,7 +244,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
await triggerWebhooks({
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:deleted',
@@ -479,6 +479,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 +490,13 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
...updateData,
});
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:updated',
payload: { documentId, organizationId, ...updateData },
});
deferRegisterDocumentActivityLog({
documentId,
event: 'updated',

View File

@@ -24,7 +24,7 @@ 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';
@@ -133,7 +133,7 @@ export async function createDocument({
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
await triggerWebhooks({
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:created',

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

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

@@ -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,10 @@
import type { Database } from '../app/database/database.types';
import type { Config } from '../config/config.types';
import type { TaskServices } from './tasks.services';
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 });
}

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 });
return {
...cadence,
start: () => {
const worker = cadence.createWorker({ workerId });
worker.start();
logger.info({ workerId }, 'Task worker started');
return worker;
},
};
}

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

@@ -16,6 +16,7 @@ COPY pnpm-workspace.yaml ./
COPY apps/papra-client/package.json apps/papra-client/package.json
COPY apps/papra-server/package.json apps/papra-server/package.json
COPY packages/webhooks/package.json packages/webhooks/package.json
COPY packages/lecture/package.json packages/lecture/package.json
RUN pnpm install --frozen-lockfile --ignore-scripts

View File

@@ -18,6 +18,7 @@ COPY pnpm-workspace.yaml ./
COPY apps/papra-client/package.json apps/papra-client/package.json
COPY apps/papra-server/package.json apps/papra-server/package.json
COPY packages/webhooks/package.json packages/webhooks/package.json
COPY packages/lecture/package.json packages/lecture/package.json
RUN pnpm install --frozen-lockfile --ignore-scripts

View File

@@ -49,7 +49,7 @@
"@antfu/eslint-config": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
"unbuild": "^3.5.0",
"unbuild": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -56,7 +56,7 @@
"eslint": "catalog:",
"tsx": "^4.19.3",
"typescript": "catalog:",
"unbuild": "^3.5.0",
"unbuild": "catalog:",
"vitest": "catalog:"
}
}

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

@@ -2,7 +2,7 @@
"name": "@papra/lecture",
"type": "module",
"version": "0.0.7",
"packageManager": "pnpm@9.15.0",
"packageManager": "pnpm@10.12.3",
"description": "A simple library to extract text from files",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "MIT",
@@ -40,15 +40,15 @@
"test": "vitest run",
"test:watch": "vitest watch",
"generate-fixtures": "vitest --update",
"prepare": "pnpm run build",
"build": "unbuild",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm run build"
},
"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:",
@@ -58,7 +58,7 @@
"mime": "^4.0.6",
"tinyglobby": "^0.2.10",
"typescript": "catalog:",
"unbuild": "^3.3.1",
"unbuild": "catalog:",
"vitest": "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

@@ -42,14 +42,16 @@
},
"dependencies": {
"@corentinth/chisels": "^1.3.0",
"@paralleldrive/cuid2": "^2.2.2",
"ofetch": "^1.4.1",
"tsee": "^1.3.4"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"eslint": "catalog:",
"standardwebhooks": "^1.0.0",
"typescript": "catalog:",
"unbuild": "^3.5.0",
"unbuild": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -2,7 +2,25 @@ export function createInvalidSignatureError() {
return Object.assign(
new Error('[Papra Webhooks] Invalid signature'),
{
code: 'INVALID_SIGNATURE',
code: 'webhook.invalid_signature',
},
);
}
export function createUnsupportedSignatureVersionError() {
return Object.assign(
new Error('[Papra Webhooks] Unsupported signature version, supported versions are "v1"'),
{
code: 'webhook.unsupported_signature_version',
},
);
}
export function createInvalidSignatureFormatError() {
return Object.assign(
new Error('[Papra Webhooks] Invalid signature format, unprocessable signature'),
{
code: 'webhook.invalid_signature_format',
},
);
}

View File

@@ -1,36 +1,45 @@
import type { BuildWebhookEventPayload, WebhookEvents, WebhookPayloads } from '../webhooks.types';
import type { StandardWebhookEventPayload, WebhookEvents } from '../webhooks.types';
import { EventEmitter } from 'tsee';
import { verifySignature } from '../signature';
import { parseBody } from '../webhooks.models';
import { createInvalidSignatureError } from './handler.errors';
function handleError({ error }: { error: unknown }) {
if (error) {
throw error;
}
throw createInvalidSignatureError();
}
export function createWebhooksHandler({
secret,
onInvalidSignature = () => {
createInvalidSignatureError();
},
onError = handleError,
}: {
secret: string;
onInvalidSignature?: ({ bodyBuffer, signature }: { bodyBuffer: ArrayBuffer; signature: string }) => void | Promise<void>;
onError?: (args: { body: string; signature: string; webhookId: string; timestamp: string; error: unknown }) => void | Promise<void>;
}) {
const eventEmitter = new EventEmitter<WebhookEvents & { '*': (payload: BuildWebhookEventPayload<WebhookPayloads>) => void }>();
const eventEmitter = new EventEmitter<WebhookEvents & { '*': (payload: StandardWebhookEventPayload) => void }>();
return {
on: eventEmitter.on,
ee: eventEmitter,
handle: async ({ bodyBuffer, signature }: { bodyBuffer: ArrayBuffer; signature: string }) => {
const isValid = await verifySignature({ bodyBuffer, signature, secret });
handle: async ({ body, signature, webhookId, timestamp }: { body: string; signature: string; webhookId: string; timestamp: string }) => {
try {
const isValid = await verifySignature({ serializedPayload: body, signature, secret, webhookId, timestamp });
if (!isValid) {
await onInvalidSignature({ bodyBuffer, signature });
return;
if (!isValid) {
throw createInvalidSignatureError();
}
const parsedBody = parseBody(body);
const { type } = parsedBody;
eventEmitter.emit(type, parsedBody as any);
eventEmitter.emit('*', parsedBody);
} catch (error) {
await onError({ body, signature, webhookId, timestamp, error });
}
const payload = parseBody(bodyBuffer.toString());
const { event } = payload;
eventEmitter.emit(event, payload as any);
eventEmitter.emit('*', payload);
},
};
}

View File

@@ -1,4 +1,4 @@
export { createWebhooksHandler } from './handler/handler.services';
export { EVENT_NAMES, type EventName } from './webhooks.constants';
export { triggerWebhook } from './webhooks.services';
export type { WebhookEventPayload, WebhookEvents, WebhookPayload, WebhookPayloads } from './webhooks.types';
export type { StandardWebhookEventPayload, WebhookEvents, WebhookPayload, WebhookPayloads } from './webhooks.types';

View File

@@ -1,4 +1,7 @@
import { describe, expect, test } from 'vitest';
import { Buffer } from 'node:buffer';
import { Webhook } from 'standardwebhooks';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { createInvalidSignatureFormatError, createUnsupportedSignatureVersionError } from './handler/handler.errors';
import { arrayBufferToBase64, base64ToArrayBuffer, signBody, verifySignature } from './signature';
const arrayBuffer = (str: string) => new TextEncoder().encode(str).buffer as ArrayBuffer;
@@ -6,25 +9,103 @@ const arrayBuffer = (str: string) => new TextEncoder().encode(str).buffer as Arr
describe('signature', () => {
describe('signBody', () => {
test('a buffer can be signed with a secret, the resulting signature is a base64 encoded string', async () => {
const bodyBuffer = arrayBuffer('test');
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
const { signature } = await signBody({ bodyBuffer, secret });
const { signature } = await signBody({ serializedPayload, webhookId, timestamp, secret });
expect(signature).to.equal('2yIt56m6njKnw7VCoPEYRQE1jSIxyuYutt8/c1ezh9M=');
expect(signature).to.equal('v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=');
});
});
describe('verifySignature', () => {
test('verify that the signature of a buffer has been created with a given secret', async () => {
const bodyBuffer = arrayBuffer('test');
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
const signature = '2yIt56m6njKnw7VCoPEYRQE1jSIxyuYutt8/c1ezh9M=';
const signature = 'v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=';
const result = await verifySignature({ bodyBuffer, signature, secret });
const result = await verifySignature({ serializedPayload, webhookId, timestamp, signature, secret });
expect(result).to.equal(true);
});
test('an error is thrown when the version is not supported', async () => {
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
const signature = 'v2,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=';
expect(verifySignature({ serializedPayload, webhookId, timestamp, signature, secret })).rejects.toThrow(createUnsupportedSignatureVersionError());
});
test('an error is thrown when the signature is not valid', async () => {
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
const signature = '';
expect(verifySignature({ serializedPayload, webhookId, timestamp, signature, secret })).rejects.toThrow(createInvalidSignatureFormatError());
});
});
describe('standardwebhooks compatibility', () => {
// Because standardwebhooks uses hardcoded Date.now()
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('a signed payload can be verified using the "standardwebhooks" package', async () => {
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
// Because standardwebhooks uses hardcoded Date.now() to check for webhook expiration...
vi.setSystemTime(new Date(Number(timestamp) * 1000));
const webhook = new Webhook(Buffer.from(secret).toString('base64'));
const result = await webhook.verify(serializedPayload, {
'webhook-id': webhookId,
'webhook-timestamp': timestamp,
'webhook-signature': 'v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=',
});
expect(result).to.eql({
event: 'foo.bar',
payload: { biz: 'baz' },
now: '2025-07-25T00:00:00.000Z',
});
});
test('the signature is the same as the one generated by the "standardwebhooks" package', async () => {
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
const serializedPayload = JSON.stringify(payload);
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
const timestamp = '1753390766';
const secret = 'secret-key';
const { signature } = await signBody({ serializedPayload, webhookId, timestamp, secret });
const standardWebhookSignature = new Webhook(Buffer.from(secret).toString('base64')).sign(webhookId, new Date(Number(timestamp) * 1000), serializedPayload);
expect(standardWebhookSignature).to.equal(signature);
});
});
describe('arrayBufferToBase64', () => {

View File

@@ -1,3 +1,7 @@
import { createInvalidSignatureFormatError, createUnsupportedSignatureVersionError } from './handler/handler.errors';
const WEBHOOK_SIGNATURE_HMAC_VERSION = 'v1';
export function arrayBufferToBase64(arrayBuffer: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
@@ -6,37 +10,74 @@ export function base64ToArrayBuffer(base64: string) {
return new Uint8Array(atob(base64).split('').map(char => char.charCodeAt(0))).buffer;
}
function createSignaturePayload({
serializedPayload,
webhookId,
timestamp,
}: {
serializedPayload: string;
webhookId: string;
timestamp: string;
}) {
return `${webhookId}.${timestamp}.${serializedPayload}`;
}
async function hmacSign({ secret, payload }: { secret: string; payload: string }) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return crypto.subtle.sign('HMAC', key, encoder.encode(payload));
}
export async function signBody({
bodyBuffer,
serializedPayload,
webhookId,
timestamp,
secret,
}: {
bodyBuffer: ArrayBuffer;
serializedPayload: string;
webhookId: string;
timestamp: string;
secret: string;
}) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp });
const signature = await crypto.subtle.sign('HMAC', key, bodyBuffer);
const signatureBase64 = arrayBufferToBase64(signature);
const rawSignature = await hmacSign({ secret, payload });
const signatureBase64 = arrayBufferToBase64(rawSignature);
const signature = `${WEBHOOK_SIGNATURE_HMAC_VERSION},${signatureBase64}`;
return { signature: signatureBase64 };
return { signature };
}
export async function verifySignature({
bodyBuffer,
serializedPayload,
webhookId,
timestamp,
signature: base64Signature,
secret,
}: {
bodyBuffer: ArrayBuffer;
serializedPayload: string;
webhookId: string;
timestamp: string;
signature: string;
secret: string;
}): Promise<boolean> {
const [version, signature] = base64Signature.split(',', 2);
if (!signature || !version) {
throw createInvalidSignatureFormatError();
}
if (version !== WEBHOOK_SIGNATURE_HMAC_VERSION) {
throw createUnsupportedSignatureVersionError();
}
const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp });
const signatureBuffer = base64ToArrayBuffer(signature);
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const signatureBuffer = base64ToArrayBuffer(base64Signature);
return crypto.subtle.verify('HMAC', key, signatureBuffer, bodyBuffer);
return crypto.subtle.verify('HMAC', key, signatureBuffer, encoder.encode(payload));
}

View File

@@ -1,6 +1,9 @@
export const EVENT_NAMES = [
'document:created',
'document:deleted',
'document:updated',
'document:tag:added',
'document:tag:removed',
] as const;
export type EventName = (typeof EVENT_NAMES)[number];

View File

@@ -1,14 +1,15 @@
import type { WebhookEventPayload, WebhookPayloads } from './webhooks.types';
import type { StandardWebhookEventPayload, WebhookPayloads } from './webhooks.types';
export function serializeBody({ now = new Date(), ...payload }: { now?: Date } & WebhookPayloads) {
const body: WebhookEventPayload = {
...payload,
timestampMs: now.getTime(),
export function serializeBody<T extends WebhookPayloads>({ now = new Date(), payload, event }: { now?: Date; payload: T['payload']; event: T['event'] }) {
const body: StandardWebhookEventPayload = {
data: payload,
type: event,
timestamp: now.toISOString(),
};
return JSON.stringify(body);
}
export function parseBody(body: string) {
return JSON.parse(body) as WebhookEventPayload;
return JSON.parse(body) as StandardWebhookEventPayload;
}

View File

@@ -1,4 +1,5 @@
import type { WebhookPayloads } from './webhooks.types';
import { createId } from '@paralleldrive/cuid2';
import { ofetch } from 'ofetch';
import { signBody } from './signature';
import { serializeBody } from './webhooks.models';
@@ -9,7 +10,7 @@ export async function webhookHttpClient({
}: {
url: string;
method: string;
body: ArrayBuffer;
body: string;
headers: Record<string, string>;
}) {
const response = await ofetch.raw<unknown>(url, {
@@ -23,39 +24,43 @@ export async function webhookHttpClient({
};
}
export async function triggerWebhook({
export async function triggerWebhook<T extends WebhookPayloads>({
webhookUrl,
webhookSecret,
httpClient = webhookHttpClient,
now = new Date(),
...payload
payload,
event,
webhookId = `msg_${createId()}`,
}: {
webhookUrl: string;
webhookSecret?: string | null;
httpClient?: typeof webhookHttpClient;
payload: T['payload'];
now?: Date;
} & WebhookPayloads) {
const { event } = payload;
event: T['event'];
webhookId?: string;
}) {
const timestamp = Math.floor(now.getTime() / 1000).toString();
const headers: Record<string, string> = {
'User-Agent': 'papra-webhook-client',
'Content-Type': 'application/json',
'X-Event': event,
'user-agent': 'papra-webhook-client',
'content-type': 'application/json',
'webhook-id': webhookId,
'webhook-timestamp': timestamp,
};
const body = serializeBody({ ...payload, now });
const bodyBuffer = new TextEncoder().encode(body).buffer as ArrayBuffer;
const body = serializeBody({ event, payload, now });
if (webhookSecret) {
const { signature } = await signBody({ bodyBuffer, secret: webhookSecret });
headers['X-Signature'] = signature;
const { signature } = await signBody({ serializedPayload: body, webhookId, timestamp, secret: webhookSecret });
headers['webhook-signature'] = signature;
}
const { responseData, responseStatus } = await httpClient({
url: webhookUrl,
method: 'POST',
body: bodyBuffer,
body,
headers,
});

View File

@@ -21,14 +21,43 @@ export type DocumentDeletedPayload = WebhookPayload<
}
>;
export type WebhookPayloads = DocumentCreatedPayload | DocumentDeletedPayload;
export type DocumentUpdatedPayload = WebhookPayload<
'document:updated',
{
documentId: string;
organizationId: string;
name?: string;
content?: string;
}
>;
export type DocumentTagAddedPayload = WebhookPayload<
'document:tag:added',
{
documentId: string;
organizationId: string;
tagId: string;
tagName: string;
}
>;
export type DocumentTagRemovedPayload = WebhookPayload<
'document:tag:removed',
{
documentId: string;
organizationId: string;
tagId: string;
tagName: string;
}
>;
export type WebhookPayloads = DocumentCreatedPayload | DocumentDeletedPayload | DocumentUpdatedPayload | DocumentTagAddedPayload | DocumentTagRemovedPayload;
type ExtractEventName<T> = T extends WebhookPayload<infer E, any> ? E : never;
export type BuildWebhookEventPayload<T> = T & { timestampMs: number };
export type BuildStandardWebhookEventPayload<T extends WebhookPayloads> = { type: T['event']; timestamp: string; data: T['payload'] };
export type BuildWebhookEvents<T extends WebhookPayloads> = {
[K in ExtractEventName<T>]: (args: BuildWebhookEventPayload<Extract<T, WebhookPayload<K, any>>>) => void;
[K in ExtractEventName<T>]: (args: BuildStandardWebhookEventPayload<Extract<T, WebhookPayload<K, any>>>) => void;
};
export type WebhookEvents = BuildWebhookEvents<WebhookPayloads>;
export type WebhookEventPayload = BuildWebhookEventPayload<WebhookPayloads>;
export type StandardWebhookEventPayload = BuildStandardWebhookEventPayload<WebhookPayloads>;

1053
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ catalog:
vitest: ^3.0.5
'@vitest/coverage-v8': ^3.0.2
better-auth: ^1.2.8
unbuild: ^3.5.0
ignoredBuiltDependencies:
- esbuild