mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-19 12:19:37 -06:00
Compare commits
11 Commits
@papra/app
...
formisch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f0b83863 | ||
|
|
41a113334a | ||
|
|
6723baf98a | ||
|
|
bbe5fe74e2 | ||
|
|
a8cff8cedc | ||
|
|
67b3b14cdf | ||
|
|
ffdae8db56 | ||
|
|
7768840aa4 | ||
|
|
dd3862e50c | ||
|
|
a82ff3a755 | ||
|
|
d5b00307da |
5
.changeset/beige-houses-confess.md
Normal file
5
.changeset/beige-houses-confess.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added diacritics and improved wording for Romanian translation
|
||||
5
.changeset/bumpy-aliens-juggle.md
Normal file
5
.changeset/bumpy-aliens-juggle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Breaking change: updated webhooks signatures and payload format to match standard-webhook spec
|
||||
5
.changeset/few-pugs-wink.md
Normal file
5
.changeset/few-pugs-wink.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Simplified the organization intake email list
|
||||
7
.changeset/heavy-chairs-look.md
Normal file
7
.changeset/heavy-chairs-look.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Added new webhook events: document:updated, document:tag:added, document:tag:removed
|
||||
7
.changeset/itchy-candies-marry.md
Normal file
7
.changeset/itchy-candies-marry.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Webhooks invocation is now defered
|
||||
5
.changeset/shaggy-olives-speak.md
Normal file
5
.changeset/shaggy-olives-speak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/lecture": minor
|
||||
---
|
||||
|
||||
Added support for scanned pdf content extraction
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. Poți 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. Poți 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: Ești sigur că dorești 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. Poți 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. Poți 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,7 +38,8 @@ describe('locales', () => {
|
||||
const dynamicKeysMatchers = [
|
||||
/^api-errors\./, // api-errors.document.already_exists
|
||||
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
|
||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
||||
/^webhooks\.events\.[a-z0-9]+\.[a-z0-9:]+.description$/, // webhooks.events.documents.document:created.description
|
||||
/^webhooks\.events\.[a-z0-9]+\.title$/, // webhooks.events.documents.title
|
||||
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
|
||||
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
|
||||
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
|
||||
|
||||
@@ -440,8 +440,12 @@ export type LocaleKeys =
|
||||
| 'webhooks.delete.confirm.message'
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.title'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| 'webhooks.events.documents.document:updated.description'
|
||||
| 'webhooks.events.documents.document:tag:added.description'
|
||||
| 'webhooks.events.documents.document:tag:removed.description'
|
||||
| 'layout.menu.home'
|
||||
| 'layout.menu.documents'
|
||||
| 'layout.menu.tags'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,9 @@ export const WEBHOOK_EVENTS = [
|
||||
events: [
|
||||
'document:created',
|
||||
'document:deleted',
|
||||
'document:updated',
|
||||
'document:tag:added',
|
||||
'document:tag:removed',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseConfig } from '../config/config';
|
||||
import { createEmailsServices } from '../emails/emails.services';
|
||||
import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
|
||||
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import { createTaskServices } from '../tasks/tasks.services';
|
||||
import { createTrackingServices } from '../tracking/tracking.services';
|
||||
import { createAuthEmailsServices } from './auth/auth.emails.services';
|
||||
import { getAuth } from './auth/auth.services';
|
||||
@@ -23,6 +24,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
const trackingServices = createTrackingServices({ config });
|
||||
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }), trackingServices }).auth;
|
||||
const subscriptionsServices = createSubscriptionsServices({ config });
|
||||
const taskServices = partialDeps.taskServices ?? createTaskServices({ config });
|
||||
|
||||
return {
|
||||
config,
|
||||
@@ -31,6 +33,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
emailsServices,
|
||||
subscriptionsServices,
|
||||
trackingServices,
|
||||
taskServices,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { EmailsServices } from '../emails/emails.services';
|
||||
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import type { TaskServices } from '../tasks/tasks.services';
|
||||
import type { TrackingServices } from '../tracking/tracking.services';
|
||||
import type { Auth } from './auth/auth.services';
|
||||
import type { Session } from './auth/auth.types';
|
||||
@@ -28,6 +29,7 @@ export type GlobalDependencies = {
|
||||
emailsServices: EmailsServices;
|
||||
subscriptionsServices: SubscriptionsServices;
|
||||
trackingServices: TrackingServices;
|
||||
taskServices: TaskServices;
|
||||
};
|
||||
|
||||
export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -11,3 +11,9 @@ export const createTagAlreadyExistsError = createErrorFactory({
|
||||
code: 'tags.already_exists',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
export const createTagNotFoundError = createErrorFactory({
|
||||
message: 'Tag not found',
|
||||
code: 'tags.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createTagsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationTags,
|
||||
getTagById,
|
||||
createTag,
|
||||
deleteTag,
|
||||
updateTag,
|
||||
@@ -50,6 +51,20 @@ async function getOrganizationTags({ organizationId, db }: { organizationId: str
|
||||
return { tags };
|
||||
}
|
||||
|
||||
async function getTagById({ tagId, organizationId, db }: { tagId: string; organizationId: string; db: Database }) {
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tagsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(tagsTable.id, tagId),
|
||||
eq(tagsTable.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
|
||||
return { tag };
|
||||
}
|
||||
|
||||
async function createTag({ tag, db }: { tag: DbInsertableTag; db: Database }) {
|
||||
const [result, error] = await safely(db.insert(tagsTable).values(tag).returning());
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@ import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { createDocumentActivityRepository } from '../documents/document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from '../documents/document-activity/document-activity.usecases';
|
||||
import { createDocumentNotFoundError } from '../documents/documents.errors';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { documentIdSchema } from '../documents/documents.schemas';
|
||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { TagColorRegex } from './tags.constants';
|
||||
import { createTagNotFoundError } from './tags.errors';
|
||||
import { createTagsRepository } from './tags.repository';
|
||||
import { tagIdSchema } from './tags.schemas';
|
||||
|
||||
@@ -161,12 +166,34 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.addTagToDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:added',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'tagged',
|
||||
@@ -197,12 +224,34 @@ function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.removeTagFromDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:removed',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'untagged',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { TaskDefinition } from './tasks.models';
|
||||
import cron from 'node-cron';
|
||||
import { createLogger, wrapWithLoggerContext } from '../shared/logger/logger';
|
||||
import { generateId } from '../shared/random/ids';
|
||||
|
||||
export { createTaskScheduler };
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:scheduler' });
|
||||
|
||||
function createTaskScheduler({
|
||||
config,
|
||||
taskDefinitions,
|
||||
tasksArgs,
|
||||
}: {
|
||||
config: Config;
|
||||
taskDefinitions: TaskDefinition[];
|
||||
tasksArgs: { db: Database };
|
||||
}) {
|
||||
const scheduledTasks = taskDefinitions.map((taskDefinition) => {
|
||||
const isEnabled = taskDefinition.getIsEnabled({ config });
|
||||
const cronSchedule = taskDefinition.getCronSchedule({ config });
|
||||
const runOnStartup = taskDefinition.getRunOnStartup({ config });
|
||||
|
||||
if (!isEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronSchedule,
|
||||
async () => wrapWithLoggerContext(
|
||||
{
|
||||
taskId: generateId({ prefix: 'task' }),
|
||||
taskName: taskDefinition.taskName,
|
||||
},
|
||||
async () => taskDefinition.run({ ...tasksArgs, config }),
|
||||
),
|
||||
{
|
||||
scheduled: false,
|
||||
runOnInit: runOnStartup,
|
||||
},
|
||||
);
|
||||
|
||||
return { job: task, taskName: taskDefinition.taskName };
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
taskScheduler: {
|
||||
scheduledTasks,
|
||||
start() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.start();
|
||||
logger.debug({ taskName }, 'Task scheduled');
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.stop();
|
||||
logger.debug({ taskName }, 'Task unscheduled');
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
|
||||
export const tasksConfig = {
|
||||
persistence: {
|
||||
driver: {
|
||||
doc: 'The driver to use for the tasks persistence',
|
||||
schema: z.enum(['memory']),
|
||||
default: 'memory',
|
||||
env: 'TASKS_PERSISTENCE_DRIVER',
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
id: {
|
||||
doc: 'The id of the task worker, used to identify the worker in the Cadence cluster in case of multiple workers',
|
||||
schema: z.string().optional(),
|
||||
env: 'TASKS_WORKER_ID',
|
||||
},
|
||||
},
|
||||
hardDeleteExpiredDocuments: {
|
||||
enabled: {
|
||||
doc: 'Whether the task to hard delete expired "soft deleted" documents is enabled',
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { hardDeleteExpiredDocumentsTaskDefinition } from '../documents/tasks/hard-delete-expired-documents.task';
|
||||
import { expireInvitationsTaskDefinition } from '../organizations/tasks/expire-invitations.task';
|
||||
|
||||
export const taskDefinitions = [
|
||||
hardDeleteExpiredDocumentsTaskDefinition,
|
||||
expireInvitationsTaskDefinition,
|
||||
];
|
||||
10
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal file
10
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export { defineTask };
|
||||
|
||||
export type TaskDefinition = ReturnType<typeof defineTask>;
|
||||
|
||||
function defineTask({
|
||||
name: taskName,
|
||||
cronSchedule,
|
||||
isEnabled,
|
||||
runOnStartup = false,
|
||||
handler,
|
||||
logger: taskLogger = createLogger({ namespace: `tasks:${taskName}` }),
|
||||
}: {
|
||||
name: string;
|
||||
isEnabled: boolean | ((args: { config: Config }) => boolean);
|
||||
cronSchedule: string | ((args: { config: Config }) => string);
|
||||
runOnStartup?: boolean | ((args: { config: Config }) => boolean);
|
||||
handler: (handlerArgs: { db: Database; config: Config; logger: Logger; now: Date }) => Promise<void>;
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const run = async ({
|
||||
getNow = () => new Date(),
|
||||
logger = taskLogger,
|
||||
...handlerArgs
|
||||
}: {
|
||||
db: Database;
|
||||
config: Config;
|
||||
getNow?: () => Date;
|
||||
logger?: Logger;
|
||||
}) => {
|
||||
const startedAt = getNow();
|
||||
|
||||
try {
|
||||
logger.debug({ taskName, startedAt }, 'Task started');
|
||||
|
||||
await handler({ ...handlerArgs, logger, now: getNow() });
|
||||
|
||||
const durationMs = getNow().getTime() - startedAt.getTime();
|
||||
logger.info({ taskName, durationMs, startedAt }, 'Task completed');
|
||||
} catch (error) {
|
||||
logger.error({ error, taskName, startedAt }, 'Task failed');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
taskName,
|
||||
run,
|
||||
getIsEnabled: (args: { config: Config }) => (isFunction(isEnabled) ? isEnabled(args) : isEnabled),
|
||||
getCronSchedule: (args: { config: Config }) => (isFunction(cronSchedule) ? cronSchedule(args) : cronSchedule),
|
||||
getRunOnStartup: (args: { config: Config }) => (isFunction(runOnStartup) ? runOnStartup(args) : runOnStartup),
|
||||
};
|
||||
}
|
||||
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import { createCadence } from '@cadence-mq/core';
|
||||
import { createMemoryDriver } from '@cadence-mq/driver-memory';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export type TaskServices = ReturnType<typeof createTaskServices>;
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:services' });
|
||||
|
||||
export function createTaskServices({ config }: { config: Config }) {
|
||||
const workerId = config.tasks.worker.id ?? 'default';
|
||||
|
||||
const driver = createMemoryDriver();
|
||||
const cadence = createCadence({ driver });
|
||||
|
||||
return {
|
||||
...cadence,
|
||||
start: () => {
|
||||
const worker = cadence.createWorker({ workerId });
|
||||
|
||||
worker.start();
|
||||
|
||||
logger.info({ workerId }, 'Task worker started');
|
||||
|
||||
return worker;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.5.0",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"eslint": "catalog:",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.5.0",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Volutpat massa enim mi lectys auisque faucibus sapien parturigng
|
||||
aliquet. Pulvinar vehicula cura nostra ultricies aptent sollicitugin
|
||||
egestas posuere justo, Hendrerit sollicitudin mus amet condimentum
|
||||
feugiat maecenas sit iacyis himenaeos. Tacit ultrices purgs posuere
|
||||
lacinia porta nisi varius placerat Porta. Sagitts ligula in vel egestas
|
||||
natoque feugiat ligula omare soos.
|
||||
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
import type { PartialExtractorConfig } from '../../src/types';
|
||||
|
||||
export const config: PartialExtractorConfig = {
|
||||
tesseract: {
|
||||
languages: ['fra'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
EF: Look Scanned
|
||||
Comment Utiliser Look Scanned pour
|
||||
Numériser vos Documents
|
||||
Look Scanned vous permet de transformer facilement vos
|
||||
documents en versions numérisées d'aspect professionnel. Voici
|
||||
comment procéder :
|
||||
Importez votre Fichier
|
||||
Cliquez sur le bouton "Importer un Fichier" ou glissez-déposez
|
||||
directement votre document sur la page. Look Scanned prend en
|
||||
charge de nombreux formats : PDF, images (JPG, PNG), DOCX, PPTX,
|
||||
Excel, Markdown, HTML et TXT. Dès que votre fichier est importé, un
|
||||
aperçu s'affiche instantanément pour vous permettre d'ajuster les
|
||||
effets.
|
||||
Personnalisez l'Effet de Numérisation
|
||||
Une fois votre fichier importé, vous pouvez personnaliser les effets
|
||||
selon vos besoins. Ajustez l'angle d'inclinaison, la luminosité, le
|
||||
contraste et le niveau de flou pour obtenir l'aspect d'un véritable
|
||||
document numérisé. Chaque modification est visible en temps réel
|
||||
dans l'aperçu, vous permettant d'obtenir exactement le résultat
|
||||
souhaité.
|
||||
Look Scanned traite les documents de plusieurs pages en maintenant
|
||||
une apparence cohérente sur l'ensemble du document.
|
||||
Téléchargez votre Document
|
||||
Une fois satisfait du résultat, cliquez sur "Générer le Document
|
||||
Numérisé”. Le traitement ne prend que quelques secondes. Vous
|
||||
pourrez ensuite télécharger votre fichier en cliquant sur
|
||||
"Télécharger". Tout le processus s'effectue localement sur votre
|
||||
|
||||
appareil et nous ne conservons aucun contenu, garantissant ainsi la
|
||||
confidentialité de vos documents.
|
||||
|
||||
Conseils d'Utilisation
|
||||
|
||||
Look Scanned offre une solution rapide et efficace pour créer des
|
||||
documents à l'aspect authentiquement numérisé, sans installation de
|
||||
logiciel. Rendez-vous sur lookscanned.io pour donner un aspect
|
||||
professionnel à vos documents !
|
||||
Binary file not shown.
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('extractors usecases', () => {
|
||||
|
||||
for (const fixture of fixturesDir) {
|
||||
// use test.concurrent to run the tests in parallel -> need to use the provided expect
|
||||
test.concurrent(`fixture ${fixture}`, async ({ expect }) => {
|
||||
test(`fixture ${fixture}`, { timeout: 10_000, concurrent: true }, async ({ expect }) => {
|
||||
const fixtureFilesPaths = await glob([`${fixture}/*`]);
|
||||
const inputFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.input\.\w+$/));
|
||||
const configFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.config\.ts$/));
|
||||
|
||||
@@ -2,6 +2,17 @@ import { Buffer } from 'node:buffer';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
|
||||
export async function extractTextFromImage(maybeArrayBuffer: ArrayBuffer | Buffer, { languages }: { languages: string[] }) {
|
||||
const buffer = maybeArrayBuffer instanceof ArrayBuffer ? Buffer.from(maybeArrayBuffer) : maybeArrayBuffer;
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export const imageExtractorDefinition = defineTextExtractor({
|
||||
name: 'image',
|
||||
mimeTypes: [
|
||||
@@ -13,13 +24,8 @@ export const imageExtractorDefinition = defineTextExtractor({
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const content = await extractTextFromImage(arrayBuffer, { languages });
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return { content: text };
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { extractText } from 'unpdf';
|
||||
import sharp from 'sharp';
|
||||
import { extractImages, extractText, getDocumentProxy } from 'unpdf';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
import { extractTextFromImage } from './img.extractor';
|
||||
|
||||
export const pdfExtractorDefinition = defineTextExtractor({
|
||||
name: 'pdf',
|
||||
mimeTypes: ['application/pdf'],
|
||||
extract: async ({ arrayBuffer }) => {
|
||||
const { text } = await extractText(arrayBuffer, { mergePages: true });
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
return { content: text };
|
||||
const pdf = await getDocumentProxy(arrayBuffer);
|
||||
|
||||
const { text, totalPages } = await extractText(pdf, { mergePages: true });
|
||||
|
||||
if (text && text.trim().length > 0) {
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
const imageTexts = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const images = await extractImages(pdf, i);
|
||||
|
||||
for (const image of images) {
|
||||
const imageBuffer = await sharp(image.data, {
|
||||
raw: { width: image.width, height: image.height, channels: image.channels },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const imageText = await extractTextFromImage(imageBuffer, { languages });
|
||||
imageTexts.push(imageText);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: imageTexts.join('\n') };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
1053
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user