diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1254a84a63..d0d0ea12b0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -38,13 +38,13 @@ If applicable, add screenshots to help explain your problem. - Version (e.g. 22) **Environment (please complete the following information):** -Specify if using demo instance or self-hosted instance. +Specify if using SAAS (https://design.penpot.app) or self-hosted instance. If self-hosted instance, add OS and runtime information to help explain your problem. - OS Version: (e.g. Ubuntu 16.04) -Also provide Docker commands or docker-compose file if possible. +Also provide Docker commands or docker-compose file if possible and if proceed.x - Docker / Docker-compose Version: (e.g. Docker version 18.03.0-ce, build 0520e24) - Image (e.g. alpine) diff --git a/CHANGES.md b/CHANGES.md index a9ef44e59e..a1e53635be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,15 +1,60 @@ # CHANGELOG # -## Next +## :rocket: Next -### New features +### :sparkles: New features -### Bugs fixed +### :bug: Bugs fixed + +### :heart: Community contributions by (Thank you!) + + +## 1.3.0-alpha + +### :sparkles: New features + +- Add emailcatcher and ldap test containers to devenv. [#506](https://github.com/penpot/penpot/pull/506) +- Add major refactor of internal pubsub/redis code; improves scalability and performance [#640](https://github.com/penpot/penpot/pull/640) +- Add more chinese transtions [#687](https://github.com/penpot/penpot/pull/687) +- Add more presets for artboard [#654](https://github.com/penpot/penpot/pull/654) +- Add optional loki integration [#645](https://github.com/penpot/penpot/pull/645) +- Add proper http session lifecycle handling. +- Allow to set border radius of each rect corner individually +- Bounce & Complaint handling [#635](https://github.com/penpot/penpot/pull/635) +- Disable groups interactions when holding "Ctrl" key (deep selection) +- New action in context menu to "edit" some shapes (binded to key "Enter") + + +### :bug: Bugs fixed + +- Add more improvements to french translation strings [#591](https://github.com/penpot/penpot/pull/591) +- Add some missing database indexes (mainly improves performance on large databases on file-update rpc method, and some background tasks). +- Disables filters in masking elements (problem with Firefox rendering) +- Drawing tool will have priority over resize/rotate handlers [Taiga #1225](https://tree.taiga.io/project/penpot/issue/1225) +- Fix broken bounding box on editing paths [Taiga #1254](https://tree.taiga.io/project/penpot/issue/1254) +- Fix corner cases on invitation/signup flows. +- Fix errors on onboarding file [Taiga #1287](https://tree.taiga.io/project/penpot/issue/1287) +- Fix infinite recursion on logout. +- Fix issues with frame selection [Taiga #1300](https://tree.taiga.io/project/penpot/issue/1300), [Taiga #1255](https://tree.taiga.io/project/penpot/issue/1255) +- Fix local fonts error [#691](https://github.com/penpot/penpot/issues/691) +- Fix problem width handoff code generation [Taiga #1204](https://tree.taiga.io/project/penpot/issue/1204) +- Fix problem with indices refreshing on page changes [#646](https://github.com/penpot/penpot/issues/646) +- Have language change notification written in the new language [Taiga #1205](https://tree.taiga.io/project/penpot/issue/1205) +- Hide register screen when registration is disabled [#598](https://github.com/penpot/penpot/issues/598) +- Properly handle errors on github, gitlab and ldap auth backends. +- Properly mark profile auth backend (on first register/ auth with 3rd party auth provider). +- Refactor LDAP auth backend. + + +### :heart: Community contributions by (Thank you!) + +- girafic [#538](https://github.com/penpot/penpot/pull/654) +- arkhi [#591](https://github.com/penpot/penpot/pull/591) ## 1.2.0-alpha -### New features +### :sparkles: New features - Add horizontal/vertical flip - Add images lock proportions by default [#541](https://github.com/penpot/penpot/discussions/541), [#609](https://github.com/penpot/penpot/issues/609) @@ -22,7 +67,7 @@ - Fix behavior of select all command when there are objects outside frames [Taiga #1209](https://tree.taiga.io/project/penpot/issue/1209) -### Bugs fixed +### :bug: Bugs fixed - Fix 404 when access shared link [#615](https://github.com/penpot/penpot/issues/615) - Fix 500 when requestion password reset @@ -42,7 +87,7 @@ - Fix updates on collaborative editing not updating selection rectangles [Taiga #1127](https://tree.taiga.io/project/penpot/issue/1127) - Make the team deletion deferred (in the same way other objects) -### Community contributions by (Thank you! :heart:) +### :heart: Community contributions by (Thank you!) - abtinmo [#538](https://github.com/penpot/penpot/pull/538) - kdrag0n [#585](https://github.com/penpot/penpot/pull/585) diff --git a/backend/deps.edn b/backend/deps.edn index c178b10a89..c4cd8b435e 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -7,6 +7,7 @@ org.clojure/clojurescript {:mvn/version "1.10.773"} org.clojure/data.json {:mvn/version "1.0.0"} org.clojure/core.async {:mvn/version "1.3.610"} + org.clojure/tools.cli {:mvn/version "1.0.194"} ;; Logging org.clojure/tools.logging {:mvn/version "1.1.0"} @@ -16,6 +17,8 @@ org.apache.logging.log4j/log4j-jul {:mvn/version "2.14.0"} org.apache.logging.log4j/log4j-slf4j-impl {:mvn/version "2.14.0"} org.slf4j/slf4j-api {:mvn/version "1.7.30"} + org.zeromq/jeromq {:mvn/version "0.5.2"} + org.graalvm.js/js {:mvn/version "20.3.0"} com.taoensso/nippy {:mvn/version "3.1.1"} @@ -32,7 +35,7 @@ expound/expound {:mvn/version "0.8.7"} com.cognitect/transit-clj {:mvn/version "1.0.324"} - io.lettuce/lettuce-core {:mvn/version "5.2.2.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "6.0.2.RELEASE"} java-http-clj/java-http-clj {:mvn/version "0.4.1"} info.sunng/ring-jetty9-adapter {:mvn/version "0.14.2"} @@ -43,7 +46,6 @@ org.postgresql/postgresql {:mvn/version "42.2.18"} com.zaxxer/HikariCP {:mvn/version "3.4.5"} - funcool/log4j2-clojure {:mvn/version "2020.11.23-1"} funcool/datoteka {:mvn/version "1.2.0"} funcool/promesa {:mvn/version "6.0.0"} funcool/cuerdas {:mvn/version "2020.03.26-3"} diff --git a/backend/dev/user.clj b/backend/dev/user.clj index 6e66cd6c17..5b861a9fac 100644 --- a/backend/dev/user.clj +++ b/backend/dev/user.clj @@ -9,24 +9,24 @@ (ns user (:require + [app.common.exceptions :as ex] [app.config :as cfg] [app.main :as main] [app.util.time :as dt] [app.util.transit :as t] - [app.common.exceptions :as ex] - [taoensso.nippy :as nippy] - [clojure.data.json :as json] + [app.util.json :as json] [clojure.java.io :as io] - [clojure.test :as test] [clojure.pprint :refer [pprint]] [clojure.repl :refer :all] [clojure.spec.alpha :as s] [clojure.spec.gen.alpha :as sgen] [clojure.test :as test] + [clojure.test :as test] [clojure.tools.namespace.repl :as repl] [clojure.walk :refer [macroexpand-all]] [criterium.core :refer [quick-bench bench with-progress-reporting]] - [integrant.core :as ig])) + [integrant.core :as ig] + [taoensso.nippy :as nippy])) (repl/disable-reload! (find-ns 'integrant.core)) diff --git a/backend/resources/emails/feedback/en.subj b/backend/resources/emails/feedback/en.subj index 2ecd8c0c4f..7f1c38c4b0 100644 --- a/backend/resources/emails/feedback/en.subj +++ b/backend/resources/emails/feedback/en.subj @@ -1 +1 @@ -[FEEDBACK]: From {{ profile.email }} +[PENPOT FEEDBACK]: {{subject|abbreviate:19}} (from {{email}}) diff --git a/backend/resources/emails/feedback/en.txt b/backend/resources/emails/feedback/en.txt index f6e602a199..a60d380c8e 100644 --- a/backend/resources/emails/feedback/en.txt +++ b/backend/resources/emails/feedback/en.txt @@ -1,6 +1,8 @@ -Feedback from: {{profile.fullname}} <{{profile.email}}> - -Profile ID: {{profile.id}} +{% if profile %} +Feedback profile: {{profile.fullname}} <{{profile.email}}> / {{profile.id}} +{% else %} +Feedback from: {{email}} +{% endif %} Subject: {{subject}} diff --git a/backend/resources/error-report.tmpl b/backend/resources/error-report.tmpl index 3a420a60cb..a04f0df6b0 100644 --- a/backend/resources/error-report.tmpl +++ b/backend/resources/error-report.tmpl @@ -31,7 +31,7 @@ .table-key { font-weight: 600; - width: 70px; + width: 60px; padding: 4px; } @@ -70,27 +70,43 @@ {% if user-agent %}
-
UAGENT:
+
UAGT:
{{user-agent}}
{% endif %} {% if frontend-version %}
-
FVERS:
+
FVER:
{{frontend-version}}
{% endif %}
-
BVERS:
+
BVER:
{{version}}
+ {% if host %}
HOST:
{{host}}
+ {% endif %} + + {% if tenant %} +
+
ENV:
+
{{tenant}}
+
+ {% endif %} + + {% if public-uri %} +
+
PURI:
+
{{public-uri}}
+
+ {% endif %} {% if type %}
@@ -106,15 +122,19 @@
{% endif %} + {% if error %}
-
CLASS:
-
{{class}}
+
CLSS:
+
{{error.class}}
+ {% endif %} + {% if error %}
HINT:
-
{{hint}}
+
{{error.message}}
+ {% endif %} {% if method %}
@@ -123,8 +143,18 @@
{% endif %} + {% if explain %} +
(go to explain)
+ {% endif %} + {% if data %} +
(go to edata)
+ {% endif %} + {% if error %} +
(go to trace)
+ {% endif %} + {% if params %} -
+
PARAMS:
{{params}}
@@ -133,7 +163,7 @@ {% endif %} {% if explain %} -
+
EXPLAIN:
{{explain}}
@@ -142,7 +172,7 @@ {% endif %} {% if data %} -
+
EDATA:
{{data}}
@@ -150,12 +180,14 @@
{% endif %} -
+ {% if error %} +
TRACE:
-
{{message}}
+
{{error.trace}}
+ {% endif %}
diff --git a/backend/resources/log4j2-bundle.xml b/backend/resources/log4j2-bundle.xml index 7f6a243ab8..8ebe2de44d 100644 --- a/backend/resources/log4j2-bundle.xml +++ b/backend/resources/log4j2-bundle.xml @@ -7,20 +7,15 @@ - - - - - - - + + - + - + diff --git a/backend/resources/log4j2.xml b/backend/resources/log4j2.xml index 0dff9f13d2..acfa8c7a9a 100644 --- a/backend/resources/log4j2.xml +++ b/backend/resources/log4j2.xml @@ -13,27 +13,33 @@ - - - + + tcp://localhost:45556 + + - - - + + + - + - - + + + + + + + diff --git a/backend/scripts/build.sh b/backend/scripts/build.sh index 39d0890405..57b2e30577 100755 --- a/backend/scripts/build.sh +++ b/backend/scripts/build.sh @@ -25,12 +25,8 @@ echo $NEWCP > ./target/dist/classpath; tee -a ./target/dist/run.sh >> /dev/null <> /dev/null <&2 echo "Couldn't find 'java'. Please set JAVA_HOME." + exit 1 + fi +fi + +if [ -f ./environ ]; then + source ./environ +fi + +exec \$JAVA_CMD \$JVM_OPTS -classpath \$CP -Dlog4j.configurationFile=./log4j2.xml clojure.main -m app.cli.manage "\$@" +EOF + chmod +x ./target/dist/run.sh +chmod +x ./target/dist/manage.sh + diff --git a/backend/scripts/repl b/backend/scripts/repl index e3fa8b3247..147661f350 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -2,6 +2,9 @@ export PENPOT_ASSERTS_ENABLED=true +export OPTIONS="-A:jmx-remote:dev -J-Dclojure.tools.logging.factory=clojure.tools.logging.impl/log4j2-factory -J-Xms512m -J-Xmx512m" +export OPTIONS_EVAL="nil" +# export OPTIONS_EVAL="(set! *warn-on-reflection* true)" + set -ex -# clojure -Ojmx-remote -A:dev -e "(set! *warn-on-reflection* true)" -m rebel-readline.main -clojure -A:jmx-remote:dev -J-Xms512m -J-Xmx512m -M -m rebel-readline.main +exec clojure $OPTIONS -M -e "$OPTIONS_EVAL" -m rebel-readline.main diff --git a/backend/src/app/cli/fixtures.clj b/backend/src/app/cli/fixtures.clj index 4400654c6a..f863d970c5 100644 --- a/backend/src/app/cli/fixtures.clj +++ b/backend/src/app/cli/fixtures.clj @@ -81,7 +81,7 @@ {:id id :fullname (str "Profile " index) :password "123123" - :demo? true + :is-demo true :email (str "profile" index "@example.com")}) team-id (:default-team-id prof) owner-id id] @@ -237,6 +237,6 @@ (try (run-in-system system preset) (catch Exception e - (log/errorf e "Unhandled exception.")) + (log/errorf e "unhandled exception")) (finally (ig/halt! system))))) diff --git a/backend/src/app/cli/manage.clj b/backend/src/app/cli/manage.clj new file mode 100644 index 0000000000..36093375e4 --- /dev/null +++ b/backend/src/app/cli/manage.clj @@ -0,0 +1,173 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2021 UXBOX Labs SL + +(ns app.cli.manage + "A manage cli api." + (:require + [app.config :as cfg] + [app.db :as db] + [app.main :as main] + [app.rpc.mutations.profile :as profile] + [app.rpc.queries.profile :refer [retrieve-profile-data-by-email]] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [clojure.tools.logging :as log] + [integrant.core :as ig]) + (:import + java.io.Console)) + +;; --- IMPL + +(defn init-system + [] + (let [data (-> (main/build-system-config cfg/config) + (select-keys [:app.db/pool :app.metrics/metrics]) + (assoc :app.migrations/all {}))] + (-> data ig/prep ig/init))) + +(defn- read-from-console + [{:keys [label type] :or {type :text}}] + (let [^Console console (System/console)] + (when-not console + (log/error "no console found, can proceed") + (System/exit 1)) + + (binding [*out* (.writer console)] + (print label " ") + (.flush *out*)) + + (case type + :text (.readLine console) + :password (String. (.readPassword console))))) + +(defn create-profile + [options] + (let [system (init-system) + email (or (:email options) + (read-from-console {:label "Email:"})) + fullname (or (:fullname options) + (read-from-console {:label "Full Name:"})) + password (or (:password options) + (read-from-console {:label "Password:" + :type :password}))] + (try + (db/with-atomic [conn (:app.db/pool system)] + (->> (profile/create-profile conn + {:fullname fullname + :email email + :password password + :is-active true + :is-demo false}) + (profile/create-profile-relations conn))) + + (when (pos? (:verbosity options)) + (println "User created successfully.")) + (System/exit 0) + + (catch Exception _e + (when (pos? (:verbosity options)) + (println "Unable to create user, already exists.")) + (System/exit 1))))) + +(defn reset-password + [options] + (let [system (init-system)] + (try + (db/with-atomic [conn (:app.db/pool system)] + (let [email (or (:email options) + (read-from-console {:label "Email:"})) + profile (retrieve-profile-data-by-email conn email)] + (when-not profile + (when (pos? (:verbosity options)) + (println "Profile does not exists.")) + (System/exit 1)) + + (let [password (or (:password options) + (read-from-console {:label "Password:" + :type :password}))] + (profile/update-profile-password! conn (assoc profile :password password)) + (when (pos? (:verbosity options)) + (println "Password changed successfully."))))) + (System/exit 0) + (catch Exception e + (when (pos? (:verbosity options)) + (println "Unable to change password.")) + (when (= 2 (:verbosity options)) + (.printStackTrace e)) + (System/exit 1))))) + +;; --- CLI PARSE + +(def cli-options + ;; An option with a required argument + [["-u" "--email EMAIL" "Email Address"] + ["-p" "--password PASSWORD" "Password"] + ["-n" "--name FULLNAME" "Full Name" + :id :fullname] + ["-v" nil "Verbosity level" + :id :verbosity + :default 1 + :update-fn inc] + ["-q" nil "Dont' print to console" + :id :verbosity + :update-fn (constantly 0)] + ["-h" "--help"]]) + +(defn usage + [options-summary] + (->> ["Penpot CLI management." + "" + "Usage: manage [options] action" + "" + "Options:" + options-summary + "" + "Actions:" + " create-profile Create new profile." + " reset-password Reset profile password." + ""] + (str/join \newline))) + +(defn error-msg [errors] + (str "The following errors occurred while parsing your command:\n\n" + (str/join \newline errors))) + +(defn validate-args + "Validate command line arguments. Either return a map indicating the program + should exit (with a error message, and optional ok status), or a map + indicating the action the program should take and the options provided." + [args] + (let [{:keys [options arguments errors summary] :as opts} (parse-opts args cli-options)] + ;; (pp/pprint opts) + (cond + (:help options) ; help => exit OK with usage summary + {:exit-message (usage summary) :ok? true} + + errors ; errors => exit with description of errors + {:exit-message (error-msg errors)} + + ;; custom validation on arguments + :else + (let [action (first arguments)] + (if (#{"create-profile" "reset-password"} action) + {:action (first arguments) :options options} + {:exit-message (usage summary)}))))) + +(defn exit [status msg] + (println msg) + (System/exit status)) + +(defn -main + [& args] + (let [{:keys [action options exit-message ok?]} (validate-args args)] + (if exit-message + (exit (if ok? 0 1) exit-message) + (case action + "create-profile" (create-profile options) + "reset-password" (reset-password options))))) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 12022af73d..a9fe74ac82 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -5,27 +5,32 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.config "A configuration management." + (:refer-clojure :exclude [get]) (:require [app.common.spec :as us] [app.common.version :as v] [app.util.time :as dt] + [clojure.core :as c] [clojure.spec.alpha :as s] [cuerdas.core :as str] [environ.core :refer [env]])) (def defaults {:http-server-port 6060 - + :host "devenv" + :tenant "dev" :database-uri "postgresql://127.0.0.1/penpot" :database-username "penpot" :database-password "penpot" :default-blob-version 1 + :loggers-zmq-uri "tcp://localhost:45556" + :asserts-enabled false :public-uri "http://localhost:3449" @@ -52,6 +57,12 @@ :smtp-default-reply-to "Penpot " :smtp-default-from "Penpot " + :profile-complaint-max-age (dt/duration {:days 7}) + :profile-complaint-threshold 2 + + :profile-bounce-max-age (dt/duration {:days 7}) + :profile-bounce-threshold 10 + :allow-demo-users true :registration-enabled true :registration-domain-whitelist "" @@ -59,100 +70,89 @@ :telemetry-enabled false :telemetry-uri "https://telemetry.penpot.app/" - ;; LDAP auth disabled by default. Set ldap-auth-host to enable - ;:ldap-auth-host "ldap.mysupercompany.com" - ;:ldap-auth-port 389 - ;:ldap-bind-dn "cn=admin,dc=ldap,dc=mysupercompany,dc=com" - ;:ldap-bind-password "verysecure" - ;:ldap-auth-ssl false - ;:ldap-auth-starttls false - ;:ldap-auth-base-dn "ou=People,dc=ldap,dc=mysupercompany,dc=com" + :ldap-user-query "(|(uid=$username)(mail=$username))" + :ldap-attrs-username "uid" + :ldap-attrs-email "mail" + :ldap-attrs-fullname "cn" + :ldap-attrs-photo "jpegPhoto" - :ldap-auth-user-query "(|(uid=$username)(mail=$username))" - :ldap-auth-username-attribute "uid" - :ldap-auth-email-attribute "mail" - :ldap-auth-fullname-attribute "displayName" - :ldap-auth-avatar-attribute "jpegPhoto" - - ;; :initial-data-file "resources/initial-data.json" - ;; :initial-data-project-name "Penpot Oboarding" + ;; a server prop key where initial project is stored. + :initial-project-skey "initial-project" }) -(s/def ::http-server-port ::us/integer) -(s/def ::database-username (s/nilable ::us/string)) +(s/def ::allow-demo-users ::us/boolean) +(s/def ::asserts-enabled ::us/boolean) +(s/def ::assets-path ::us/string) (s/def ::database-password (s/nilable ::us/string)) (s/def ::database-uri ::us/string) -(s/def ::redis-uri ::us/string) - - -(s/def ::storage-backend ::us/keyword) -(s/def ::storage-fs-directory ::us/string) -(s/def ::assets-path ::us/string) -(s/def ::storage-s3-region ::us/keyword) -(s/def ::storage-s3-bucket ::us/string) - -(s/def ::media-uri ::us/string) -(s/def ::media-directory ::us/string) -(s/def ::asserts-enabled ::us/boolean) - -(s/def ::feedback-enabled ::us/boolean) -(s/def ::feedback-destination ::us/string) - +(s/def ::database-username (s/nilable ::us/string)) +(s/def ::default-blob-version ::us/integer) (s/def ::error-report-webhook ::us/string) - -(s/def ::smtp-enabled ::us/boolean) -(s/def ::smtp-default-reply-to ::us/string) -(s/def ::smtp-default-from ::us/string) -(s/def ::smtp-host ::us/string) -(s/def ::smtp-port ::us/integer) -(s/def ::smtp-username (s/nilable ::us/string)) -(s/def ::smtp-password (s/nilable ::us/string)) -(s/def ::smtp-tls ::us/boolean) -(s/def ::smtp-ssl ::us/boolean) -(s/def ::allow-demo-users ::us/boolean) -(s/def ::registration-enabled ::us/boolean) -(s/def ::registration-domain-whitelist ::us/string) -(s/def ::public-uri ::us/string) - -(s/def ::srepl-host ::us/string) -(s/def ::srepl-port ::us/integer) - -(s/def ::rlimits-password ::us/integer) -(s/def ::rlimits-image ::us/integer) - -(s/def ::google-client-id ::us/string) -(s/def ::google-client-secret ::us/string) - -(s/def ::gitlab-client-id ::us/string) -(s/def ::gitlab-client-secret ::us/string) -(s/def ::gitlab-base-uri ::us/string) - +(s/def ::feedback-destination ::us/string) +(s/def ::feedback-enabled ::us/boolean) +(s/def ::feedback-reply-to ::us/email) +(s/def ::feedback-token ::us/string) (s/def ::github-client-id ::us/string) (s/def ::github-client-secret ::us/string) - -(s/def ::ldap-auth-host ::us/string) -(s/def ::ldap-auth-port ::us/integer) +(s/def ::gitlab-base-uri ::us/string) +(s/def ::gitlab-client-id ::us/string) +(s/def ::gitlab-client-secret ::us/string) +(s/def ::google-client-id ::us/string) +(s/def ::google-client-secret ::us/string) +(s/def ::host ::us/string) +(s/def ::http-server-port ::us/integer) +(s/def ::http-session-cookie-name ::us/string) +(s/def ::http-session-idle-max-age ::dt/duration) +(s/def ::http-session-updater-batch-max-age ::dt/duration) +(s/def ::http-session-updater-batch-max-size ::us/integer) +(s/def ::initial-project-skey ::us/string) +(s/def ::ldap-attrs-email ::us/string) +(s/def ::ldap-attrs-fullname ::us/string) +(s/def ::ldap-attrs-photo ::us/string) +(s/def ::ldap-attrs-username ::us/string) +(s/def ::ldap-base-dn ::us/string) (s/def ::ldap-bind-dn ::us/string) (s/def ::ldap-bind-password ::us/string) -(s/def ::ldap-auth-ssl ::us/boolean) -(s/def ::ldap-auth-starttls ::us/boolean) -(s/def ::ldap-auth-base-dn ::us/string) -(s/def ::ldap-auth-user-query ::us/string) -(s/def ::ldap-auth-username-attribute ::us/string) -(s/def ::ldap-auth-email-attribute ::us/string) -(s/def ::ldap-auth-fullname-attribute ::us/string) -(s/def ::ldap-auth-avatar-attribute ::us/string) - +(s/def ::ldap-host ::us/string) +(s/def ::ldap-port ::us/integer) +(s/def ::ldap-ssl ::us/boolean) +(s/def ::ldap-starttls ::us/boolean) +(s/def ::ldap-user-query ::us/string) +(s/def ::loggers-loki-uri ::us/string) +(s/def ::loggers-zmq-uri ::us/string) +(s/def ::media-directory ::us/string) +(s/def ::media-uri ::us/string) +(s/def ::profile-bounce-max-age ::dt/duration) +(s/def ::profile-bounce-threshold ::us/integer) +(s/def ::profile-complaint-max-age ::dt/duration) +(s/def ::profile-complaint-threshold ::us/integer) +(s/def ::public-uri ::us/string) +(s/def ::redis-uri ::us/string) +(s/def ::registration-domain-whitelist ::us/string) +(s/def ::registration-enabled ::us/boolean) +(s/def ::rlimits-image ::us/integer) +(s/def ::rlimits-password ::us/integer) +(s/def ::smtp-default-from ::us/string) +(s/def ::smtp-default-reply-to ::us/string) +(s/def ::smtp-enabled ::us/boolean) +(s/def ::smtp-host ::us/string) +(s/def ::smtp-password (s/nilable ::us/string)) +(s/def ::smtp-port ::us/integer) +(s/def ::smtp-ssl ::us/boolean) +(s/def ::smtp-tls ::us/boolean) +(s/def ::smtp-username (s/nilable ::us/string)) +(s/def ::srepl-host ::us/string) +(s/def ::srepl-port ::us/integer) +(s/def ::storage-backend ::us/keyword) +(s/def ::storage-fs-directory ::us/string) +(s/def ::storage-s3-bucket ::us/string) +(s/def ::storage-s3-region ::us/keyword) (s/def ::telemetry-enabled ::us/boolean) -(s/def ::telemetry-with-taiga ::us/boolean) -(s/def ::telemetry-uri ::us/string) (s/def ::telemetry-server-enabled ::us/boolean) (s/def ::telemetry-server-port ::us/integer) - -(s/def ::initial-data-file ::us/string) -(s/def ::initial-data-project-name ::us/string) - -(s/def ::default-blob-version ::us/integer) +(s/def ::telemetry-uri ::us/string) +(s/def ::telemetry-with-taiga ::us/boolean) +(s/def ::tenant ::us/string) (s/def ::config (s/keys :opt-un [::allow-demo-users @@ -162,8 +162,10 @@ ::database-username ::default-blob-version ::error-report-webhook - ::feedback-enabled ::feedback-destination + ::feedback-enabled + ::feedback-reply-to + ::feedback-token ::github-client-id ::github-client-secret ::gitlab-base-uri @@ -171,25 +173,37 @@ ::gitlab-client-secret ::google-client-id ::google-client-secret + ::host ::http-server-port - ::ldap-auth-avatar-attribute - ::ldap-auth-base-dn - ::ldap-auth-email-attribute - ::ldap-auth-fullname-attribute - ::ldap-auth-host - ::ldap-auth-port - ::ldap-auth-ssl - ::ldap-auth-starttls - ::ldap-auth-user-query - ::ldap-auth-username-attribute + ::http-session-idle-max-age + ::http-session-updater-batch-max-age + ::http-session-updater-batch-max-size + ::initial-project-skey + ::ldap-attrs-email + ::ldap-attrs-fullname + ::ldap-attrs-photo + ::ldap-attrs-username + ::ldap-base-dn ::ldap-bind-dn ::ldap-bind-password + ::ldap-host + ::ldap-port + ::ldap-ssl + ::ldap-starttls + ::ldap-user-query + ::local-assets-uri + ::loggers-loki-uri + ::loggers-zmq-uri + ::profile-bounce-max-age + ::profile-bounce-threshold + ::profile-complaint-max-age + ::profile-complaint-threshold ::public-uri ::redis-uri ::registration-domain-whitelist ::registration-enabled - ::rlimits-password ::rlimits-image + ::rlimits-password ::smtp-default-from ::smtp-default-reply-to ::smtp-enabled @@ -199,20 +213,18 @@ ::smtp-ssl ::smtp-tls ::smtp-username - ::storage-backend - ::storage-fs-directory ::srepl-host ::srepl-port - ::local-assets-uri + ::storage-backend + ::storage-fs-directory ::storage-s3-bucket ::storage-s3-region ::telemetry-enabled - ::telemetry-with-taiga ::telemetry-server-enabled ::telemetry-server-port ::telemetry-uri - ::initial-data-file - ::initial-data-project-name])) + ::telemetry-with-taiga + ::tenant])) (defn- env->config [env] @@ -247,3 +259,10 @@ (def deletion-delay (dt/duration {:days 7})) + +(defn get + "A configuration getter. Helps code be more testable." + ([key] + (c/get config key)) + ([key default] + (c/get config key default))) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index 1fcb18e47a..73026bae7d 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -13,12 +13,14 @@ [app.common.geom.point :as gpt] [app.common.spec :as us] [app.db.sql :as sql] + [app.metrics :as mtx] [app.util.json :as json] [app.util.migrations :as mg] [app.util.time :as dt] [app.util.transit :as t] [clojure.java.io :as io] [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] [integrant.core :as ig] [next.jdbc :as jdbc] [next.jdbc.date-time :as jdbc-dt]) @@ -26,6 +28,7 @@ com.zaxxer.hikari.HikariConfig com.zaxxer.hikari.HikariDataSource com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory + java.lang.AutoCloseable java.sql.Connection java.sql.Savepoint org.postgresql.PGConnection @@ -43,21 +46,24 @@ ;; Initialization ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(declare instrument-jdbc!) + (s/def ::uri ::us/not-empty-string) (s/def ::name ::us/not-empty-string) (s/def ::min-pool-size ::us/integer) (s/def ::max-pool-size ::us/integer) (s/def ::migrations map?) -(s/def ::metrics map?) (defmethod ig/pre-init-spec ::pool [_] - (s/keys :req-un [::uri ::name ::min-pool-size ::max-pool-size ::migrations])) + (s/keys :req-un [::uri ::name ::min-pool-size ::max-pool-size ::migrations ::mtx/metrics])) (defmethod ig/init-key ::pool - [_ {:keys [migrations] :as cfg}] + [_ {:keys [migrations metrics] :as cfg}] + (log/infof "initialize connection pool '%s' with uri '%s'" (:name cfg) (:uri cfg)) + (instrument-jdbc! (:registry metrics)) (let [pool (create-pool cfg)] (when (seq migrations) - (with-open [conn (open pool)] + (with-open [conn ^AutoCloseable (open pool)] (mg/setup! conn) (doseq [[mname steps] migrations] (mg/migrate! conn {:name (name mname) :steps steps})))) @@ -67,12 +73,22 @@ [_ pool] (.close ^HikariDataSource pool)) +(defn- instrument-jdbc! + [registry] + (mtx/instrument-vars! + [#'next.jdbc/execute-one! + #'next.jdbc/execute!] + {:registry registry + :type :counter + :name "database_query_count" + :help "An absolute counter of database queries."})) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; API & Impl ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def initsql - (str "SET statement_timeout = 60000;\n" + (str "SET statement_timeout = 120000;\n" "SET idle_in_transaction_session_timeout = 120000;")) (defn- create-datasource-config @@ -162,7 +178,7 @@ [& args] `(jdbc/with-transaction ~@args)) -(defn open +(defn ^Connection open [pool] (jdbc/get-connection pool)) @@ -184,11 +200,6 @@ (sql/insert table params opts) (assoc opts :return-keys true)))) -(defn insert-multi! - [ds table param-list] - (doseq [params param-list] - (insert! ds table params))) - (defn update! ([ds table params where] (update! ds table params where nil)) ([ds table params where opts] @@ -286,7 +297,7 @@ (pginterval data) (dt/duration? data) - (->> (/ (.toMillis data) 1000.0) + (->> (/ (.toMillis ^java.time.Duration data) 1000.0) (format "%s seconds") (pginterval)) diff --git a/backend/src/app/db/profile_initial_data.clj b/backend/src/app/db/profile_initial_data.clj deleted file mode 100644 index 0d810ee255..0000000000 --- a/backend/src/app/db/profile_initial_data.clj +++ /dev/null @@ -1,117 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020-2021 UXBOX Labs SL - -(ns app.db.profile-initial-data - (:require - [app.common.uuid :as uuid] - [app.config :as cfg] - [app.db :as db] - [app.rpc.mutations.projects :as projects] - [app.util.transit :as tr] - [clojure.java.io :as io] - [datoteka.core :as fs])) - -(def sql:file - "select * from file where project_id = ?") - -(def sql:file-library-rel - "with file_ids as (select id from file where project_id = ?) - select * - from file_library_rel - where file_id in (select id from file_ids)") - -(def sql:file-media-object - "with file_ids as (select id from file where project_id = ?) - select * - from file_media_object - where file_id in (select id from file_ids)") - -(defn change-ids - "Given a collection and a map from ID to ID. Changes all the `keys` properties - so they point to the new ID existing in `map-ids`" - [map-ids coll keys] - (let [generate-id - (fn [map-ids {:keys [id]}] - (assoc map-ids id (uuid/next))) - - remap-key - (fn [obj map-ids key] - (cond-> obj - (contains? obj key) - (assoc key (get map-ids (get obj key) (get obj key))))) - - change-id - (fn [map-ids obj] - (reduce #(remap-key %1 map-ids %2) obj keys)) - - new-map-ids (reduce generate-id map-ids coll)] - - [new-map-ids (map (partial change-id new-map-ids) coll)])) - -(defn create-initial-data-dump - [conn project-id output-path] - (let [ ;; Retrieve data from templates - opath (fs/path output-path) - file (db/exec! conn [sql:file, project-id]) - file-library-rel (db/exec! conn [sql:file-library-rel, project-id]) - file-media-object (db/exec! conn [sql:file-media-object, project-id]) - - data {:file file - :file-library-rel file-library-rel - :file-media-object file-media-object}] - (with-open [output (io/output-stream opath)] - (tr/encode-stream data output) - nil))) - -(defn read-initial-data - [path] - (when (fs/exists? path) - (with-open [input (io/input-stream (fs/path path))] - (tr/decode-stream input)))) - -(defn create-profile-initial-data - ([conn profile] - (when-let [initial-data-path (:initial-data-file cfg/config)] - (create-profile-initial-data conn initial-data-path profile))) - - ([conn file profile] - (when-let [{:keys [file file-library-rel file-media-object]} (read-initial-data file)] - (let [sample-project-name (:initial-data-project-name cfg/config "Penpot Onboarding") - - proj (projects/create-project conn {:profile-id (:id profile) - :team-id (:default-team-id profile) - :name sample-project-name}) - - map-ids {} - - ;; Create new ID's and change the references - [map-ids file] (change-ids map-ids file #{:id}) - [map-ids file-library-rel] (change-ids map-ids file-library-rel #{:file-id :library-file-id}) - [_ file-media-object] (change-ids map-ids file-media-object #{:id :file-id :media-id :thumbnail-id}) - - file (map #(assoc % :project-id (:id proj)) file) - file-profile-rel (map #(array-map :file-id (:id %) - :profile-id (:id profile) - :is-owner true - :is-admin true - :can-edit true) - file)] - - (projects/create-project-profile conn {:project-id (:id proj) - :profile-id (:id profile)}) - - (projects/create-team-project-profile conn {:team-id (:default-team-id profile) - :project-id (:id proj) - :profile-id (:id profile)}) - - ;; Re-insert into the database - (db/insert-multi! conn :file file) - (db/insert-multi! conn :file-profile-rel file-profile-rel) - (db/insert-multi! conn :file-library-rel file-library-rel) - (db/insert-multi! conn :file-media-object file-media-object))))) diff --git a/backend/src/app/emails.clj b/backend/src/app/emails.clj index 68441d821a..74fbaf84bf 100644 --- a/backend/src/app/emails.clj +++ b/backend/src/app/emails.clj @@ -5,13 +5,15 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.emails "Main api for send emails." (:require [app.common.spec :as us] [app.config :as cfg] + [app.db :as db] + [app.db.sql :as sql] [app.tasks :as tasks] [app.util.emails :as emails] [clojure.spec.alpha :as s])) @@ -41,6 +43,54 @@ :priority 200 :props email}))) + +(def sql:profile-complaint-report + "select (select count(*) + from profile_complaint_report + where type = 'complaint' + and profile_id = ? + and created_at > now() - ?::interval) as complaints, + (select count(*) + from profile_complaint_report + where type = 'bounce' + and profile_id = ? + and created_at > now() - ?::interval) as bounces;") + +(defn allow-send-emails? + [conn profile] + (when-not (:is-muted profile false) + (let [complaint-threshold (cfg/get :profile-complaint-threshold) + complaint-max-age (cfg/get :profile-complaint-max-age) + bounce-threshold (cfg/get :profile-bounce-threshold) + bounce-max-age (cfg/get :profile-bounce-max-age) + + {:keys [complaints bounces] :as result} + (db/exec-one! conn [sql:profile-complaint-report + (:id profile) + (db/interval complaint-max-age) + (:id profile) + (db/interval bounce-max-age)])] + + (and (< complaints complaint-threshold) + (< bounces bounce-threshold))))) + +(defn has-complaint-reports? + ([conn email] (has-complaint-reports? conn email nil)) + ([conn email {:keys [threshold] :or {threshold 1}}] + (let [reports (db/exec! conn (sql/select :global-complaint-report + {:email email :type "complaint"} + {:limit 10}))] + (>= (count reports) threshold)))) + +(defn has-bounce-reports? + ([conn email] (has-bounce-reports? conn email nil)) + ([conn email {:keys [threshold] :or {threshold 1}}] + (let [reports (db/exec! conn (sql/select :global-complaint-report + {:email email :type "bounce"} + {:limit 10}))] + (>= (count reports) threshold)))) + + ;; --- Emails (s/def ::subject ::us/string) diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index ab267e36bc..f15411d6a7 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -12,7 +12,6 @@ [app.common.data :as d] [app.common.spec :as us] [app.config :as cfg] - [app.http.auth :as auth] [app.http.errors :as errors] [app.http.middleware :as middleware] [app.metrics :as mtx] @@ -43,7 +42,7 @@ (defmethod ig/init-key ::server [_ {:keys [handler ws port name metrics] :as opts}] - (log/infof "Starting %s server on port %s." name port) + (log/infof "starting '%s' server on port %s." name port) (let [pre-start (fn [^Server server] (let [handler (doto (ErrorHandler.) (.setShowStacks true) @@ -69,7 +68,7 @@ (defmethod ig/halt-key! ::server [_ {:keys [server name port] :as opts}] - (log/infof "Stoping %s server on port %s." name port) + (log/infof "stoping '%s' server on port %s." name port) (jetty/stop-server server)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -81,14 +80,13 @@ (s/def ::rpc map?) (s/def ::session map?) (s/def ::metrics map?) -(s/def ::google-auth map?) -(s/def ::gitlab-auth map?) -(s/def ::ldap-auth fn?) +(s/def ::oauth map?) (s/def ::storage map?) (s/def ::assets map?) +(s/def ::feedback fn?) (defmethod ig/pre-init-spec ::router [_] - (s/keys :req-un [::rpc ::session ::metrics ::google-auth ::gitlab-auth ::storage ::assets])) + (s/keys :req-un [::rpc ::session ::metrics ::oauth ::storage ::assets ::feedback])) (defmethod ig/init-key ::router [_ cfg] @@ -105,16 +103,16 @@ (try (let [cdata (errors/get-error-context request e)] (update-thread-context! cdata) - (log/errorf e "Unhandled exception: %s (id: %s)" (ex-message e) (str (:id cdata))) + (log/errorf e "unhandled exception: %s (id: %s)" (ex-message e) (str (:id cdata))) {:status 500 :body "internal server error"}) (catch Throwable e - (log/errorf e "Unhandled exception: %s" (ex-message e)) + (log/errorf e "unhandled exception: %s" (ex-message e)) {:status 500 :body "internal server error"}))))))) (defn- create-router - [{:keys [session rpc google-auth gitlab-auth github-auth metrics ldap-auth svgparse assets] :as cfg}] + [{:keys [session rpc oauth metrics svgparse assets feedback] :as cfg}] (rr/router [["/metrics" {:get (:handler metrics)}] @@ -127,6 +125,9 @@ ["/dbg" ["/error-by-id/:id" {:get (:error-report-handler cfg)}]] + ["/webhooks" + ["/sns" {:post (:sns-webhook cfg)}]] + ["/api" {:middleware [[middleware/format-response-body] [middleware/params] [middleware/multipart-params] @@ -136,21 +137,18 @@ [middleware/cookies]]} ["/svg" {:post svgparse}] + ["/feedback" {:middleware [(:middleware session)] + :post feedback}] ["/oauth" - ["/google" {:post (:auth-handler google-auth)}] - ["/google/callback" {:get (:callback-handler google-auth)}] + ["/google" {:post (get-in oauth [:google :handler])}] + ["/google/callback" {:get (get-in oauth [:google :callback-handler])}] - ["/gitlab" {:post (:auth-handler gitlab-auth)}] - ["/gitlab/callback" {:get (:callback-handler gitlab-auth)}] + ["/gitlab" {:post (get-in oauth [:gitlab :handler])}] + ["/gitlab/callback" {:get (get-in oauth [:gitlab :callback-handler])}] - ["/github" {:post (:auth-handler github-auth)}] - ["/github/callback" {:get (:callback-handler github-auth)}]] - - ["/login" {:post #(auth/login-handler cfg %)}] - ["/logout" {:post #(auth/logout-handler cfg %)}] - - ["/login-ldap" {:post ldap-auth}] + ["/github" {:post (get-in oauth [:github :handler])}] + ["/github/callback" {:get (get-in oauth [:github :callback-handler])}]] ["/rpc" {:middleware [(:middleware session)]} ["/query/:type" {:get (:query-handler rpc)}] diff --git a/backend/src/app/http/auth.clj b/backend/src/app/http/auth.clj deleted file mode 100644 index 4dec0d3b4a..0000000000 --- a/backend/src/app/http/auth.clj +++ /dev/null @@ -1,31 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.http.auth - (:require - [app.http.session :as session])) - -(defn login-handler - [{:keys [session rpc] :as cfg} request] - (let [data (:params request) - uagent (get-in request [:headers "user-agent"]) - method (get-in rpc [:methods :mutation :login]) - profile (method data) - id (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - {:status 200 - :cookies (session/cookies session {:value id}) - :body profile})) - -(defn logout-handler - [{:keys [session] :as cfg} request] - (session/delete! cfg request) - {:status 204 - :cookies (session/cookies session {:value "" :max-age -1}) - :body ""}) diff --git a/backend/src/app/http/auth/github.clj b/backend/src/app/http/auth/github.clj deleted file mode 100644 index 83d38136c7..0000000000 --- a/backend/src/app/http/auth/github.clj +++ /dev/null @@ -1,171 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020-2021 UXBOX Labs SL - -(ns app.http.auth.github - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.config :as cfg] - [app.http.session :as session] - [app.util.http :as http] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [integrant.core :as ig] - [lambdaisland.uri :as u])) - -(def base-github-uri - (u/uri "https://github.com")) - -(def base-api-github-uri - (u/uri "https://api.github.com")) - -(def authorize-uri - (assoc base-github-uri :path "/login/oauth/authorize")) - -(def token-url - (assoc base-github-uri :path "/login/oauth/access_token")) - -(def user-info-url - (assoc base-api-github-uri :path "/user")) - -(def scope "user:email") - - -(defn- build-redirect-url - [cfg] - (let [public (u/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/github/callback")))) - -(defn- get-access-token - [cfg state code] - (let [params {:client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :code code - :state state - :redirect_uri (build-redirect-url cfg)} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded" - "accept" "application/json"} - :uri (str token-url) - :body (u/map->query-string params)} - res (http/send! req)] - - (when (not= 200 (:status res)) - (ex/raise :type :internal - :code :invalid-response-from-github - :context {:status (:status res) - :body (:body res)})) - (try - (let [data (json/read-str (:body res))] - (get data "access_token")) - (catch Throwable e - (log/error "unexpected error on parsing response body from github access token request" e) - nil)))) - -(defn- get-user-info - [token] - (let [req {:uri (str user-info-url) - :headers {"authorization" (str "token " token)} - :method :get} - res (http/send! req)] - - (when (not= 200 (:status res)) - (ex/raise :type :internal - :code :invalid-response-from-github - :context {:status (:status res) - :body (:body res)})) - - (try - (let [data (json/read-str (:body res))] - {:email (get data "email") - :fullname (get data "name")}) - (catch Throwable e - (log/error "unexpected error on parsing response body from github access token request" e) - nil)))) - -(defn auth - [{:keys [tokens] :as cfg} _request] - (let [state (tokens :generate - {:iss :github-oauth - :exp (dt/in-future "15m")}) - - params {:client_id (:client-id cfg/config) - :redirect_uri (build-redirect-url cfg) - :state state - :scope scope} - query (u/map->query-string params) - uri (-> authorize-uri - (assoc :query query))] - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn callback - [{:keys [tokens rpc session] :as cfg} request] - (let [state (get-in request [:params :state]) - _ (tokens :verify {:token state :iss :github-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg state) - (get-user-info))] - - (when-not info - (ex/raise :type :authentication - :code :unable-to-authenticate-with-github)) - - (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) - profile (method-fn {:email (:email info) - :fullname (:fullname info)}) - uagent (get-in request [:headers "user-agent"]) - - token (tokens :generate - {:iss :auth - :exp (dt/in-future "15m") - :profile-id (:id profile)}) - - uri (-> (u/uri (:public-uri cfg/config)) - (assoc :path "/#/auth/verify-token") - (assoc :query (u/map->query-string {:token token}))) - - sid (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - - {:status 302 - :headers {"location" (str uri)} - :cookies (session/cookies session/cookies {:value sid}) - :body ""}))) - -;; --- ENTRY POINT - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.auth/github [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::client-id - ::client-secret])) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.auth/github - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:auth-handler #(auth cfg %) - :callback-handler #(callback cfg %)} - {:auth-handler default-handler - :callback-handler default-handler})) - diff --git a/backend/src/app/http/auth/gitlab.clj b/backend/src/app/http/auth/gitlab.clj deleted file mode 100644 index 6aeb64e71c..0000000000 --- a/backend/src/app/http/auth/gitlab.clj +++ /dev/null @@ -1,179 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.http.auth.gitlab - (:require - [app.common.data :as d] - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.http.session :as session] - [app.util.http :as http] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [integrant.core :as ig] - [lambdaisland.uri :as uri])) - -(def scope "read_user") - -(defn- build-redirect-url - [cfg] - (let [public (uri/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/gitlab/callback")))) - - -(defn- build-oauth-uri - [cfg] - (let [base-uri (uri/uri (:base-uri cfg))] - (assoc base-uri :path "/oauth/authorize"))) - - -(defn- build-token-url - [cfg] - (let [base-uri (uri/uri (:base-uri cfg))] - (str (assoc base-uri :path "/oauth/token")))) - - -(defn- build-user-info-url - [cfg] - (let [base-uri (uri/uri (:base-uri cfg))] - (str (assoc base-uri :path "/api/v4/user")))) - -(defn- get-access-token - [cfg code] - (let [params {:client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :code code - :grant_type "authorization_code" - :redirect_uri (build-redirect-url cfg)} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded"} - :uri (build-token-url cfg) - :body (uri/map->query-string params)} - res (http/send! req)] - - (when (not= 200 (:status res)) - (ex/raise :type :internal - :code :invalid-response-from-gitlab - :context {:status (:status res) - :body (:body res)})) - - (try - (let [data (json/read-str (:body res))] - (get data "access_token")) - (catch Throwable e - (log/error "unexpected error on parsing response body from gitlab access token request" e) - nil)))) - - -(defn- get-user-info - [cfg token] - (let [req {:uri (build-user-info-url cfg) - :headers {"Authorization" (str "Bearer " token)} - :method :get} - res (http/send! req)] - - (when (not= 200 (:status res)) - (ex/raise :type :internal - :code :invalid-response-from-gitlab - :context {:status (:status res) - :body (:body res)})) - - (try - (let [data (json/read-str (:body res))] - ;; (clojure.pprint/pprint data) - {:email (get data "email") - :fullname (get data "name")}) - (catch Throwable e - (log/error "unexpected error on parsing response body from gitlab access token request" e) - nil)))) - -(defn auth - [{:keys [tokens] :as cfg} _request] - (let [token (tokens :generate {:iss :gitlab-oauth - :exp (dt/in-future "15m")}) - - params {:client_id (:client-id cfg) - :redirect_uri (build-redirect-url cfg) - :response_type "code" - :state token - :scope scope} - query (uri/map->query-string params) - uri (-> (build-oauth-uri cfg) - (assoc :query query))] - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn callback - [{:keys [tokens rpc session] :as cfg} request] - (let [token (get-in request [:params :state]) - _ (tokens :verify {:token token :iss :gitlab-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg) - (get-user-info cfg))] - - (when-not info - (ex/raise :type :authentication - :code :unable-to-authenticate-with-gitlab)) - - (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) - profile (method-fn {:email (:email info) - :fullname (:fullname info)}) - uagent (get-in request [:headers "user-agent"]) - - token (tokens :generate {:iss :auth - :exp (dt/in-future "15m") - :profile-id (:id profile)}) - - uri (-> (uri/uri (:public-uri cfg)) - (assoc :path "/#/auth/verify-token") - (assoc :query (uri/map->query-string {:token token}))) - - sid (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - {:status 302 - :headers {"location" (str uri)} - :cookies (session/cookies session {:value sid}) - :body ""}))) - - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::base-uri ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.auth/gitlab [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::base-uri - ::client-id - ::client-secret])) - - -(defmethod ig/prep-key :app.http.auth/gitlab - [_ cfg] - (d/merge {:base-uri "https://gitlab.com"} - (d/without-nils cfg))) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.auth/gitlab - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:auth-handler #(auth cfg %) - :callback-handler #(callback cfg %)} - {:auth-handler default-handler - :callback-handler default-handler})) diff --git a/backend/src/app/http/auth/google.clj b/backend/src/app/http/auth/google.clj deleted file mode 100644 index a615ddd80d..0000000000 --- a/backend/src/app/http/auth/google.clj +++ /dev/null @@ -1,149 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.http.auth.google - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.http.session :as session] - [app.util.http :as http] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [integrant.core :as ig] - [lambdaisland.uri :as uri])) - -(def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth") - -(def scope - (str "email profile " - "https://www.googleapis.com/auth/userinfo.email " - "https://www.googleapis.com/auth/userinfo.profile " - "openid")) - -(defn- build-redirect-url - [cfg] - (let [public (uri/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/google/callback")))) - -(defn- get-access-token - [cfg code] - (try - (let [params {:code code - :client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :redirect_uri (build-redirect-url cfg) - :grant_type "authorization_code"} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded"} - :uri "https://oauth2.googleapis.com/token" - :body (uri/map->query-string params)} - res (http/send! req)] - - (when (= 200 (:status res)) - (-> (json/read-str (:body res)) - (get "access_token")))) - - (catch Exception e - (log/error e "unexpected error on get-access-token") - nil))) - -(defn- get-user-info - [token] - (try - (let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo" - :headers {"Authorization" (str "Bearer " token)} - :method :get} - res (http/send! req)] - (when (= 200 (:status res)) - (let [data (json/read-str (:body res))] - {:email (get data "email") - :fullname (get data "name")}))) - (catch Exception e - (log/error e "unexpected exception on get-user-info") - nil))) - -(defn- auth - [{:keys [tokens] :as cfg} _req] - (let [token (tokens :generate {:iss :google-oauth :exp (dt/in-future "15m")}) - params {:scope scope - :access_type "offline" - :include_granted_scopes true - :state token - :response_type "code" - :redirect_uri (build-redirect-url cfg) - :client_id (:client-id cfg)} - query (uri/map->query-string params) - uri (-> (uri/uri base-goauth-uri) - (assoc :query query))] - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn- callback - [{:keys [tokens rpc session] :as cfg} request] - (try - (let [token (get-in request [:params :state]) - _ (tokens :verify {:token token :iss :google-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg) - (get-user-info)) - _ (when-not info - (ex/raise :type :internal - :code :unable-to-auth)) - method-fn (get-in rpc [:methods :mutation :login-or-register]) - profile (method-fn {:email (:email info) - :fullname (:fullname info)}) - uagent (get-in request [:headers "user-agent"]) - token (tokens :generate {:iss :auth - :exp (dt/in-future "15m") - :profile-id (:id profile)}) - uri (-> (uri/uri (:public-uri cfg)) - (assoc :path "/#/auth/verify-token") - (assoc :query (uri/map->query-string {:token token}))) - - sid (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - {:status 302 - :headers {"location" (str uri)} - :cookies (session/cookies session {:value sid}) - :body ""}) - (catch Exception _e - (let [uri (-> (uri/uri (:public-uri cfg)) - (assoc :path "/#/auth/login") - (assoc :query (uri/map->query-string {:error "unable-to-auth"})))] - {:status 302 - :headers {"location" (str uri)} - :body ""})))) - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.auth/google [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::client-id - ::client-secret])) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.auth/google - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:auth-handler #(auth cfg %) - :callback-handler #(callback cfg %)} - {:auth-handler default-handler - :callback-handler default-handler})) diff --git a/backend/src/app/http/auth/ldap.clj b/backend/src/app/http/auth/ldap.clj deleted file mode 100644 index 02a82fae97..0000000000 --- a/backend/src/app/http/auth/ldap.clj +++ /dev/null @@ -1,129 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.http.auth.ldap - (:require - [app.common.exceptions :as ex] - [app.config :as cfg] - [app.http.session :as session] - [clj-ldap.client :as client] - [clojure.set :as set] - [clojure.spec.alpha :as s] - [clojure.string ] - [clojure.tools.logging :as log] - [integrant.core :as ig])) - -(declare authenticate) -(declare create-connection) -(declare replace-several) - - -(s/def ::host ::cfg/ldap-auth-host) -(s/def ::port ::cfg/ldap-auth-port) -(s/def ::ssl ::cfg/ldap-auth-ssl) -(s/def ::starttls ::cfg/ldap-auth-starttls) -(s/def ::user-query ::cfg/ldap-auth-user-query) -(s/def ::base-dn ::cfg/ldap-auth-base-dn) -(s/def ::username-attribute ::cfg/ldap-auth-username-attribute) -(s/def ::email-attribute ::cfg/ldap-auth-email-attribute) -(s/def ::fullname-attribute ::cfg/ldap-auth-fullname-attribute) -(s/def ::avatar-attribute ::cfg/ldap-auth-avatar-attribute) - -(s/def ::rpc map?) -(s/def ::session map?) - -(defmethod ig/pre-init-spec :app.http.auth/ldap - [_] - (s/keys - :req-un [::rpc ::session] - :opt-un [::host - ::port - ::ssl - ::starttls - ::username-attribute - ::base-dn - ::username-attribute - ::email-attribute - ::fullname-attribute - ::avatar-attribute])) - -(defmethod ig/init-key :app.http.auth/ldap - [_ {:keys [session rpc] :as cfg}] - (let [conn (create-connection cfg)] - (with-meta - (fn [request] - (let [data (:body-params request)] - (when-some [info (authenticate (assoc cfg - :conn conn - :username (:email data) - :password (:password data)))] - (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) - profile (method-fn {:email (:email info) - :fullname (:fullname info)}) - uagent (get-in request [:headers "user-agent"]) - sid (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - {:status 200 - :cookies (session/cookies session {:value sid}) - :body profile})))) - {::conn conn}))) - -(defmethod ig/halt-key! ::client - [_ handler] - (let [{:keys [::conn]} (meta handler)] - (when (realized? conn) - (.close @conn)))) - -(defn- replace-several [s & {:as replacements}] - (reduce-kv clojure.string/replace s replacements)) - -(defn- create-connection - [cfg] - (let [params (merge {:host {:address (:host cfg) - :port (:port cfg)}} - (-> cfg - (select-keys [:ssl - :starttls - :ldap-bind-dn - :ldap-bind-password]) - (set/rename-keys {:ssl :ssl? - :starttls :startTLS? - :ldap-bind-dn :bind-dn - :ldap-bind-password :password})))] - (delay - (try - (client/connect params) - (catch Exception e - (log/errorf e "Cannot connect to LDAP %s:%s" - (:host cfg) (:port cfg))))))) - - -(defn- authenticate - [{:keys [conn username password] :as cfg}] - (when-some [conn (some-> conn deref)] - (let [user-search-query (replace-several (:user-query cfg) "$username" username) - user-attributes (-> cfg - (select-keys [:username-attribute - :email-attribute - :fullname-attribute - :avatar-attribute]) - vals)] - (when-some [user-entry (-> conn - (client/search (:base-dn cfg) - {:filter user-search-query - :sizelimit 1 - :attributes user-attributes}) - (first))] - (when-not (client/bind? conn (:dn user-entry) password) - (ex/raise :type :authentication - :code :wrong-credentials)) - (set/rename-keys user-entry {(keyword (:avatar-attribute cfg)) :photo - (keyword (:fullname-attribute cfg)) :fullname - (keyword (:email-attribute cfg)) :email}))))) - diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj new file mode 100644 index 0000000000..ea47131ce9 --- /dev/null +++ b/backend/src/app/http/awsns.clj @@ -0,0 +1,207 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 Andrey Antukh + +(ns app.http.awsns + "AWS SNS webhook handler for bounces." + (:require + [app.common.exceptions :as ex] + [app.db :as db] + [app.db.sql :as sql] + [app.util.http :as http] + [clojure.pprint :refer [pprint]] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [cuerdas.core :as str] + [integrant.core :as ig] + [jsonista.core :as j])) + +(declare parse-json) +(declare parse-notification) +(declare process-report) + +(defn- pprint-report + [message] + (binding [clojure.pprint/*print-right-margin* 120] + (with-out-str (pprint message)))) + +(defmethod ig/pre-init-spec ::handler [_] + (s/keys :req-un [::db/pool])) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [request] + (let [body (parse-json (slurp (:body request))) + mtype (get body "Type")] + (cond + (= mtype "SubscriptionConfirmation") + (let [surl (get body "SubscribeURL") + stopic (get body "TopicArn")] + (log/infof "subscription received (topic=%s, url=%s)" stopic surl) + (http/send! {:uri surl :method :post :timeout 10000})) + + (= mtype "Notification") + (when-let [message (parse-json (get body "Message"))] + ;; (log/infof "Received: %s" (pr-str message)) + (let [notification (parse-notification cfg message)] + (process-report cfg notification))) + + :else + (log/warn (str "unexpected data received\n" + (pprint-report body)))) + + {:status 200 :body ""}))) + +(defn- parse-bounce + [data] + {:type "bounce" + :kind (str/lower (get data "bounceType")) + :category (str/lower (get data "bounceSubType")) + :feedback-id (get data "feedbackId") + :timestamp (get data "timestamp") + :recipients (->> (get data "bouncedRecipients") + (mapv (fn [item] + {:email (str/lower (get item "emailAddress")) + :status (get item "status") + :action (get item "action") + :dcode (get item "diagnosticCode")})))}) + +(defn- parse-complaint + [data] + {:type "complaint" + :user-agent (get data "userAgent") + :kind (get data "complaintFeedbackType") + :category (get data "complaintSubType") + :timestamp (get data "arrivalDate") + :feedback-id (get data "feedbackId") + :recipients (->> (get data "complainedRecipients") + (mapv #(get % "emailAddress")) + (mapv str/lower))}) + +(defn- extract-headers + [mail] + (reduce (fn [acc item] + (let [key (get item "name") + val (get item "value")] + (assoc acc (str/lower key) val))) + {} + (get mail "headers"))) + +(defn- extract-identity + [{:keys [tokens] :as cfg} headers] + (let [tdata (get headers "x-penpot-data")] + (when-not (str/empty? tdata) + (let [result (tokens :verify {:token tdata :iss :profile-identity})] + (:profile-id result))))) + +(defn- parse-notification + [cfg message] + (let [type (get message "notificationType") + data (case type + "Bounce" (parse-bounce (get message "bounce")) + "Complaint" (parse-complaint (get message "complaint")) + {:type (keyword (str/lower type)) + :message message})] + (when data + (let [mail (get message "mail")] + (when-not mail + (ex/raise :type :internal + :code :incomplete-notification + :hint "no email data received, please enable full headers report")) + (let [headers (extract-headers mail) + mail {:destination (get mail "destination") + :source (get mail "source") + :timestamp (get mail "timestamp") + :subject (get-in mail ["commonHeaders" "subject"]) + :headers headers}] + (assoc data + :mail mail + :profile-id (extract-identity cfg headers))))))) + +(defn- parse-json + [v] + (ex/ignoring + (j/read-value v))) + +(defn- register-bounce-for-profile + [{:keys [pool]} {:keys [type kind profile-id] :as report}] + (when (= kind "permanent") + (db/with-atomic [conn pool] + (db/insert! conn :profile-complaint-report + {:profile-id profile-id + :type (name type) + :content (db/tjson report)}) + + ;; TODO: maybe also try to find profiles by mail and if exists + ;; register profile reports for them? + (doseq [recipient (:recipients report)] + (db/insert! conn :global-complaint-report + {:email (:email recipient) + :type (name type) + :content (db/tjson report)})) + + (let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))] + (when (some #(= (:email profile) (:email %)) (:recipients report)) + ;; If the report matches the profile email, this means that + ;; the report is for itself, can be caused when a user + ;; registers with an invalid email or the user email is + ;; permanently rejecting receiving the email. In this case we + ;; have no option to mark the user as muted (and in this case + ;; the profile will be also inactive. + (db/update! conn :profile + {:is-muted true} + {:id profile-id})))))) + +(defn- register-complaint-for-profile + [{:keys [pool]} {:keys [type profile-id] :as report}] + (db/with-atomic [conn pool] + (db/insert! conn :profile-complaint-report + {:profile-id profile-id + :type (name type) + :content (db/tjson report)}) + + ;; TODO: maybe also try to find profiles by email and if exists + ;; register profile reports for them? + (doseq [email (:recipients report)] + (db/insert! conn :global-complaint-report + {:email email + :type (name type) + :content (db/tjson report)})) + + (let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))] + (when (some #(= % (:email profile)) (:recipients report)) + ;; If the report matches the profile email, this means that + ;; the report is for itself, rare case but can happen; In this + ;; case just mark profile as muted (very rare case). + (db/update! conn :profile + {:is-muted true} + {:id profile-id}))))) + +(defn- process-report + [cfg {:keys [type profile-id] :as report}] + (log/trace (str "procesing report:\n" (pprint-report report))) + (cond + ;; In this case we receive a bounce/complaint notification without + ;; confirmed identity, we just emit a warning but do nothing about + ;; it because this is not a normal case. All notifications should + ;; come with profile identity. + (nil? profile-id) + (log/warn (str "a notification without identity recevied from AWS\n" + (pprint-report report))) + + (= "bounce" type) + (register-bounce-for-profile cfg report) + + (= "complaint" type) + (register-complaint-for-profile cfg report) + + :else + (log/warn (str "unrecognized report received from AWS\n" + (pprint-report report))))) + + diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index af688d4738..72fa34a652 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -11,7 +11,6 @@ "A errors handling for the http server." (:require [app.common.uuid :as uuid] - [app.config :as cfg] [app.util.log4j :refer [update-thread-context!]] [clojure.tools.logging :as log] [cuerdas.core :as str] @@ -30,16 +29,10 @@ :path (:uri request) :method (:request-method request) :params (:params request) - :version (:full cfg/version) - :host (:public-uri cfg/config) - :class (.getCanonicalName ^java.lang.Class (class error)) - :hint (ex-message error) :data edata} - (let [headers (:headers request)] {:user-agent (get headers "user-agent") :frontend-version (get headers "x-frontend-version" "unknown")}) - (when (and (map? edata) (:data edata)) {:explain (explain-error edata)})))) @@ -53,6 +46,11 @@ [err _] {:status 401 :body (ex-data err)}) + +(defmethod handle-exception :restriction + [err _] + {:status 400 :body (ex-data err)}) + (defmethod handle-exception :validation [err req] (let [header (get-in req [:headers "accept"]) @@ -75,7 +73,7 @@ (let [edata (ex-data error) cdata (get-error-context request error)] (update-thread-context! cdata) - (log/errorf error "Internal error: assertion (id: %s)" (str (:id cdata))) + (log/errorf error "internal error: assertion (id: %s)" (str (:id cdata))) {:status 500 :body {:type :server-error :data (-> edata @@ -90,7 +88,7 @@ [error request] (let [cdata (get-error-context request error)] (update-thread-context! cdata) - (log/errorf error "Internal error: %s (id: %s)" + (log/errorf error "internal error: %s (id: %s)" (ex-message error) (str (:id cdata))) {:status 500 diff --git a/backend/src/app/http/feedback.clj b/backend/src/app/http/feedback.clj new file mode 100644 index 0000000000..759b9c992e --- /dev/null +++ b/backend/src/app/http/feedback.clj @@ -0,0 +1,73 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2021 UXBOX Labs SL + +(ns app.http.feedback + "A general purpose feedback module." + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cfg] + [app.db :as db] + [app.emails :as emails] + [app.rpc.queries.profile :as profile] + [clojure.spec.alpha :as s] + [integrant.core :as ig])) + +(declare send-feedback) + +(defmethod ig/pre-init-spec ::handler [_] + (s/keys :req-un [::db/pool])) + +(defmethod ig/init-key ::handler + [_ {:keys [pool] :as scfg}] + (let [ftoken (cfg/get :feedback-token ::no-token) + enabled (cfg/get :feedback-enabled)] + (fn [{:keys [profile-id] :as request}] + (let [token (get-in request [:headers "x-feedback-token"]) + params (d/merge (:params request) + (:body-params request))] + + (when-not enabled + (ex/raise :type :validation + :code :feedback-disabled + :hint "feedback module is disabled")) + + (cond + (uuid? profile-id) + (let [profile (profile/retrieve-profile-data pool profile-id) + params (assoc params :from (:email profile))] + (when-not (:is-muted profile) + (send-feedback pool profile params))) + + (= token ftoken) + (send-feedback scfg nil params)) + + {:status 204 :body ""})))) + +(s/def ::content ::us/string) +(s/def ::from ::us/email) +(s/def ::subject ::us/string) + +(s/def ::feedback + (s/keys :req-un [::from ::subject ::content])) + +(defn send-feedback + [pool profile params] + (let [params (us/conform ::feedback params) + destination (cfg/get :feedback-destination) + reply-to (cfg/get :feedback-reply-to)] + (emails/send! pool emails/feedback + {:to destination + :profile profile + :reply-to (:from params) + :email (:from params) + :subject (:subject params) + :content (:content params)}) + nil)) diff --git a/backend/src/app/http/oauth/github.clj b/backend/src/app/http/oauth/github.clj new file mode 100644 index 0000000000..bfa4c3c148 --- /dev/null +++ b/backend/src/app/http/oauth/github.clj @@ -0,0 +1,159 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.http.oauth.github + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cfg] + [app.http.oauth.google :as gg] + [app.util.http :as http] + [app.util.time :as dt] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [integrant.core :as ig] + [lambdaisland.uri :as u])) + +(def base-github-uri + (u/uri "https://github.com")) + +(def base-api-github-uri + (u/uri "https://api.github.com")) + +(def authorize-uri + (assoc base-github-uri :path "/login/oauth/authorize")) + +(def token-url + (assoc base-github-uri :path "/login/oauth/access_token")) + +(def user-info-url + (assoc base-api-github-uri :path "/user")) + +(def scope "user:email") + +(defn- build-redirect-url + [cfg] + (let [public (u/uri (:public-uri cfg))] + (str (assoc public :path "/api/oauth/github/callback")))) + +(defn- get-access-token + [cfg state code] + (try + (let [params {:client_id (:client-id cfg) + :client_secret (:client-secret cfg) + :code code + :state state + :redirect_uri (build-redirect-url cfg)} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded" + "accept" "application/json"} + :uri (str token-url) + :timeout 6000 + :body (u/map->query-string params)} + res (http/send! req)] + + (when (= 200 (:status res)) + (-> (json/read-str (:body res)) + (get "access_token")))) + + (catch Exception e + (log/error e "unexpected error on get-access-token") + nil))) + +(defn- get-user-info + [_ token] + (try + (let [req {:uri (str user-info-url) + :headers {"authorization" (str "token " token)} + :timeout 6000 + :method :get} + res (http/send! req)] + (when (= 200 (:status res)) + (let [data (json/read-str (:body res))] + {:email (get data "email") + :backend "github" + :fullname (get data "name")}))) + (catch Exception e + (log/error e "unexpected exception on get-user-info") + nil))) + +(defn- retrieve-info + [{:keys [tokens] :as cfg} request] + (let [token (get-in request [:params :state]) + state (tokens :verify {:token token :iss :github-oauth}) + info (some->> (get-in request [:params :code]) + (get-access-token cfg state) + (get-user-info cfg))] + (when-not info + (ex/raise :type :internal + :code :unable-to-auth)) + + (cond-> info + (some? (:invitation-token state)) + (assoc :invitation-token (:invitation-token state))))) + +(defn auth-handler + [{:keys [tokens] :as cfg} request] + (let [invitation (get-in request [:params :invitation-token]) + state (tokens :generate {:iss :github-oauth + :invitation-token invitation + :exp (dt/in-future "15m")}) + params {:client_id (:client-id cfg/config) + :redirect_uri (build-redirect-url cfg) + :state state + :scope scope} + query (u/map->query-string params) + uri (-> authorize-uri + (assoc :query query))] + {:status 200 + :body {:redirect-uri (str uri)}})) + +(defn- callback-handler + [{:keys [session] :as cfg} request] + (try + (let [info (retrieve-info cfg request) + profile (gg/register-profile cfg info) + uri (gg/generate-redirect-uri cfg profile) + sxf ((:create session) (:id profile))] + (->> (gg/redirect-response uri) + (sxf request))) + (catch Exception _e + (-> (gg/generate-error-redirect-uri cfg) + (gg/redirect-response))))) + + +;; --- ENTRY POINT + +(s/def ::client-id ::us/not-empty-string) +(s/def ::client-secret ::us/not-empty-string) +(s/def ::public-uri ::us/not-empty-string) +(s/def ::session map?) +(s/def ::tokens fn?) + +(defmethod ig/pre-init-spec :app.http.oauth/github [_] + (s/keys :req-un [::public-uri + ::session + ::tokens] + :opt-un [::client-id + ::client-secret])) + +(defn- default-handler + [_] + (ex/raise :type :not-found)) + +(defmethod ig/init-key :app.http.oauth/github + [_ cfg] + (if (and (:client-id cfg) + (:client-secret cfg)) + {:handler #(auth-handler cfg %) + :callback-handler #(callback-handler cfg %)} + {:handler default-handler + :callback-handler default-handler})) + diff --git a/backend/src/app/http/oauth/gitlab.clj b/backend/src/app/http/oauth/gitlab.clj new file mode 100644 index 0000000000..4a8b0a7c2c --- /dev/null +++ b/backend/src/app/http/oauth/gitlab.clj @@ -0,0 +1,167 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.http.oauth.gitlab + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.http.oauth.google :as gg] + [app.util.http :as http] + [app.util.time :as dt] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [integrant.core :as ig] + [lambdaisland.uri :as u])) + +(def scope "read_user") + +(defn- build-redirect-url + [cfg] + (let [public (u/uri (:public-uri cfg))] + (str (assoc public :path "/api/oauth/gitlab/callback")))) + +(defn- build-oauth-uri + [cfg] + (let [base-uri (u/uri (:base-uri cfg))] + (assoc base-uri :path "/oauth/authorize"))) + +(defn- build-token-url + [cfg] + (let [base-uri (u/uri (:base-uri cfg))] + (str (assoc base-uri :path "/oauth/token")))) + +(defn- build-user-info-url + [cfg] + (let [base-uri (u/uri (:base-uri cfg))] + (str (assoc base-uri :path "/api/v4/user")))) + +(defn- get-access-token + [cfg code] + (try + (let [params {:client_id (:client-id cfg) + :client_secret (:client-secret cfg) + :code code + :grant_type "authorization_code" + :redirect_uri (build-redirect-url cfg)} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :uri (build-token-url cfg) + :body (u/map->query-string params)} + res (http/send! req)] + + (when (= 200 (:status res)) + (-> (json/read-str (:body res)) + (get "access_token")))) + + (catch Exception e + (log/error e "unexpected error on get-access-token") + nil))) + +(defn- get-user-info + [cfg token] + (try + (let [req {:uri (build-user-info-url cfg) + :headers {"Authorization" (str "Bearer " token)} + :timeout 6000 + :method :get} + res (http/send! req)] + + (when (= 200 (:status res)) + (let [data (json/read-str (:body res))] + {:email (get data "email") + :backend "gitlab" + :fullname (get data "name")}))) + + (catch Exception e + (log/error e "unexpected exception on get-user-info") + nil))) + + +(defn- retrieve-info + [{:keys [tokens] :as cfg} request] + (let [token (get-in request [:params :state]) + state (tokens :verify {:token token :iss :gitlab-oauth}) + info (some->> (get-in request [:params :code]) + (get-access-token cfg) + (get-user-info cfg))] + (when-not info + (ex/raise :type :internal + :code :unable-to-auth)) + + (cond-> info + (some? (:invitation-token state)) + (assoc :invitation-token (:invitation-token state))))) + + +(defn- auth-handler + [{:keys [tokens] :as cfg} request] + (let [invitation (get-in request [:params :invitation-token]) + state (tokens :generate + {:iss :gitlab-oauth + :invitation-token invitation + :exp (dt/in-future "15m")}) + + params {:client_id (:client-id cfg) + :redirect_uri (build-redirect-url cfg) + :response_type "code" + :state state + :scope scope} + query (u/map->query-string params) + uri (-> (build-oauth-uri cfg) + (assoc :query query))] + {:status 200 + :body {:redirect-uri (str uri)}})) + +(defn- callback-handler + [{:keys [session] :as cfg} request] + (try + (let [info (retrieve-info cfg request) + profile (gg/register-profile cfg info) + uri (gg/generate-redirect-uri cfg profile) + sxf ((:create session) (:id profile))] + (->> (gg/redirect-response uri) + (sxf request))) + (catch Exception _e + (-> (gg/generate-error-redirect-uri cfg) + (gg/redirect-response))))) + +(s/def ::client-id ::us/not-empty-string) +(s/def ::client-secret ::us/not-empty-string) +(s/def ::base-uri ::us/not-empty-string) +(s/def ::public-uri ::us/not-empty-string) +(s/def ::session map?) +(s/def ::tokens fn?) + +(defmethod ig/pre-init-spec :app.http.oauth/gitlab [_] + (s/keys :req-un [::public-uri + ::session + ::tokens] + :opt-un [::base-uri + ::client-id + ::client-secret])) + +(defmethod ig/prep-key :app.http.oauth/gitlab + [_ cfg] + (d/merge {:base-uri "https://gitlab.com"} + (d/without-nils cfg))) + +(defn- default-handler + [_] + (ex/raise :type :not-found)) + +(defmethod ig/init-key :app.http.oauth/gitlab + [_ cfg] + (if (and (:client-id cfg) + (:client-secret cfg)) + {:handler #(auth-handler cfg %) + :callback-handler #(callback-handler cfg %)} + {:handler default-handler + :callback-handler default-handler})) diff --git a/backend/src/app/http/oauth/google.clj b/backend/src/app/http/oauth/google.clj new file mode 100644 index 0000000000..ce92116892 --- /dev/null +++ b/backend/src/app/http/oauth/google.clj @@ -0,0 +1,182 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.http.oauth.google + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.util.http :as http] + [app.util.time :as dt] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [integrant.core :as ig] + [lambdaisland.uri :as u])) + +(def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth") + +(def scope + (str "email profile " + "https://www.googleapis.com/auth/userinfo.email " + "https://www.googleapis.com/auth/userinfo.profile " + "openid")) + +(defn- build-redirect-url + [cfg] + (let [public (u/uri (:public-uri cfg))] + (str (assoc public :path "/api/oauth/google/callback")))) + +(defn- get-access-token + [cfg code] + (try + (let [params {:code code + :client_id (:client-id cfg) + :client_secret (:client-secret cfg) + :redirect_uri (build-redirect-url cfg) + :grant_type "authorization_code"} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :uri "https://oauth2.googleapis.com/token" + :timeout 6000 + :body (u/map->query-string params)} + res (http/send! req)] + + (when (= 200 (:status res)) + (-> (json/read-str (:body res)) + (get "access_token")))) + (catch Exception e + (log/error e "unexpected error on get-access-token") + nil))) + +(defn- get-user-info + [_ token] + (try + (let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo" + :headers {"Authorization" (str "Bearer " token)} + :timeout 6000 + :method :get} + res (http/send! req)] + + (when (= 200 (:status res)) + (let [data (json/read-str (:body res))] + {:email (get data "email") + :backend "google" + :fullname (get data "name")}))) + (catch Exception e + (log/error e "unexpected exception on get-user-info") + nil))) + +(defn- retrieve-info + [{:keys [tokens] :as cfg} request] + (let [token (get-in request [:params :state]) + state (tokens :verify {:token token :iss :google-oauth}) + info (some->> (get-in request [:params :code]) + (get-access-token cfg) + (get-user-info cfg))] + + + (when-not info + (ex/raise :type :internal + :code :unable-to-auth)) + + (cond-> info + (some? (:invitation-token state)) + (assoc :invitation-token (:invitation-token state))))) + +(defn register-profile + [{:keys [rpc] :as cfg} info] + (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) + profile (method-fn {:email (:email info) + :backend (:backend info) + :fullname (:fullname info)})] + (cond-> profile + (some? (:invitation-token info)) + (assoc :invitation-token (:invitation-token info))))) + +(defn generate-redirect-uri + [{:keys [tokens] :as cfg} profile] + (let [token (or (:invitation-token profile) + (tokens :generate {:iss :auth + :exp (dt/in-future "15m") + :profile-id (:id profile)}))] + (-> (u/uri (:public-uri cfg)) + (assoc :path "/#/auth/verify-token") + (assoc :query (u/map->query-string {:token token}))))) + +(defn generate-error-redirect-uri + [cfg] + (-> (u/uri (:public-uri cfg)) + (assoc :path "/#/auth/login") + (assoc :query (u/map->query-string {:error "unable-to-auth"})))) + +(defn redirect-response + [uri] + {:status 302 + :headers {"location" (str uri)} + :body ""}) + +(defn- auth-handler + [{:keys [tokens] :as cfg} request] + (let [invitation (get-in request [:params :invitation-token]) + state (tokens :generate + {:iss :google-oauth + :invitation-token invitation + :exp (dt/in-future "15m")}) + params {:scope scope + :access_type "offline" + :include_granted_scopes true + :state state + :response_type "code" + :redirect_uri (build-redirect-url cfg) + :client_id (:client-id cfg)} + query (u/map->query-string params) + uri (-> (u/uri base-goauth-uri) + (assoc :query query))] + + {:status 200 + :body {:redirect-uri (str uri)}})) + +(defn- callback-handler + [{:keys [session] :as cfg} request] + (try + (let [info (retrieve-info cfg request) + profile (register-profile cfg info) + uri (generate-redirect-uri cfg profile) + sxf ((:create session) (:id profile))] + (->> (redirect-response uri) + (sxf request))) + (catch Exception _e + (-> (generate-error-redirect-uri cfg) + (redirect-response))))) + +(s/def ::client-id ::us/not-empty-string) +(s/def ::client-secret ::us/not-empty-string) +(s/def ::public-uri ::us/not-empty-string) +(s/def ::session map?) +(s/def ::tokens fn?) + +(defmethod ig/pre-init-spec :app.http.oauth/google [_] + (s/keys :req-un [::public-uri + ::session + ::tokens] + :opt-un [::client-id + ::client-secret])) + +(defn- default-handler + [_] + (ex/raise :type :not-found)) + +(defmethod ig/init-key :app.http.oauth/google + [_ cfg] + (if (and (:client-id cfg) + (:client-secret cfg)) + {:handler #(auth-handler cfg %) + :callback-handler #(callback-handler cfg %)} + {:handler default-handler + :callback-handler default-handler})) diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 98ccb9ee0a..0c047b310b 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -9,21 +9,32 @@ (ns app.http.session (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.config :as cfg] [app.db :as db] + [app.metrics :as mtx] + [app.util.async :as aa] [app.util.log4j :refer [update-thread-context!]] + [app.util.time :as dt] + [app.worker :as wrk] [buddy.core.codecs :as bc] [buddy.core.nonce :as bn] + [clojure.core.async :as a] [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] [integrant.core :as ig])) -(defn next-session-id +;; --- IMPL + +(defn- next-session-id ([] (next-session-id 96)) ([n] (-> (bn/random-nonce n) (bc/bytes->b64u) (bc/bytes->str)))) -(defn create! +(defn- create [{:keys [conn] :as cfg} {:keys [profile-id user-agent]}] (let [id (next-session-id)] (db/insert! conn :http-session {:id id @@ -31,44 +42,177 @@ :user-agent user-agent}) id)) -(defn delete! - [{:keys [conn cookie-name] :as cfg} request] - (when-let [token (get-in request [:cookies cookie-name :value])] +(defn- delete + [{:keys [conn cookie-name] :as cfg} {:keys [cookies] :as request}] + (when-let [token (get-in cookies [cookie-name :value])] (db/delete! conn :http-session {:id token})) nil) -(defn retrieve +(defn- retrieve [{:keys [conn] :as cfg} token] (when token - (-> (db/exec-one! conn ["select profile_id from http_session where id = ?" token]) - (:profile-id)))) + (db/exec-one! conn ["select id, profile_id from http_session where id = ?" token]))) -(defn retrieve-from-request - [{:keys [cookie-name] :as cfg} request] - (->> (get-in request [:cookies cookie-name :value]) +(defn- retrieve-from-request + [{:keys [cookie-name] :as cfg} {:keys [cookies] :as request}] + (->> (get-in cookies [cookie-name :value]) (retrieve cfg))) -(defn cookies +(defn- cookies [{:keys [cookie-name] :as cfg} vals] {cookie-name (merge vals {:path "/" :http-only true})}) -(defn middleware +(defn- middleware [cfg handler] (fn [request] - (if-let [profile-id (retrieve-from-request cfg request)] - (do + (if-let [{:keys [id profile-id] :as session} (retrieve-from-request cfg request)] + (let [ech (::events-ch cfg)] + (a/>!! ech id) (update-thread-context! {:profile-id profile-id}) (handler (assoc request :profile-id profile-id))) (handler request)))) +;; --- STATE INIT: SESSION + +(s/def ::cookie-name ::cfg/http-session-cookie-name) + (defmethod ig/pre-init-spec ::session [_] - (s/keys :req-un [::db/pool])) + (s/keys :req-un [::db/pool] + :opt-un [::cookie-name])) (defmethod ig/prep-key ::session [_ cfg] - (merge {:cookie-name "auth-token"} cfg)) + (merge {:cookie-name "auth-token" + :buffer-size 64} + (d/without-nils cfg))) (defmethod ig/init-key ::session [_ {:keys [pool] :as cfg}] - (let [cfg (assoc cfg :conn pool)] - (merge cfg {:middleware #(middleware cfg %)}))) + (let [events (a/chan (a/dropping-buffer (:buffer-size cfg))) + cfg (assoc cfg + :conn pool + ::events-ch events)] + (-> cfg + (assoc :middleware #(middleware cfg %)) + (assoc :create (fn [profile-id] + (fn [request response] + (let [uagent (get-in request [:headers "user-agent"]) + value (create cfg {:profile-id profile-id :user-agent uagent})] + (assoc response :cookies (cookies cfg {:value value})))))) + (assoc :delete (fn [request response] + (delete cfg request) + (assoc response + :status 204 + :body "" + :cookies (cookies cfg {:value "" :max-age -1}))))))) + +(defmethod ig/halt-key! ::session + [_ data] + (a/close! (::events-ch data))) + +;; --- STATE INIT: SESSION UPDATER + +(declare batch-events) +(declare update-sessions) + +(s/def ::session map?) +(s/def ::max-batch-age ::cfg/http-session-updater-batch-max-age) +(s/def ::max-batch-size ::cfg/http-session-updater-batch-max-size) + +(defmethod ig/pre-init-spec ::updater [_] + (s/keys :req-un [::db/pool ::wrk/executor ::mtx/metrics ::session] + :opt-un [::max-batch-age + ::max-batch-size])) + +(defmethod ig/prep-key ::updater + [_ cfg] + (merge {:max-batch-age (dt/duration {:minutes 5}) + :max-batch-size 200} + (d/without-nils cfg))) + +(defmethod ig/init-key ::updater + [_ {:keys [session metrics] :as cfg}] + (log/infof "initialize session updater (max-batch-age=%s, max-batch-size=%s)" + (str (:max-batch-age cfg)) + (str (:max-batch-size cfg))) + (let [input (batch-events cfg (::events-ch session)) + mcnt (mtx/create + {:name "http_session_updater_count" + :help "A counter of session update batch events." + :registry (:registry metrics) + :type :counter})] + (a/go-loop [] + (when-let [[reason batch] (a/! out [:timeout buf]) + (recur (timeout-chan cfg) #{}))) + + (nil? val) + (a/close! out) + + (identical? port in) + (let [buf (conj buf val)] + (if (>= (count buf) (:max-batch-size cfg)) + (do + (a/>! out [:size buf]) + (recur (timeout-chan cfg) #{})) + (recur tch buf)))))) + out)) + +(defn- update-sessions + [{:keys [pool executor]} ids] + (aa/with-thread executor + (db/exec-one! pool ["update http_session set updated_at=now() where id = ANY(?)" + (into-array String ids)]) + (count ids))) + +;; --- STATE INIT: SESSION GC + +(declare sql:delete-expired) + +(s/def ::max-age ::dt/duration) + +(defmethod ig/pre-init-spec ::gc-task [_] + (s/keys :req-un [::db/pool] + :opt-un [::max-age])) + +(defmethod ig/prep-key ::gc-task + [_ cfg] + (merge {:max-age (dt/duration {:days 2})} + (d/without-nils cfg))) + +(defmethod ig/init-key ::gc-task + [_ {:keys [pool max-age] :as cfg}] + (fn [_] + (db/with-atomic [conn pool] + (let [interval (db/interval max-age) + result (db/exec-one! conn [sql:delete-expired interval]) + result (:next.jdbc/update-count result)] + (log/debugf "gc-task: removed %s rows from http-session table" result) + result)))) + +(def ^:private + sql:delete-expired + "delete from http_session + where updated_at < now() - ?::interval") diff --git a/backend/src/app/loggers/loki.clj b/backend/src/app/loggers/loki.clj new file mode 100644 index 0000000000..2514d57253 --- /dev/null +++ b/backend/src/app/loggers/loki.clj @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.loggers.loki + "A Loki integration." + (:require + [app.common.spec :as us] + [app.config :as cfg] + [app.util.async :as aa] + [app.util.http :as http] + [app.util.json :as json] + [app.worker :as wrk] + [clojure.core.async :as a] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [integrant.core :as ig])) + +(declare handle-event) + +(s/def ::uri ::us/string) +(s/def ::receiver fn?) + +(defmethod ig/pre-init-spec ::reporter [_] + (s/keys :req-un [::wrk/executor ::receiver] + :opt-un [::uri])) + +(defmethod ig/init-key ::reporter + [_ {:keys [receiver uri] :as cfg}] + (when uri + (log/info "intializing loki reporter") + (let [output (a/chan (a/sliding-buffer 1024))] + (receiver :sub output) + (a/go-loop [] + (let [msg (a/ +;; Copyright (c) 2020-2021 UXBOX Labs SL -(ns app.error-reporter +(ns app.loggers.mattermost "A mattermost integration for error reporting." (:require [app.common.exceptions :as ex] @@ -15,6 +15,7 @@ [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] + [app.util.async :as aa] [app.util.http :as http] [app.util.json :as json] [app.util.template :as tmpl] @@ -24,11 +25,7 @@ [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [cuerdas.core :as str] - [integrant.core :as ig] - [promesa.exec :as px]) - (:import - org.apache.logging.log4j.core.LogEvent - org.apache.logging.log4j.util.ReadOnlyStringMap)) + [integrant.core :as ig])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Error Listener @@ -37,76 +34,51 @@ (declare handle-event) (defonce enabled-mattermost (atom true)) -(defonce queue (a/chan (a/sliding-buffer 64))) -(defonce queue-fn (fn [event] (a/>!! queue event))) (s/def ::uri ::us/string) (defmethod ig/pre-init-spec ::reporter [_] - (s/keys :req-un [::wrk/executor ::db/pool] + (s/keys :req-un [::wrk/executor ::db/pool ::receiver] :opt-un [::uri])) (defmethod ig/init-key ::reporter - [_ {:keys [executor] :as cfg}] - (log/info "Intializing error reporter.") - (let [close-ch (a/chan 1)] + [_ {:keys [receiver] :as cfg}] + (log/info "intializing mattermost error reporter") + (let [output (a/chan (a/sliding-buffer 128) + (filter #(= (:level %) "error")))] + (receiver :sub output) (a/go-loop [] - (let [[val port] (a/alts! [close-ch queue])] - (cond - (= port close-ch) - (log/info "Stoping error reporting loop.") - - (nil? val) - (log/info "Stoping error reporting loop.") - - :else + (let [msg (a/ (parse-context event) + (merge (dissoc event :context)) + (assoc :tenant (cfg/get :tenant)) + (assoc :host (cfg/get :host)) + (assoc :public-uri (cfg/get :public-uri)) + (assoc :version (:full cfg/version)))) + (defn handle-event - [cfg event] - (try - (let [cdata (get-context-data event)] - (when (and (:uri cfg) @enabled-mattermost) - (send-mattermost-notification! cfg cdata)) - (persist-on-database! cfg cdata)) - (catch Exception e - (log/warnf e "Unexpected exception on error reporter.")))) + [{:keys [executor] :as cfg} event] + (aa/with-thread executor + (try + (let [cdata (parse-event event)] + (when (and (:uri cfg) @enabled-mattermost) + (send-mattermost-notification! cfg cdata)) + (persist-on-database! cfg cdata)) + (catch Exception e + (log/error e "unexpected exception on error reporter"))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Http Handler diff --git a/backend/src/app/loggers/zmq.clj b/backend/src/app/loggers/zmq.clj new file mode 100644 index 0000000000..9f1b8287c1 --- /dev/null +++ b/backend/src/app/loggers/zmq.clj @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.loggers.zmq + "A generic ZMQ listener." + (:require + [app.common.data :as d] + [app.common.spec :as us] + [app.util.json :as json] + [app.util.time :as dt] + [clojure.core.async :as a] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [cuerdas.core :as str] + [integrant.core :as ig]) + (:import + org.zeromq.SocketType + org.zeromq.ZMQ$Socket + org.zeromq.ZContext)) + +(declare prepare) +(declare start-rcv-loop) + +(s/def ::endpoint ::us/string) + +(defmethod ig/pre-init-spec ::receiver [_] + (s/keys :opt-un [::endpoint])) + +(defmethod ig/init-key ::receiver + [_ {:keys [endpoint] :as cfg}] + (log/infof "intializing ZMQ receiver on '%s'" endpoint) + (let [buffer (a/chan 1) + output (a/chan 1 (comp (filter map?) + (map prepare))) + mult (a/mult output)] + (when endpoint + (a/thread (start-rcv-loop {:out buffer :endpoint endpoint}))) + (a/pipe buffer output) + (with-meta + (fn [cmd ch] + (case cmd + :sub (a/tap mult ch) + :unsub (a/untap mult ch)) + ch) + {::output output + ::buffer buffer + ::mult mult}))) + +(defmethod ig/halt-key! ::receiver + [_ f] + (a/close! (::buffer (meta f)))) + +(defn- start-rcv-loop + ([] (start-rcv-loop nil)) + ([{:keys [out endpoint] :or {endpoint "tcp://localhost:5556"}}] + (let [out (or out (a/chan 1)) + zctx (ZContext.) + socket (.. zctx (createSocket SocketType/SUB))] + (.. socket (connect ^String endpoint)) + (.. socket (subscribe "")) + (.. socket (setReceiveTimeOut 5000)) + (loop [] + (let [msg (.recv ^ZMQ$Socket socket) + msg (json/decode msg) + msg (if (nil? msg) :empty msg)] + (if (a/>!! out msg) + (recur) + (do + (.close ^java.lang.AutoCloseable socket) + (.close ^java.lang.AutoCloseable zctx)))))))) + +(defn- prepare + [event] + (d/merge + {:logger (:loggerName event) + :level (str/lower (:level event)) + :thread (:thread event) + :created-at (dt/instant (:timeMillis event)) + :message (:message event)} + (when-let [ctx (:contextMap event)] + {:context ctx}) + (when-let [thrown (:thrown event)] + {:error + {:class (:name thrown) + :message (:message thrown) + :trace (:extendedStackTrace thrown)}}))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index e26d84a929..024310bd27 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -37,11 +37,19 @@ :max-pool-size 20} :app.metrics/metrics - {} + {:definitions + {:profile-register + {:name "actions_profile_register_count" + :help "A global counter of user registrations." + :type :counter} + :profile-activation + {:name "actions_profile_activation_count" + :help "A global counter of profile activations" + :type :counter}}} :app.migrations/all - {:main (ig/ref :app.migrations/migrations) - :telemetry (ig/ref :app.telemetry/migrations)} + {:main (ig/ref :app.migrations/migrations) + :telemetry (ig/ref :app.telemetry/migrations)} :app.migrations/migrations {} @@ -49,11 +57,11 @@ :app.telemetry/migrations {} - :app.redis/redis + :app.msgbus/msgbus {:uri (:redis-uri config)} :app.tokens/tokens - {:sprops (ig/ref :app.sprops/props)} + {:sprops (ig/ref :app.setup/props)} :app.storage/gc-deleted-task {:pool (ig/ref :app.db/pool) @@ -69,7 +77,23 @@ :app.http.session/session {:pool (ig/ref :app.db/pool) - :cookie-name "auth-token"} + :cookie-name (:http-session-cookie-name config)} + + :app.http.session/gc-task + {:pool (ig/ref :app.db/pool) + :max-age (:http-session-idle-max-age config)} + + :app.http.session/updater + {:pool (ig/ref :app.db/pool) + :metrics (ig/ref :app.metrics/metrics) + :executor (ig/ref :app.worker/executor) + :session (ig/ref :app.http.session/session) + :max-batch-age (:http-session-updater-batch-max-age config) + :max-batch-size (:http-session-updater-batch-max-size config)} + + :app.http.awsns/handler + {:tokens (ig/ref :app.tokens/tokens) + :pool (ig/ref :app.db/pool)} :app.http/server {:port (:http-server-port config) @@ -83,14 +107,13 @@ :tokens (ig/ref :app.tokens/tokens) :public-uri (:public-uri config) :metrics (ig/ref :app.metrics/metrics) - :google-auth (ig/ref :app.http.auth/google) - :gitlab-auth (ig/ref :app.http.auth/gitlab) - :github-auth (ig/ref :app.http.auth/github) - :ldap-auth (ig/ref :app.http.auth/ldap) + :oauth (ig/ref :app.http.oauth/all) :assets (ig/ref :app.http.assets/handlers) :svgparse (ig/ref :app.svgparse/handler) :storage (ig/ref :app.storage/storage) - :error-report-handler (ig/ref :app.error-reporter/handler)} + :sns-webhook (ig/ref :app.http.awsns/handler) + :feedback (ig/ref :app.http.feedback/handler) + :error-report-handler (ig/ref :app.loggers.mattermost/handler)} :app.http.assets/handlers {:metrics (ig/ref :app.metrics/metrics) @@ -99,7 +122,15 @@ :cache-max-age (dt/duration {:hours 24}) :signature-max-age (dt/duration {:hours 24 :minutes 5})} - :app.http.auth/google + :app.http.feedback/handler + {:pool (ig/ref :app.db/pool)} + + :app.http.oauth/all + {:google (ig/ref :app.http.oauth/google) + :gitlab (ig/ref :app.http.oauth/gitlab) + :github (ig/ref :app.http.oauth/github)} + + :app.http.oauth/google {:rpc (ig/ref :app.rpc/rpc) :session (ig/ref :app.http.session/session) :tokens (ig/ref :app.tokens/tokens) @@ -107,7 +138,7 @@ :client-id (:google-client-id config) :client-secret (:google-client-secret config)} - :app.http.auth/github + :app.http.oauth/github {:rpc (ig/ref :app.rpc/rpc) :session (ig/ref :app.http.session/session) :tokens (ig/ref :app.tokens/tokens) @@ -115,7 +146,7 @@ :client-id (:github-client-id config) :client-secret (:github-client-secret config)} - :app.http.auth/gitlab + :app.http.oauth/gitlab {:rpc (ig/ref :app.rpc/rpc) :session (ig/ref :app.http.session/session) :tokens (ig/ref :app.tokens/tokens) @@ -124,20 +155,6 @@ :client-id (:gitlab-client-id config) :client-secret (:gitlab-client-secret config)} - :app.http.auth/ldap - {:host (:ldap-auth-host config) - :port (:ldap-auth-port config) - :ssl (:ldap-auth-ssl config) - :starttls (:ldap-auth-starttls config) - :user-query (:ldap-auth-user-query config) - :username-attribute (:ldap-auth-username-attribute config) - :email-attribute (:ldap-auth-email-attribute config) - :fullname-attribute (:ldap-auth-fullname-attribute config) - :avatar-attribute (:ldap-auth-avatar-attribute config) - :base-dn (:ldap-auth-base-dn config) - :session (ig/ref :app.http.session/session) - :rpc (ig/ref :app.rpc/rpc)} - :app.svgparse/svgc {:metrics (ig/ref :app.metrics/metrics)} @@ -165,15 +182,16 @@ :tokens (ig/ref :app.tokens/tokens) :metrics (ig/ref :app.metrics/metrics) :storage (ig/ref :app.storage/storage) - :redis (ig/ref :app.redis/redis) + :msgbus (ig/ref :app.msgbus/msgbus) :rlimits (ig/ref :app.rlimits/all) :svgc (ig/ref :app.svgparse/svgc)} :app.notifications/handler - {:redis (ig/ref :app.redis/redis) - :pool (ig/ref :app.db/pool) - :session (ig/ref :app.http.session/session) - :metrics (ig/ref :app.metrics/metrics)} + {:msgbus (ig/ref :app.msgbus/msgbus) + :pool (ig/ref :app.db/pool) + :session (ig/ref :app.http.session/session) + :metrics (ig/ref :app.metrics/metrics) + :executor (ig/ref :app.worker/executor)} :app.worker/executor {:name "worker"} @@ -181,46 +199,61 @@ :app.worker/worker {:executor (ig/ref :app.worker/executor) :pool (ig/ref :app.db/pool) - :tasks (ig/ref :app.tasks/all)} + :tasks (ig/ref :app.tasks/registry)} :app.worker/scheduler {:executor (ig/ref :app.worker/executor) :pool (ig/ref :app.db/pool) + :tasks (ig/ref :app.tasks/registry) :schedule [{:id "file-media-gc" :cron #app/cron "0 0 0 */1 * ? *" ;; daily - :fn (ig/ref :app.tasks.file-media-gc/handler)} + :task :file-media-gc} {:id "file-xlog-gc" :cron #app/cron "0 0 */1 * * ?" ;; hourly - :fn (ig/ref :app.tasks.file-xlog-gc/handler)} + :task :file-xlog-gc} {:id "storage-deleted-gc" :cron #app/cron "0 0 1 */1 * ?" ;; daily (1 hour shift) - :fn (ig/ref :app.storage/gc-deleted-task)} + :task :storage-deleted-gc} {:id "storage-touched-gc" :cron #app/cron "0 0 2 */1 * ?" ;; daily (2 hour shift) - :fn (ig/ref :app.storage/gc-touched-task)} + :task :storage-touched-gc} + + {:id "session-gc" + :cron #app/cron "0 0 3 */1 * ?" ;; daily (3 hour shift) + :task :session-gc} {:id "storage-recheck" :cron #app/cron "0 0 */1 * * ?" ;; hourly - :fn (ig/ref :app.storage/recheck-task)} + :task :storage-recheck} {:id "tasks-gc" :cron #app/cron "0 0 0 */1 * ?" ;; daily - :fn (ig/ref :app.tasks.tasks-gc/handler)} + :task :tasks-gc} (when (:telemetry-enabled config) {:id "telemetry" :cron #app/cron "0 0 */6 * * ?" ;; every 6h :uri (:telemetry-uri config) - :fn (ig/ref :app.tasks.telemetry/handler)})]} + :task :telemetry})]} - :app.tasks/all - {"sendmail" (ig/ref :app.tasks.sendmail/handler) - "delete-object" (ig/ref :app.tasks.delete-object/handler) - "delete-profile" (ig/ref :app.tasks.delete-profile/handler)} + :app.tasks/registry + {:metrics (ig/ref :app.metrics/metrics) + :tasks + {:sendmail (ig/ref :app.tasks.sendmail/handler) + :delete-object (ig/ref :app.tasks.delete-object/handler) + :delete-profile (ig/ref :app.tasks.delete-profile/handler) + :file-media-gc (ig/ref :app.tasks.file-media-gc/handler) + :file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler) + :storage-deleted-gc (ig/ref :app.storage/gc-deleted-task) + :storage-touched-gc (ig/ref :app.storage/gc-touched-task) + :storage-recheck (ig/ref :app.storage/recheck-task) + :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) + :telemetry (ig/ref :app.tasks.telemetry/handler) + :session-gc (ig/ref :app.http.session/gc-task)}} :app.tasks.sendmail/handler {:host (:smtp-host config) @@ -266,21 +299,30 @@ {:pool (ig/ref :app.db/pool) :version (:full cfg/version) :uri (:telemetry-uri config) - :sprops (ig/ref :app.sprops/props)} + :sprops (ig/ref :app.setup/props)} :app.srepl/server {:port (:srepl-port config) :host (:srepl-host config)} - :app.sprops/props + :app.setup/props {:pool (ig/ref :app.db/pool)} - :app.error-reporter/reporter + :app.loggers.zmq/receiver + {:endpoint (:loggers-zmq-uri config)} + + :app.loggers.loki/reporter + {:uri (:loggers-loki-uri config) + :receiver (ig/ref :app.loggers.zmq/receiver) + :executor (ig/ref :app.worker/executor)} + + :app.loggers.mattermost/reporter {:uri (:error-report-webhook config) + :receiver (ig/ref :app.loggers.zmq/receiver) :pool (ig/ref :app.db/pool) :executor (ig/ref :app.worker/executor)} - :app.error-reporter/handler + :app.loggers.mattermost/handler {:pool (ig/ref :app.db/pool)} :app.storage/storage @@ -333,7 +375,7 @@ (-> system-config (ig/prep) (ig/init)))) - (log/infof "Welcome to penpot! Version: '%s'." + (log/infof "welcome to penpot (version: '%s')" (:full cfg/version)))) (defn stop diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 14a4191e0a..13219f23a4 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -77,7 +77,7 @@ (assoc params :format format :mtype (cm/format->mtype format) - :size (alength thumbnail-data) + :size (alength ^bytes thumbnail-data) :data (ByteArrayInputStream. thumbnail-data))))) (defmulti process :cmd) @@ -89,7 +89,7 @@ (.addImage) (.autoOrient) (.strip) - (.thumbnail (int width) (int height) ">") + (.thumbnail ^Integer (int width) ^Integer (int height) ">") (.quality (double quality)) (.addImage))] (generic-process (assoc params :operation op)))) @@ -101,7 +101,7 @@ (.addImage) (.autoOrient) (.strip) - (.thumbnail (int width) (int height) "^") + (.thumbnail ^Integer (int width) ^Integer (int height) "^") (.gravity "center") (.extent (int width) (int height)) (.quality (double quality)) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index a7e8660685..e71283ca23 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -10,17 +10,15 @@ (ns app.metrics (:require [app.common.exceptions :as ex] - [app.util.time :as dt] - [app.worker] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] - [integrant.core :as ig] - [next.jdbc :as jdbc]) + [integrant.core :as ig]) (:import io.prometheus.client.CollectorRegistry io.prometheus.client.Counter io.prometheus.client.Gauge io.prometheus.client.Summary + io.prometheus.client.Histogram io.prometheus.client.exporter.common.TextFormat io.prometheus.client.hotspot.DefaultExports io.prometheus.client.jetty.JettyStatisticsCollector @@ -30,41 +28,12 @@ (declare instrument-vars!) (declare instrument) (declare create-registry) - +(declare create) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Entry Point ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- instrument-jdbc! - [registry] - (instrument-vars! - [#'next.jdbc/execute-one! - #'next.jdbc/execute!] - {:registry registry - :type :counter - :name "database_query_counter" - :help "An absolute counter of database queries."})) - -(defn- instrument-workers! - [registry] - (instrument-vars! - [#'app.worker/run-task] - {:registry registry - :type :summary - :name "worker_task_checkout_millis" - :help "Latency measured between scheduld_at and execution time." - :wrap (fn [rootf mobj] - (let [mdata (meta rootf) - origf (::original mdata rootf)] - (with-meta - (fn [tasks item] - (let [now (inst-ms (dt/now)) - sat (inst-ms (:scheduled-at item))] - (mobj :observe (- now sat)) - (origf tasks item))) - {::original origf})))})) - (defn- handler [registry _request] (let [samples (.metricFamilySamples ^CollectorRegistry registry) @@ -73,13 +42,24 @@ {:headers {"content-type" TextFormat/CONTENT_TYPE_004} :body (.toString writer)})) +(s/def ::definitions + (s/map-of keyword? map?)) + +(defmethod ig/pre-init-spec ::metrics [_] + (s/keys :opt-un [::definitions])) + (defmethod ig/init-key ::metrics - [_ _cfg] + [_ {:keys [definitions] :as cfg}] (log/infof "Initializing prometheus registry and instrumentation.") - (let [registry (create-registry)] - (instrument-workers! registry) - (instrument-jdbc! registry) + (let [registry (create-registry) + definitions (reduce-kv (fn [res k v] + (->> (assoc v :registry registry) + (create) + (assoc res k))) + {} + definitions)] {:handler (partial handler registry) + :definitions definitions :registry registry})) (s/def ::handler fn?) @@ -87,7 +67,6 @@ (s/def ::metrics (s/keys :req-un [::registry ::handler])) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Implementation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -126,7 +105,7 @@ (invoke [_ cmd labels] (.. ^Counter instance - (labels labels) + (labels (into-array String labels)) (inc)))))) (defn make-gauge @@ -150,19 +129,27 @@ :dec (.dec ^Gauge instance))) (invoke [_ cmd labels] - (case cmd - :inc (.. ^Gauge instance (labels labels) (inc)) - :dec (.. ^Gauge instance (labels labels) (dec))))))) + (let [labels (into-array String [labels])] + (case cmd + :inc (.. ^Gauge instance (labels labels) (inc)) + :dec (.. ^Gauge instance (labels labels) (dec)))))))) + +(def default-quantiles + [[0.75 0.02] + [0.99 0.001]]) (defn make-summary - [{:keys [name help registry reg labels max-age] :or {max-age 3600} :as props}] + [{:keys [name help registry reg labels max-age quantiles buckets] + :or {max-age 3600 buckets 6 quantiles default-quantiles} :as props}] (let [registry (or registry reg) instance (doto (Summary/build) (.name name) - (.help help) - (.maxAgeSeconds max-age) - (.quantile 0.75 0.02) - (.quantile 0.99 0.001)) + (.help help)) + _ (when (seq quantiles) + (.maxAgeSeconds ^Summary instance max-age) + (.ageBuckets ^Summary instance buckets)) + _ (doseq [[q e] quantiles] + (.quantile ^Summary instance q e)) _ (when (seq labels) (.labelNames instance (into-array String labels))) instance (.register instance registry)] @@ -176,7 +163,34 @@ (invoke [_ cmd val labels] (.. ^Summary instance - (labels labels) + (labels (into-array String labels)) + (observe val)))))) + +(def default-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500]) + +(defn make-histogram + [{:keys [name help registry reg labels buckets] + :or {buckets default-histogram-buckets}}] + (let [registry (or registry reg) + instance (doto (Histogram/build) + (.name name) + (.help help) + (.buckets (into-array Double/TYPE buckets))) + _ (when (seq labels) + (.labelNames instance (into-array String labels))) + instance (.register instance registry)] + (reify + clojure.lang.IDeref + (deref [_] instance) + + clojure.lang.IFn + (invoke [_ cmd val] + (.observe ^Histogram instance val)) + + (invoke [_ cmd val labels] + (.. ^Histogram instance + (labels (into-array String labels)) (observe val)))))) (defn create @@ -184,7 +198,8 @@ (case type :counter (make-counter props) :gauge (make-gauge props) - :summary (make-summary props))) + :summary (make-summary props) + :histogram (make-histogram props))) (defn wrap-counter ([rootf mobj] @@ -204,7 +219,6 @@ (assoc mdata ::original origf)))) ([rootf mobj labels] (let [mdata (meta rootf) - labels (into-array String labels) origf (::original mdata rootf)] (with-meta (fn @@ -241,7 +255,6 @@ ([rootf mobj labels] (let [mdata (meta rootf) - labels (into-array String labels) origf (::original mdata rootf)] (with-meta (fn @@ -284,6 +297,9 @@ (instance? Summary @obj) ((or wrap wrap-summary) f obj) + (instance? Histogram @obj) + ((or wrap wrap-summary) f obj) + :else (ex/raise :type :not-implemented)))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2d247e11c6..4b329d2eb6 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -148,6 +148,21 @@ {:name "0045-add-index-to-file-change-table" :fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")} + + {:name "0046-add-profile-complaint-table" + :fn (mg/resource "app/migrations/sql/0046-add-profile-complaint-table.sql")} + + {:name "0047-mod-file-change-table" + :fn (mg/resource "app/migrations/sql/0047-mod-file-change-table.sql")} + + {:name "0048-mod-storage-tables" + :fn (mg/resource "app/migrations/sql/0048-mod-storage-tables.sql")} + + {:name "0049-mod-http-session-table" + :fn (mg/resource "app/migrations/sql/0049-mod-http-session-table.sql")} + + {:name "0050-mod-server-prop-table" + :fn (mg/resource "app/migrations/sql/0050-mod-server-prop-table.sql")} ]) diff --git a/backend/src/app/migrations/sql/0035-add-storage-tables.sql b/backend/src/app/migrations/sql/0035-add-storage-tables.sql index 4bf96725d5..d1ec7f9d46 100644 --- a/backend/src/app/migrations/sql/0035-add-storage-tables.sql +++ b/backend/src/app/migrations/sql/0035-add-storage-tables.sql @@ -10,11 +10,17 @@ CREATE TABLE storage_object ( metadata jsonb NULL DEFAULT NULL ); +CREATE INDEX storage_object__id__deleted_at__idx + ON storage_object(id, deleted_at) + WHERE deleted_at IS NOT null; + CREATE TABLE storage_data ( id uuid PRIMARY KEY REFERENCES storage_object (id) ON DELETE CASCADE, data bytea NOT NULL ); +CREATE INDEX storage_data__id__idx ON storage_data(id); + -- Table used for store inflight upload ids, for later recheck and -- delete possible staled files that exists on the phisical storage -- but does not exists in the 'storage_object' table. @@ -28,8 +34,3 @@ CREATE TABLE storage_pending ( PRIMARY KEY (created_at, id) ); -CREATE INDEX storage_data__id__idx ON storage_data(id); -CREATE INDEX storage_object__id__deleted_at__idx - ON storage_object(id, deleted_at) - WHERE deleted_at IS NOT null; - diff --git a/backend/src/app/migrations/sql/0044-add-storage-refcount.sql b/backend/src/app/migrations/sql/0044-add-storage-refcount.sql index 28f97548c5..ecb95e9e7f 100644 --- a/backend/src/app/migrations/sql/0044-add-storage-refcount.sql +++ b/backend/src/app/migrations/sql/0044-add-storage-refcount.sql @@ -5,9 +5,6 @@ CREATE INDEX storage_object__id_touched_at__idx ON storage_object (touched_at, id) WHERE touched_at IS NOT NULL; --- DROP TRIGGER file_media_object__on_delete__tgr ON file_media_object CASCADE; --- DROP FUNCTION on_delete_file_media_object () ; - CREATE OR REPLACE FUNCTION on_delete_file_media_object() RETURNS TRIGGER AS $func$ BEGIN diff --git a/backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql b/backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql new file mode 100644 index 0000000000..431f737442 --- /dev/null +++ b/backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql @@ -0,0 +1,45 @@ +CREATE TABLE profile_complaint_report ( + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + + type text NOT NULL, + content jsonb, + + PRIMARY KEY (profile_id, created_at) +); + +ALTER TABLE profile_complaint_report + ALTER COLUMN type SET STORAGE external, + ALTER COLUMN content SET STORAGE external; + +ALTER TABLE profile + ADD COLUMN is_muted boolean DEFAULT false, + ADD COLUMN auth_backend text NULL; + +ALTER TABLE profile + ALTER COLUMN auth_backend SET STORAGE external; + +UPDATE profile + SET auth_backend = 'google' + WHERE password = '!'; + +UPDATE profile + SET auth_backend = 'penpot' + WHERE password != '!'; + +-- Table storing a permanent complaint table for register all +-- permanent bounces and spam reports (complaints) and avoid sending +-- more emails there. +CREATE TABLE global_complaint_report ( + email text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + type text NOT NULL, + content jsonb, + + PRIMARY KEY (email, created_at) +); + +ALTER TABLE global_complaint_report + ALTER COLUMN type SET STORAGE external, + ALTER COLUMN content SET STORAGE external; diff --git a/backend/src/app/migrations/sql/0047-mod-file-change-table.sql b/backend/src/app/migrations/sql/0047-mod-file-change-table.sql new file mode 100644 index 0000000000..01bb0f0920 --- /dev/null +++ b/backend/src/app/migrations/sql/0047-mod-file-change-table.sql @@ -0,0 +1,16 @@ +--- Helps on the lagged changes query on update-file rpc +CREATE INDEX file_change__file_id__revn__idx ON file_change (file_id, revn); + +--- Drop redundant index +DROP INDEX page_change_file_id_idx; + +--- Add profile_id field. +ALTER TABLE file_change + ADD COLUMN profile_id uuid NULL REFERENCES profile (id) ON DELETE SET NULL; + +CREATE INDEX file_change__profile_id__idx + ON file_change (profile_id) + WHERE profile_id IS NOT NULL; + +--- Fix naming +ALTER INDEX file_change__created_at_idx RENAME TO file_change__created_at__idx; diff --git a/backend/src/app/migrations/sql/0048-mod-storage-tables.sql b/backend/src/app/migrations/sql/0048-mod-storage-tables.sql new file mode 100644 index 0000000000..eb9fa8fed5 --- /dev/null +++ b/backend/src/app/migrations/sql/0048-mod-storage-tables.sql @@ -0,0 +1,9 @@ +--- Drop redundant index already covered by primary key +DROP INDEX storage_data__id__idx; + +--- Replace not efficient index with more efficient one +DROP INDEX storage_object__id__deleted_at__idx; + +CREATE INDEX storage_object__id__deleted_at__idx + ON storage_object(deleted_at, id) + WHERE deleted_at IS NOT NULL; diff --git a/backend/src/app/migrations/sql/0049-mod-http-session-table.sql b/backend/src/app/migrations/sql/0049-mod-http-session-table.sql new file mode 100644 index 0000000000..4ee4657abd --- /dev/null +++ b/backend/src/app/migrations/sql/0049-mod-http-session-table.sql @@ -0,0 +1,6 @@ +ALTER TABLE http_session + ADD COLUMN updated_at timestamptz NULL; + +CREATE INDEX http_session__updated_at__idx + ON http_session (updated_at) + WHERE updated_at IS NOT NULL; diff --git a/backend/src/app/migrations/sql/0050-mod-server-prop-table.sql b/backend/src/app/migrations/sql/0050-mod-server-prop-table.sql new file mode 100644 index 0000000000..11f8d140e7 --- /dev/null +++ b/backend/src/app/migrations/sql/0050-mod-server-prop-table.sql @@ -0,0 +1,4 @@ +ALTER TABLE server_prop + ADD COLUMN preload boolean DEFAULT false; + +UPDATE server_prop SET preload = true; diff --git a/backend/src/app/msgbus.clj b/backend/src/app/msgbus.clj new file mode 100644 index 0000000000..63ea00ae4a --- /dev/null +++ b/backend/src/app/msgbus.clj @@ -0,0 +1,249 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2021 UXBOX Labs SL + +(ns app.msgbus + "The msgbus abstraction implemented using redis as underlying backend." + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cfg] + [app.util.blob :as blob] + [app.util.time :as dt] + [clojure.core.async :as a] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [integrant.core :as ig] + [promesa.core :as p]) + (:import + java.time.Duration + io.lettuce.core.RedisClient + io.lettuce.core.RedisURI + io.lettuce.core.api.StatefulRedisConnection + io.lettuce.core.api.async.RedisAsyncCommands + io.lettuce.core.codec.ByteArrayCodec + io.lettuce.core.codec.RedisCodec + io.lettuce.core.codec.StringCodec + io.lettuce.core.pubsub.RedisPubSubListener + io.lettuce.core.pubsub.StatefulRedisPubSubConnection + io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands)) + +(declare impl-publish-loop) +(declare impl-redis-pub) +(declare impl-redis-sub) +(declare impl-redis-unsub) +(declare impl-subscribe-loop) + + +;; --- STATE INIT: Publisher + +(s/def ::uri ::us/string) +(s/def ::buffer-size ::us/integer) + +(defmethod ig/pre-init-spec ::msgbus [_] + (s/keys :req-un [::uri] + :opt-un [::buffer-size])) + +(defmethod ig/prep-key ::msgbus + [_ cfg] + (merge {:buffer-size 128} cfg)) + +(defmethod ig/init-key ::msgbus + [_ {:keys [uri buffer-size] :as cfg}] + (let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE) + + uri (RedisURI/create uri) + rclient (RedisClient/create ^RedisURI uri) + + snd-conn (.connect ^RedisClient rclient ^RedisCodec codec) + rcv-conn (.connectPubSub ^RedisClient rclient ^RedisCodec codec) + + ;; Channel used for receive publications from the application. + pub-chan (a/chan (a/dropping-buffer buffer-size)) + + ;; Channel used for receive data from redis + rcv-chan (a/chan (a/dropping-buffer buffer-size)) + + ;; Channel used for receive subscription requests. + sub-chan (a/chan) + cch (a/chan 1)] + + (.setTimeout ^StatefulRedisConnection snd-conn ^Duration (dt/duration {:seconds 10})) + (.setTimeout ^StatefulRedisPubSubConnection rcv-conn ^Duration (dt/duration {:seconds 10})) + + (log/debugf "initializing msgbus (uri: '%s')" (str uri)) + + ;; Start the sending (publishing) loop + (impl-publish-loop snd-conn pub-chan cch) + + ;; Start the receiving (subscribing) loop + (impl-subscribe-loop rcv-conn rcv-chan sub-chan cch) + + (with-meta + (fn run + ([command] (run command nil)) + ([command params] + (a/go + (case command + :pub (a/>! pub-chan params) + :sub (a/>! sub-chan params))))) + + {::snd-conn snd-conn + ::rcv-conn rcv-conn + ::cch cch + ::pub-chan pub-chan + ::rcv-chan rcv-chan}))) + +(defmethod ig/halt-key! ::msgbus + [_ f] + (let [mdata (meta f)] + (.close ^StatefulRedisConnection (::snd-conn mdata)) + (.close ^StatefulRedisPubSubConnection (::rcv-conn mdata)) + (a/close! (::cch mdata)) + (a/close! (::pub-chan mdata)) + (a/close! (::rcv-chan mdata)))) + +(defn- impl-publish-loop + [conn pub-chan cch] + (let [rac (.async ^StatefulRedisConnection conn)] + (a/go-loop [] + (let [[val _] (a/alts! [cch pub-chan] :priority true)] + (when (some? val) + (let [result (a/> (vals state) + (mapcat identity) + (filter some?) + (run! a/close!)))) + + ;; This means we receive data from redis and we need to + ;; forward it to the underlying subscriptions. + (= port rcv-chan) + (let [topic (:topic val) ; topic is already string + pending (loop [chans (seq (get-in @chans [:topics topic])) + pending #{}] + (if-let [ch (first chans)] + (if (a/>! ch (:message val)) + (recur (rest chans) pending) + (recur (rest chans) (conj pending ch))) + pending))] + ;; (log/tracef "received message => pending: %s" (pr-str pending)) + (some->> (seq pending) + (send-off chans unsubscribe-channels)) + + (recur))))))) + +(defn- impl-redis-pub + [rac {:keys [topic message]}] + (let [topic (str (cfg/get :tenant) "." topic) + message (blob/encode message) + res (a/chan 1)] + (-> (.publish ^RedisAsyncCommands rac ^String topic ^bytes message) + (p/finally (fn [_ e] + (when e (a/>!! res e)) + (a/close! res)))) + res)) + +(defn impl-redis-sub + [conn topic] + (let [^RedisPubSubAsyncCommands cmd (.async ^StatefulRedisPubSubConnection conn) + res (a/chan 1)] + (-> (.subscribe cmd (into-array String [topic])) + (p/finally (fn [_ e] + (when e (a/>!! res e)) + (a/close! res)))) + res)) + +(defn impl-redis-unsub + [conn topic] + (let [^RedisPubSubAsyncCommands cmd (.async ^StatefulRedisPubSubConnection conn) + res (a/chan 1)] + (-> (.unsubscribe cmd (into-array String [topic])) + (p/finally (fn [_ e] + (when e (a/>!! res e)) + (a/close! res)))) + res)) diff --git a/backend/src/app/notifications.clj b/backend/src/app/notifications.clj index e8543d9d77..82ccd23d1a 100644 --- a/backend/src/app/notifications.clj +++ b/backend/src/app/notifications.clj @@ -13,9 +13,10 @@ [app.common.spec :as us] [app.db :as db] [app.metrics :as mtx] - [app.redis :as rd] [app.util.async :as aa] + [app.util.time :as dt] [app.util.transit :as t] + [app.worker :as wrk] [clojure.core.async :as a] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] @@ -23,7 +24,9 @@ [ring.adapter.jetty9 :as jetty] [ring.middleware.cookies :refer [wrap-cookies]] [ring.middleware.keyword-params :refer [wrap-keyword-params]] - [ring.middleware.params :refer [wrap-params]])) + [ring.middleware.params :refer [wrap-params]]) + (:import + org.eclipse.jetty.websocket.api.WebSocketAdapter)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Http Handler @@ -34,9 +37,10 @@ (declare handler) (s/def ::session map?) +(s/def ::msgbus fn?) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::rd/redis ::db/pool ::session ::mtx/metrics])) + (s/keys :req-un [::msgbus ::db/pool ::session ::mtx/metrics ::wrk/executor])) (defmethod ig/init-key ::handler [_ {:keys [session metrics] :as cfg}] @@ -44,29 +48,32 @@ mtx-active-connections (mtx/create - {:name "websocket_notifications_active_connections" + {:name "websocket_active_connections" :registry (:registry metrics) :type :gauge - :help "Active websocket connections on notifications service."}) + :help "Active websocket connections."}) - mtx-message-recv + mtx-messages (mtx/create - {:name "websocket_notifications_message_recv_timing" + {:name "websocket_message_count" :registry (:registry metrics) - :type :summary - :help "Message receive summary timing (ms)."}) + :labels ["op"] + :type :counter + :help "Counter of processed messages."}) - mtx-message-send + mtx-sessions (mtx/create - {:name "websocket_notifications_message_send_timing" + {:name "websocket_session_timing" :registry (:registry metrics) - :type :summary - :help "Message receive summary timing (ms)."}) + :quantiles [] + :help "Websocket session timing (seconds)." + :type :summary}) cfg (assoc cfg :mtx-active-connections mtx-active-connections - :mtx-message-recv mtx-message-recv - :mtx-message-send mtx-message-send)] + :mtx-messages mtx-messages + :mtx-sessions mtx-sessions + )] (-> #(handler cfg %) (wrap-session) (wrap-keyword-params) @@ -109,14 +116,10 @@ (db/exec-one! conn [sql:retrieve-file id])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; WebSocket Http Handler -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; --- WEBSOCKET INIT (declare handle-connect) -(defrecord WebSocket [conn in out sub]) - (defn- ws-send [conn data] (try @@ -127,109 +130,120 @@ false))) (defn websocket - [{:keys [file-id team-id redis] :as cfg}] - (let [in (a/chan 32) - out (a/chan 32) - mtx-active-connections (:mtx-active-connections cfg) - mtx-message-send (:mtx-message-send cfg) - mtx-message-recv (:mtx-message-recv cfg) - - ws-send (mtx/wrap-summary ws-send mtx-message-send)] + [{:keys [file-id team-id msgbus executor] :as cfg}] + (let [rcv-ch (a/chan 32) + out-ch (a/chan 32) + mtx-aconn (:mtx-active-connections cfg) + mtx-messages (:mtx-messages cfg) + mtx-sessions (:mtx-sessions cfg) + created-at (dt/now) + ws-send (mtx/wrap-counter ws-send mtx-messages ["send"])] (letfn [(on-connect [conn] - (mtx-active-connections :inc) - (let [sub (rd/subscribe redis {:xform (map t/decode-str) - :topics [file-id team-id]}) - ws (WebSocket. conn in out sub nil cfg)] + (mtx-aconn :inc) + ;; A subscription channel should use a lossy buffer + ;; because we can't penalize normal clients when one + ;; slow client is connected to the room. + (let [sub-ch (a/chan (a/dropping-buffer 128)) + cfg (assoc cfg + :conn conn + :rcv-ch rcv-ch + :out-ch out-ch + :sub-ch sub-ch)] - ;; message forwarding loop + (log/tracef "on-connect %s" (:session-id cfg)) + + ;; Forward all messages from out-ch to the websocket + ;; connection (a/go-loop [] - (let [val (a/!! in message)))] + (when-not (a/offer! rcv-ch message) + (log/warn "droping ws input message, channe full"))))] {:on-connect on-connect :on-error on-error :on-close on-close - :on-text (mtx/wrap-summary on-message mtx-message-recv) + :on-text (mtx/wrap-counter on-message mtx-messages ["recv"]) :on-bytes (constantly nil)}))) +;; --- CONNECTION INIT + (declare handle-message) (declare start-loop!) (defn- handle-connect - [{:keys [conn] :as ws}] + [{:keys [conn] :as cfg}] (a/go (try - (aa/! out val)) + (a/>! out-ch val)) (recur)) - ;; Timeout channel signaling + ;; When timeout channel is signaled, we need to send a ping + ;; message to the output channel. TODO: we need to make this + ;; more smart. (= port timeout) (do - (a/>! out {:type :ping}) + (a/>! out-ch {:type :ping}) (recur)) :else nil))))) -;; Incoming Messages Handling - -(defn- publish - [redis channel message] - (aa/go-try - (let [message (t/encode-str message)] - (aa/ - -(ns app.redis - (:refer-clojure :exclude [run!]) - (:require - [app.common.spec :as us] - [app.util.redis :as redis] - [clojure.spec.alpha :as s] - [integrant.core :as ig]) - (:import - java.lang.AutoCloseable)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; State -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defmethod ig/pre-init-spec ::redis [_] - (s/keys :req-un [::uri])) - -(defmethod ig/init-key ::redis - [_ cfg] - (let [client (redis/client (:uri cfg "redis://redis/0")) - conn (redis/connect client)] - {::client client - ::conn conn})) - -(defmethod ig/halt-key! ::redis - [_ {:keys [::client ::conn]}] - (.close ^AutoCloseable conn) - (.close ^AutoCloseable client)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; API -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(s/def ::client some?) -(s/def ::conn some?) -(s/def ::redis (s/keys :req [::client ::conn])) - -(defn subscribe - [client opts] - (us/assert ::redis client) - (redis/subscribe (::client client) opts)) - -(defn run! - [client cmd params] - (us/assert ::redis client) - (redis/run! (::conn client) cmd params)) - -(defn run - [client cmd params] - (us/assert ::redis client) - (redis/run (::conn client) cmd params)) - diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 28f9e2f9e6..e236eac4e3 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -25,6 +25,11 @@ [_] (ex/raise :type :not-found)) +(defn- run-hook + [hook-fn response] + (ex/ignoring (hook-fn)) + response) + (defn- rpc-query-handler [methods {:keys [profile-id] :as request}] (let [type (keyword (get-in request [:path-params :type])) @@ -50,7 +55,11 @@ result ((get methods type default-handler) data) mdata (meta result)] (cond->> {:status 200 :body result} - (fn? (:transform-response mdata)) ((:transform-response mdata) request)))) + (fn? (:transform-response mdata)) + ((:transform-response mdata) request) + + (fn? (:before-complete mdata)) + (run-hook (:before-complete mdata))))) (defn- wrap-with-metrics [cfg f mdata] @@ -66,7 +75,7 @@ (ex/raise :type :internal :code :rlimit-not-configured :hint (str/fmt "%s rlimit not configured" key))) - (log/tracef "Adding rlimit to '%s' rpc handler." (::sv/name mdata)) + (log/tracef "adding rlimit to '%s' rpc handler" (::sv/name mdata)) (fn [cfg params] (rlm/execute rlinst (f cfg params)))) f)) @@ -76,7 +85,7 @@ (let [f (wrap-with-rlimits cfg f mdata) f (wrap-with-metrics cfg f mdata) spec (or (::sv/spec mdata) (s/spec any?))] - (log/tracef "Registering '%s' command to rpc service." (::sv/name mdata)) + (log/tracef "registering '%s' command to rpc service" (::sv/name mdata)) (fn [params] (when (and (:auth mdata true) (not (uuid? (:profile-id params)))) (ex/raise :type :authentication @@ -96,7 +105,7 @@ {:name "rpc_query_timing" :labels ["name"] :registry (get-in cfg [:metrics :registry]) - :type :summary + :type :histogram :help "Timing of query services."}) cfg (assoc cfg ::mobj mobj)] (->> (sv/scan-ns 'app.rpc.queries.projects @@ -115,7 +124,7 @@ {:name "rpc_mutation_timing" :labels ["name"] :registry (get-in cfg [:metrics :registry]) - :type :summary + :type :histogram :help "Timing of mutation services."}) cfg (assoc cfg ::mobj mobj)] (->> (sv/scan-ns 'app.rpc.mutations.demo @@ -126,7 +135,7 @@ 'app.rpc.mutations.projects 'app.rpc.mutations.viewer 'app.rpc.mutations.teams - 'app.rpc.mutations.feedback + 'app.rpc.mutations.ldap 'app.rpc.mutations.verify-token) (map (partial process-method cfg)) (into {})))) diff --git a/backend/src/app/rpc/mutations/demo.clj b/backend/src/app/rpc/mutations/demo.clj index c5959f91d6..80658a5da4 100644 --- a/backend/src/app/rpc/mutations/demo.clj +++ b/backend/src/app/rpc/mutations/demo.clj @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.rpc.mutations.demo "A demo specific mutations." @@ -14,8 +14,8 @@ [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] - [app.db.profile-initial-data :refer [create-profile-initial-data]] [app.rpc.mutations.profile :as profile] + [app.setup.initial-data :as sid] [app.tasks :as tasks] [app.util.services :as sv] [buddy.core.codecs :as bc] @@ -36,7 +36,7 @@ params {:id id :email email :fullname fullname - :demo? true + :is-demo true :password password :props {:onboarding-viewed true}}] @@ -48,7 +48,7 @@ (db/with-atomic [conn pool] (->> (#'profile/create-profile conn params) (#'profile/create-profile-relations conn) - (create-profile-initial-data conn)) + (sid/load-initial-project! conn)) ;; Schedule deletion of the demo profile (tasks/submit! conn {:name "delete-profile" diff --git a/backend/src/app/rpc/mutations/feedback.clj b/backend/src/app/rpc/mutations/feedback.clj deleted file mode 100644 index 875a939857..0000000000 --- a/backend/src/app/rpc/mutations/feedback.clj +++ /dev/null @@ -1,41 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2021 UXBOX Labs SL - -(ns app.rpc.mutations.feedback - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.config :as cfg] - [app.db :as db] - [app.emails :as emails] - [app.rpc.queries.profile :as profile] - [app.util.services :as sv] - [clojure.spec.alpha :as s])) - -(s/def ::subject ::us/string) -(s/def ::content ::us/string) - -(s/def ::send-profile-feedback - (s/keys :req-un [::profile-id ::subject ::content])) - -(sv/defmethod ::send-profile-feedback - [{:keys [pool] :as cfg} {:keys [profile-id subject content] :as params}] - (when-not (:feedback-enabled cfg/config) - (ex/raise :type :validation - :code :feedback-disabled - :hint "feedback module is disabled")) - - (db/with-atomic [conn pool] - (let [profile (profile/retrieve-profile-data conn profile-id)] - (emails/send! conn emails/feedback - {:to (:feedback-destination cfg/config) - :profile profile - :subject subject - :content content}) - nil))) diff --git a/backend/src/app/rpc/mutations/files.clj b/backend/src/app/rpc/mutations/files.clj index 9d04afeb2d..bb453122f0 100644 --- a/backend/src/app/rpc/mutations/files.clj +++ b/backend/src/app/rpc/mutations/files.clj @@ -16,14 +16,12 @@ [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] - [app.redis :as rd] [app.rpc.queries.files :as files] [app.rpc.queries.projects :as proj] [app.tasks :as tasks] [app.util.blob :as blob] [app.util.services :as sv] [app.util.time :as dt] - [app.util.transit :as t] [clojure.spec.alpha :as s])) ;; --- Helpers & Specs @@ -157,6 +155,7 @@ :hint "A file cannot be linked to itself")) (db/with-atomic [conn pool] (files/check-edition-permissions! conn profile-id file-id) + (files/check-edition-permissions! conn profile-id library-id) (link-file-to-library conn params))) (def sql:link-file-to-library @@ -252,19 +251,22 @@ :reg-objects :mov-objects} (:type change)) (some? (:component-id change))))) -(declare update-file) -(declare retrieve-lagged-changes) (declare insert-change) +(declare retrieve-lagged-changes) +(declare retrieve-team-id) +(declare send-notifications) +(declare update-file) (sv/defmethod ::update-file [{:keys [pool] :as cfg} {:keys [id profile-id] :as params}] (db/with-atomic [conn pool] (let [{:keys [id] :as file} (db/get-by-id conn :file id {:for-update true})] (files/check-edition-permissions! conn profile-id id) - (update-file (assoc cfg :conn conn) file params)))) + (update-file (assoc cfg :conn conn) + (assoc params :file file))))) (defn- update-file - [{:keys [conn redis]} file params] + [{:keys [conn] :as cfg} {:keys [file changes session-id profile-id] :as params}] (when (> (:revn params) (:revn file)) (ex/raise :type :validation @@ -272,64 +274,70 @@ :hint "The incoming revision number is greater that stored version." :context {:incoming-revn (:revn params) :stored-revn (:revn file)})) - (let [sid (:session-id params) - changes (:changes params) - file (-> file - (update :data blob/decode) - (update :data assoc :id (:id file)) - (update :data pmg/migrate-data) - (update :data cp/process-changes changes) - (update :data blob/encode) - (update :revn inc) - (assoc :changes (blob/encode changes) - :session-id sid)) - _ (insert-change conn file) - msg {:type :file-change - :profile-id (:profile-id params) - :file-id (:id file) - :session-id sid - :revn (:revn file) - :changes changes} - - library-changes (filter library-change? changes)] - - @(rd/run! redis :publish {:channel (str (:id file)) - :message (t/encode-str msg)}) - - (when (and (:is-shared file) (seq library-changes)) - (let [{:keys [team-id] :as project} - (db/get-by-id conn :project (:project-id file)) - - msg {:type :library-change - :profile-id (:profile-id params) + (let [file (-> file + (update :revn inc) + (update :data (fn [data] + (-> data + (blob/decode) + (assoc :id (:id file)) + (pmg/migrate-data) + (cp/process-changes changes) + (blob/encode)))))] + ;; Insert change to the xlog + (db/insert! conn :file-change + {:id (uuid/next) + :session-id session-id + :profile-id profile-id :file-id (:id file) - :session-id sid :revn (:revn file) - :modified-at (dt/now) - :changes library-changes}] - - @(rd/run! redis :publish {:channel (str team-id) - :message (t/encode-str msg)}))) + :data (:data file) + :changes (blob/encode changes)}) + ;; Update file (db/update! conn :file {:revn (:revn file) - :data (:data file)} + :data (:data file) + :has-media-trimmed false} {:id (:id file)}) - (retrieve-lagged-changes conn params))) + (let [params (assoc params :file file)] + ;; Send asynchronous notifications + (send-notifications cfg params) -(defn- insert-change - [conn {:keys [revn data changes session-id] :as file}] - (let [id (uuid/next) - file-id (:id file)] - (db/insert! conn :file-change - {:id id - :session-id session-id - :file-id file-id - :revn revn - :data data - :changes changes}))) + ;; Retrieve and return lagged data + (retrieve-lagged-changes conn params)))) + +(defn- send-notifications + [{:keys [msgbus conn] :as cfg} {:keys [file changes session-id] :as params}] + (let [lchanges (filter library-change? changes)] + + ;; Asynchronously publish message to the msgbus + (msgbus :pub {:topic (:id file) + :message + {:type :file-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id (:session-id params) + :revn (:revn file) + :changes changes}}) + + (when (and (:is-shared file) (seq lchanges)) + (let [team-id (retrieve-team-id conn (:project-id file))] + ;; Asynchronously publish message to the msgbus + (msgbus :pub {:topic team-id + :message + {:type :library-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id session-id + :revn (:revn file) + :modified-at (dt/now) + :changes lchanges}}))))) + +(defn- retrieve-team-id + [conn project-id] + (:team-id (db/get-by-id conn :project project-id {:columns [:team-id]}))) (def ^:private sql:lagged-changes diff --git a/backend/src/app/rpc/mutations/ldap.clj b/backend/src/app/rpc/mutations/ldap.clj new file mode 100644 index 0000000000..704e49a2d7 --- /dev/null +++ b/backend/src/app/rpc/mutations/ldap.clj @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.rpc.mutations.ldap + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cfg] + [app.rpc.mutations.profile :refer [login-or-register]] + [app.util.services :as sv] + [clj-ldap.client :as ldap] + [clojure.spec.alpha :as s] + [clojure.string] + [clojure.tools.logging :as log])) + +(def cpool + (delay + (let [params {:ssl? (cfg/get :ldap-ssl) + :startTLS? (cfg/get :ldap-starttls) + :bind-dn (cfg/get :ldap-bind-dn) + :password (cfg/get :ldap-bind-password) + :host {:address (cfg/get :ldap-host) + :port (cfg/get :ldap-port)}}] + (try + (ldap/connect params) + (catch Exception e + (log/errorf e "cannot connect to LDAP %s:%s" + (get-in params [:host :address]) + (get-in params [:host :port]))))))) + +;; --- Mutation: login-with-ldap + +(declare authenticate) + +(s/def ::email ::us/email) +(s/def ::password ::us/string) +(s/def ::invitation-token ::us/string) + +(s/def ::login-with-ldap + (s/keys :req-un [::email ::password] + :opt-un [::invitation-token])) + +(sv/defmethod ::login-with-ldap {:auth false :rlimit :password} + [{:keys [pool session tokens] :as cfg} {:keys [email password invitation-token] :as params}] + (when-not @cpool + (ex/raise :type :restriction + :code :ldap-disabled + :hint "ldap disabled or unable to connect")) + + (let [info (authenticate @cpool params) + cfg (assoc cfg :conn pool)] + (when-not info + (ex/raise :type :validation + :code :wrong-credentials)) + (let [profile (login-or-register cfg {:email (:email info) + :backend (:backend info) + :fullname (:fullname info)})] + (if-let [token (:invitation-token params)] + ;; If invitation token comes in params, this is because the + ;; user comes from team-invitation process; in this case, + ;; regenerate token and send back to the user a new invitation + ;; token (and mark current session as logged). + (let [claims (tokens :verify {:token token :iss :team-invitation}) + claims (assoc claims + :member-id (:id profile) + :member-email (:email profile)) + token (tokens :generate claims)] + (with-meta + {:invitation-token token} + {:transform-response ((:create session) (:id profile))})) + + (with-meta profile + {:transform-response ((:create session) (:id profile))}))))) + +(defn- replace-several [s & {:as replacements}] + (reduce-kv clojure.string/replace s replacements)) + +(defn- get-ldap-user + [cpool {:keys [email] :as params}] + (let [query (-> (cfg/get :ldap-user-query) + (replace-several "$username" email)) + + attrs [(cfg/get :ldap-attrs-username) + (cfg/get :ldap-attrs-email) + (cfg/get :ldap-attrs-photo) + (cfg/get :ldap-attrs-fullname)] + + base-dn (cfg/get :ldap-base-dn) + params {:filter query :sizelimit 1 :attributes attrs}] + (first (ldap/search cpool base-dn params)))) + +(defn- authenticate + [cpool {:keys [password] :as params}] + (when-let [{:keys [dn] :as luser} (get-ldap-user cpool params)] + (when (ldap/bind? cpool dn password) + {:photo (get luser (keyword (cfg/get :ldap-attrs-photo))) + :fullname (get luser (keyword (cfg/get :ldap-attrs-fullname))) + :email (get luser (keyword (cfg/get :ldap-attrs-email))) + :backend "ldap"}))) diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj index 8ee5d7e8fd..0b2d9c9064 100644 --- a/backend/src/app/rpc/mutations/profile.clj +++ b/backend/src/app/rpc/mutations/profile.clj @@ -14,28 +14,25 @@ [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] - [app.db.profile-initial-data :refer [create-profile-initial-data]] [app.emails :as emails] - [app.http.session :as session] [app.media :as media] [app.rpc.mutations.projects :as projects] [app.rpc.mutations.teams :as teams] - [app.rpc.mutations.verify-token :refer [process-token]] [app.rpc.queries.profile :as profile] + [app.setup.initial-data :as sid] [app.storage :as sto] [app.tasks :as tasks] [app.util.services :as sv] [app.util.time :as dt] [buddy.hashers :as hashers] [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] [cuerdas.core :as str])) ;; --- Helpers & Specs (s/def ::email ::us/email) (s/def ::fullname ::us/not-empty-string) -(s/def ::lang ::us/not-empty-string) +(s/def ::lang (s/nilable ::us/not-empty-string)) (s/def ::path ::us/string) (s/def ::profile-id ::us/uuid) (s/def ::password ::us/not-empty-string) @@ -44,79 +41,88 @@ ;; --- Mutation: Register Profile +(declare annotate-profile-register) (declare check-profile-existence!) (declare create-profile) (declare create-profile-relations) (declare email-domain-in-whitelist?) +(declare register-profile) -(s/def ::token ::us/not-empty-string) +(s/def ::invitation-token ::us/not-empty-string) (s/def ::register-profile (s/keys :req-un [::email ::password ::fullname] - :opt-un [::token])) + :opt-un [::invitation-token])) (sv/defmethod ::register-profile {:auth false :rlimit :password} - [{:keys [pool tokens session] :as cfg} {:keys [token] :as params}] - (when-not (:registration-enabled cfg/config) + [{:keys [pool tokens session] :as cfg} params] + (when-not (cfg/get :registration-enabled) (ex/raise :type :restriction :code :registration-disabled)) - (when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) - (:email params)) + (when-not (email-domain-in-whitelist? (cfg/get :registration-domain-whitelist) (:email params)) (ex/raise :type :validation :code :email-domain-is-not-allowed)) (db/with-atomic [conn pool] - (check-profile-existence! conn params) - (let [profile (->> (create-profile conn params) - (create-profile-relations conn))] - (create-profile-initial-data conn profile) + (let [cfg (assoc cfg :conn conn)] + (register-profile cfg params)))) - (if token - ;; If token comes in params, this is because the user comes - ;; from team-invitation process; in this case we revalidate - ;; the token and process the token claims again with the new - ;; profile data. - (let [claims (tokens :verify {:token token :iss :team-invitation}) - claims (assoc claims :member-id (:id profile)) - params (assoc params :profile-id (:id profile)) - cfg (assoc cfg :conn conn)] +(defn- annotate-profile-register + "A helper for properly increase the profile-register metric once the + transaction is completed." + [metrics profile] + (fn [] + (when (::created profile) + ((get-in metrics [:definitions :profile-register]) :inc)))) - (process-token cfg params claims) +(defn- register-profile + [{:keys [conn tokens session metrics] :as cfg} params] + (check-profile-existence! conn params) + (let [profile (->> (create-profile conn params) + (create-profile-relations conn)) + profile (assoc profile ::created true)] - ;; Automatically mark the created profile as active because - ;; we already have the verification of email with the - ;; team-invitation token. - (db/update! conn :profile - {:is-active true} - {:id (:id profile)}) + (sid/load-initial-project! conn profile) - ;; Return profile data and create http session for - ;; automatically login the profile. - (with-meta (assoc profile - :is-active true - :claims claims) - {:transform-response - (fn [request response] - (let [uagent (get-in request [:headers "user-agent"]) - id (session/create! session {:profile-id (:id profile) - :user-agent uagent})] - (assoc response - :cookies (session/cookies session {:value id}))))})) + (if-let [token (:invitation-token params)] + ;; If invitation token comes in params, this is because the + ;; user comes from team-invitation process; in this case, + ;; regenerate token and send back to the user a new invitation + ;; token (and mark current session as logged). + (let [claims (tokens :verify {:token token :iss :team-invitation}) + claims (assoc claims + :member-id (:id profile) + :member-email (:email profile)) + token (tokens :generate claims) + resp {:invitation-token token}] + (with-meta resp + {:transform-response ((:create session) (:id profile)) + :before-complete (annotate-profile-register metrics profile)})) - ;; If no token is provided, send a verification email - (let [token (tokens :generate - {:iss :verify-email - :exp (dt/in-future "48h") - :profile-id (:id profile) - :email (:email profile)})] + ;; If no token is provided, send a verification email + (let [vtoken (tokens :generate + {:iss :verify-email + :exp (dt/in-future "48h") + :profile-id (:id profile) + :email (:email profile)}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] - (emails/send! conn emails/register - {:to (:email profile) - :name (:fullname profile) - :token token}) - - profile))))) + ;; Don't allow proceed in register page if the email is + ;; already reported as permanent bounced + (when (emails/has-bounce-reports? conn (:email profile)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email has one or many bounces reported")) + (emails/send! conn emails/register + {:to (:email profile) + :name (:fullname profile) + :token vtoken + :extra-data ptoken}) + (with-meta profile + {:before-complete (annotate-profile-register metrics profile)}))))) (defn email-domain-in-whitelist? "Returns true if email's domain is in the given whitelist or if given @@ -154,31 +160,30 @@ [attempt password] (try (hashers/verify attempt password) - (catch Exception e - (log/warnf e "Error on verify password (only informative, nothing affected to user).") + (catch Exception _e {:update false :valid false}))) -(defn- create-profile +(defn create-profile "Create the profile entry on the database with limited input filling all the other fields with defaults." - [conn {:keys [id fullname email password demo? props is-active] - :or {is-active false} - :as params}] - (let [id (or id (uuid/next)) - demo? (if (boolean? demo?) demo? false) - active? (if demo? true is-active) - props (db/tjson (or props {})) - password (derive-password password)] + [conn {:keys [id fullname email password props is-active is-muted is-demo opts] + :or {is-active false is-muted false is-demo false}}] + (let [id (or id (uuid/next)) + is-active (if is-demo true is-active) + props (db/tjson (or props {})) + password (derive-password password) + params {:id id + :fullname fullname + :email (str/lower email) + :auth-backend "penpot" + :password password + :props props + :is-active is-active + :is-muted is-muted + :is-demo is-demo}] (try - (-> (db/insert! conn :profile - {:id id - :fullname fullname - :email (str/lower email) - :password password - :props props - :is-active active? - :is-demo demo?}) + (-> (db/insert! conn :profile params opts) (update :props db/decode-transit-pgobject)) (catch org.postgresql.util.PSQLException e (let [state (.getSQLState e)] @@ -189,7 +194,7 @@ :cause e))))))) -(defn- create-profile-relations +(defn create-profile-relations [conn profile] (let [team (teams/create-team conn {:profile-id (:id profile) :name "Default" @@ -214,10 +219,10 @@ (s/def ::login (s/keys :req-un [::email ::password] - :opt-un [::scope])) + :opt-un [::scope ::invitation-token])) (sv/defmethod ::login {:auth false :rlimit :password} - [{:keys [pool] :as cfg} {:keys [email password scope] :as params}] + [{:keys [pool session tokens] :as cfg} {:keys [email password scope] :as params}] (letfn [(check-password [profile password] (when (= (:password profile) "!") (ex/raise :type :validation @@ -237,29 +242,63 @@ profile)] (db/with-atomic [conn pool] - (let [prof (-> (profile/retrieve-profile-data-by-email conn email) - (validate-profile) - (profile/strip-private-attrs)) - addt (profile/retrieve-additional-data conn (:id prof))] - (merge prof addt))))) + (let [profile (->> (profile/retrieve-profile-data-by-email conn email) + (validate-profile) + (profile/strip-private-attrs) + (profile/populate-additional-data conn))] + (if-let [token (:invitation-token params)] + ;; If the request comes with an invitation token, this means + ;; that user wants to accept it with different user. A very + ;; strange case but still can happen. In this case, we + ;; proceed in the same way as in register: regenerate the + ;; invitation token and return it to the user for proper + ;; invitation acceptation. + (let [claims (tokens :verify {:token token :iss :team-invitation}) + claims (assoc claims + :member-id (:id profile) + :member-email (:email profile)) + token (tokens :generate claims)] + (with-meta {:invitation-token token} + {:transform-response ((:create session) (:id profile))})) + + (with-meta profile + {:transform-response ((:create session) (:id profile))})))))) + +;; --- Mutation: Logout + +(s/def ::logout + (s/keys :req-un [::profile-id])) + +(sv/defmethod ::logout + [{:keys [pool session] :as cfg} {:keys [profile-id] :as params}] + (with-meta {} + {:transform-response (:delete session)})) ;; --- Mutation: Register if not exists +(declare login-or-register) + +(s/def ::backend ::us/string) (s/def ::login-or-register - (s/keys :req-un [::email ::fullname])) + (s/keys :req-un [::email ::fullname ::backend])) (sv/defmethod ::login-or-register {:auth false} - [{:keys [pool] :as cfg} {:keys [email fullname] :as params}] - (letfn [(populate-additional-data [conn profile] - (let [data (profile/retrieve-additional-data conn (:id profile))] - (merge profile data))) + [{:keys [pool metrics] :as cfg} params] + (db/with-atomic [conn pool] + (let [profile (-> (assoc cfg :conn conn) + (login-or-register params))] + (with-meta profile + {:before-complete (annotate-profile-register metrics profile)})))) - (create-profile [conn {:keys [fullname email]}] +(defn login-or-register + [{:keys [conn] :as cfg} {:keys [email backend] :as params}] + (letfn [(create-profile [conn {:keys [fullname email]}] (db/insert! conn :profile {:id (uuid/next) :fullname fullname :email (str/lower email) + :auth-backend backend :is-active true :password "!" :is-demo false})) @@ -267,15 +306,14 @@ (register-profile [conn params] (let [profile (->> (create-profile conn params) (create-profile-relations conn))] - (create-profile-initial-data conn profile) - profile))] + (sid/load-initial-project! conn profile) + (assoc profile ::created true)))] - (db/with-atomic [conn pool] - (let [profile (profile/retrieve-profile-data-by-email conn email) - profile (if profile - (populate-additional-data conn profile) - (register-profile conn params))] - (profile/strip-private-attrs profile))))) + (let [profile (profile/retrieve-profile-data-by-email conn email) + profile (if profile + (profile/populate-additional-data conn profile) + (register-profile conn params))] + (profile/strip-private-attrs profile)))) ;; --- Mutation: Update Profile (own) @@ -300,12 +338,8 @@ ;; --- Mutation: Update Password -(defn- validate-password! - [conn {:keys [profile-id old-password] :as params}] - (let [profile (db/get-by-id conn :profile profile-id)] - (when-not (:valid (verify-password old-password (:password profile))) - (ex/raise :type :validation - :code :old-password-not-match)))) +(declare validate-password!) +(declare update-profile-password!) (s/def ::update-profile-password (s/keys :req-un [::profile-id ::password ::old-password])) @@ -313,12 +347,23 @@ (sv/defmethod ::update-profile-password {:rlimit :password} [{:keys [pool] :as cfg} {:keys [password profile-id] :as params}] (db/with-atomic [conn pool] - (validate-password! conn params) - (db/update! conn :profile - {:password (derive-password password)} - {:id profile-id}) - nil)) + (let [profile (validate-password! conn params)] + (update-profile-password! conn (assoc profile :password password)) + nil))) +(defn- validate-password! + [conn {:keys [profile-id old-password] :as params}] + (let [profile (db/get-by-id conn :profile profile-id)] + (when-not (:valid (verify-password old-password (:password profile))) + (ex/raise :type :validation + :code :old-password-not-match)) + profile)) + +(defn update-profile-password! + [conn {:keys [id password] :as profile}] + (db/update! conn :profile + {:password (derive-password password)} + {:id id})) ;; --- Mutation: Update Photo @@ -352,31 +397,68 @@ {:id profile-id}) nil) + ;; --- Mutation: Request Email Change +(declare request-email-change) +(declare change-email-inmediatelly) + (s/def ::request-email-change (s/keys :req-un [::email])) (sv/defmethod ::request-email-change - [{:keys [pool tokens] :as cfg} {:keys [profile-id email] :as params}] + [{:keys [pool] :as cfg} {:keys [profile-id email] :as params}] (db/with-atomic [conn pool] - (let [email (str/lower email) - profile (db/get-by-id conn :profile profile-id) - token (tokens :generate - {:iss :change-email - :exp (dt/in-future "15m") - :profile-id profile-id - :email email})] + (let [profile (db/get-by-id conn :profile profile-id) + cfg (assoc cfg :conn conn) + params (assoc params + :profile profile + :email (str/lower email))] + (if (cfg/get :smtp-enabled) + (request-email-change cfg params) + (change-email-inmediatelly cfg params))))) - (when (not= email (:email profile)) - (check-profile-existence! conn params)) +(defn- change-email-inmediatelly + [{:keys [conn]} {:keys [profile email] :as params}] + (when (not= email (:email profile)) + (check-profile-existence! conn params)) + (db/update! conn :profile + {:email email} + {:id (:id profile)}) + {:changed true}) + +(defn- request-email-change + [{:keys [conn tokens]} {:keys [profile email] :as params}] + (let [token (tokens :generate + {:iss :change-email + :exp (dt/in-future "15m") + :profile-id (:id profile) + :email email}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + + (when (not= email (:email profile)) + (check-profile-existence! conn params)) + + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) + + (when (emails/has-bounce-reports? conn email) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + + (emails/send! conn emails/change-email + {:to (:email profile) + :name (:fullname profile) + :pending-email email + :token token + :extra-data ptoken}) + nil)) - (emails/send! conn emails/change-email - {:to (:email profile) - :name (:fullname profile) - :pending-email email - :token token}) - nil))) (defn select-profile-for-update [conn id] @@ -397,18 +479,33 @@ (assoc profile :token token))) (send-email-notification [conn profile] - (emails/send! conn emails/password-recovery - {:to (:email profile) - :token (:token profile) - :name (:fullname profile)}) - nil)] + (let [ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + (emails/send! conn emails/password-recovery + {:to (:email profile) + :token (:token profile) + :name (:fullname profile) + :extra-data ptoken}) + nil))] (db/with-atomic [conn pool] (when-let [profile (profile/retrieve-profile-data-by-email conn email)] + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) + (when-not (:is-active profile) (ex/raise :type :validation :code :profile-not-verified :hint "the user need to validate profile before recover password")) + + (when (emails/has-bounce-reports? conn (:email profile)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + (->> profile (create-recovery-token) (send-email-notification conn)))))) @@ -480,11 +577,7 @@ {:id profile-id}) (with-meta {} - {:transform-response - (fn [request response] - (session/delete! session request) - (assoc response - :cookies (session/cookies session {:value "" :max-age -1})))}))) + {:transform-response (:delete session)}))) (def sql:owned-teams "with owner_teams as ( diff --git a/backend/src/app/rpc/mutations/projects.clj b/backend/src/app/rpc/mutations/projects.clj index de92bb054a..deaf42321e 100644 --- a/backend/src/app/rpc/mutations/projects.clj +++ b/backend/src/app/rpc/mutations/projects.clj @@ -14,6 +14,7 @@ [app.config :as cfg] [app.db :as db] [app.rpc.queries.projects :as proj] + [app.rpc.queries.teams :as teams] [app.tasks :as tasks] [app.util.services :as sv] [app.util.time :as dt] @@ -38,13 +39,14 @@ :opt-un [::id])) (sv/defmethod ::create-project - [{:keys [pool] :as cfg} params] + [{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}] (db/with-atomic [conn pool] - (let [proj (create-project conn params) - params (assoc params :project-id (:id proj))] + (teams/check-edition-permissions! conn profile-id team-id) + (let [project (create-project conn params) + params (assoc params :project-id (:id project))] (create-project-profile conn params) (create-team-project-profile conn params) - (assoc proj :is-pinned true)))) + (assoc project :is-pinned true)))) (defn create-project [conn {:keys [id team-id name default?] :as params}] @@ -92,6 +94,7 @@ (sv/defmethod ::update-project-pin [{:keys [pool] :as cfg} {:keys [id profile-id team-id is-pinned] :as params}] (db/with-atomic [conn pool] + (proj/check-edition-permissions! conn profile-id id) (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) nil)) diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index 5abbb41526..b7a8eaaa25 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -146,7 +146,7 @@ nil))) -;; --- Mutation: Tean Update Role +;; --- Mutation: Team Update Role (declare retrieve-team-member) (declare role->params) @@ -218,7 +218,7 @@ :viewer {:is-owner false :is-admin false :can-edit false})) -;; --- Mutation: Team Update Role +;; --- Mutation: Delete Team Member (s/def ::delete-team-member (s/keys :req-un [::profile-id ::team-id ::member-id])) @@ -227,8 +227,8 @@ [{:keys [pool] :as cfg} {:keys [team-id profile-id member-id] :as params}] (db/with-atomic [conn pool] (let [perms (teams/check-read-permissions! conn profile-id team-id)] - (when-not (or (:is-owner perms) - (:is-admin perms)) + (when-not (or (some :is-owner perms) + (some :is-admin perms)) (ex/raise :type :validation :code :insufficient-permissions)) @@ -297,26 +297,48 @@ (sv/defmethod ::invite-team-member [{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}] (db/with-atomic [conn pool] - (let [perms (teams/check-edition-permissions! conn profile-id team-id) - profile (db/get-by-id conn :profile profile-id) - member (profile/retrieve-profile-data-by-email conn email) - team (db/get-by-id conn :team team-id) - token (tokens :generate - {:iss :team-invitation - :exp (dt/in-future "24h") - :profile-id (:id profile) - :role role - :team-id team-id - :member-email (:email member email) - :member-id (:id member)})] + (let [perms (teams/check-edition-permissions! conn profile-id team-id) + profile (db/get-by-id conn :profile profile-id) + member (profile/retrieve-profile-data-by-email conn email) + team (db/get-by-id conn :team team-id) + itoken (tokens :generate + {:iss :team-invitation + :exp (dt/in-future "6h") + :profile-id (:id profile) + :role role + :team-id team-id + :member-email (:email member email) + :member-id (:id member)}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] (when-not (some :is-admin perms) (ex/raise :type :validation :code :insufficient-permissions)) + ;; First check if the current profile is allowed to send emails. + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) + + (when (and member (not (emails/allow-send-emails? conn member))) + (ex/raise :type :validation + :code :member-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) + + ;; Secondly check if the invited member email is part of the + ;; global spam/bounce report. + (when (emails/has-bounce-reports? conn email) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + (emails/send! conn emails/invite-to-team {:to email :invited-by (:fullname profile) :team (:name team) - :token token}) + :token itoken + :extra-data ptoken}) nil))) diff --git a/backend/src/app/rpc/mutations/verify_token.clj b/backend/src/app/rpc/mutations/verify_token.clj index 8f2a7d0f4b..4c7938e85e 100644 --- a/backend/src/app/rpc/mutations/verify_token.clj +++ b/backend/src/app/rpc/mutations/verify_token.clj @@ -5,14 +5,13 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.rpc.mutations.verify-token (:require [app.common.exceptions :as ex] [app.common.spec :as us] [app.db :as db] - [app.http.session :as session] [app.rpc.mutations.teams :as teams] [app.rpc.queries.profile :as profile] [app.util.services :as sv] @@ -41,8 +40,15 @@ {:id profile-id}) claims) +(defn- annotate-profile-activation + "A helper for properly increase the profile-activation metric once the + transaction is completed." + [metrics] + (fn [] + ((get-in metrics [:definitions :profile-activation]) :inc))) + (defmethod process-token :verify-email - [{:keys [conn session] :as cfg} _params {:keys [profile-id] :as claims}] + [{:keys [conn session metrics] :as cfg} _ {:keys [profile-id] :as claims}] (let [profile (profile/retrieve-profile conn profile-id) claims (assoc claims :profile profile)] @@ -57,14 +63,8 @@ {:id (:id profile)})) (with-meta claims - {:transform-response - (fn [request response] - (let [uagent (get-in request [:headers "user-agent"]) - id (session/create! session {:profile-id profile-id - :user-agent uagent})] - (assoc response - :cookies (session/cookies session {:value id}))))}))) - + {:transform-response ((:create session) profile-id) + :before-complete (annotate-profile-activation metrics)}))) (defmethod process-token :auth [{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}] @@ -91,47 +91,78 @@ :internal.tokens.team-invitation/member-email] :opt-un [:internal.tokens.team-invitation/member-id])) +(defn- accept-invitation + [{:keys [conn] :as cfg} {:keys [member-id team-id role] :as claims}] + (let [params (merge {:team-id team-id + :profile-id member-id} + (teams/role->params role)) + member (profile/retrieve-profile conn member-id)] + + ;; Insert the invited member to the team + (db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true}) + + ;; If profile is not yet verified, mark it as verified because + ;; accepting an invitation link serves as verification. + (when-not (:is-active member) + (db/update! conn :profile + {:is-active true} + {:id member-id})) + (assoc member :is-active true))) + (defmethod process-token :team-invitation - [{:keys [conn session] :as cfg} {:keys [profile-id token]} {:keys [member-id team-id role] :as claims}] + [{:keys [session] :as cfg} {:keys [profile-id token]} {:keys [member-id] :as claims}] (us/assert ::team-invitation-claims claims) - (if (uuid? member-id) - (let [params (merge {:team-id team-id - :profile-id member-id} - (teams/role->params role)) - claims (assoc claims :state :created)] - - (db/insert! conn :team-profile-rel params - {:on-conflict-do-nothing true}) - - (if (and (uuid? profile-id) - (= member-id profile-id)) + (cond + ;; This happens when token is filled with member-id and current + ;; user is already logged in with some account. + (and (uuid? profile-id) + (uuid? member-id)) + (do + (accept-invitation cfg claims) + (if (= member-id profile-id) ;; If the current session is already matches the invited ;; member, then just return the token and leave the frontend ;; app redirect to correct team. - claims + (assoc claims :state :created) - ;; If the session does not matches the invited member id, - ;; replace the session with a new one matching the invited - ;; member. This techinique should be considered secure because - ;; the user clicking the link he already has access to the - ;; email account. - (with-meta claims - {:transform-response - (fn [request response] - (let [uagent (get-in request [:headers "user-agent"]) - id (session/create! session {:profile-id member-id - :user-agent uagent})] - (assoc response - :cookies (session/cookies session {:value id}))))}))) + ;; If the session does not matches the invited member, replace + ;; the session with a new one matching the invited member. + ;; This techinique should be considered secure because the + ;; user clicking the link he already has access to the email + ;; account. + (with-meta + (assoc claims :state :created) + {:transform-response ((:create session) member-id)}))) + + ;; This happens when member-id is not filled in the invitation but + ;; the user already has an account (probably with other mail) and + ;; is already logged-in. + (and (uuid? profile-id) + (nil? member-id)) + (do + (accept-invitation cfg (assoc claims :member-id profile-id)) + (assoc claims :state :created)) + + ;; This happens when member-id is filled but the accessing user is + ;; not logged-in. In this case we proceed to accept invitation and + ;; leave the user logged-in. + (and (nil? profile-id) + (uuid? member-id)) + (do + (accept-invitation cfg claims) + (with-meta + (assoc claims :state :created) + {:transform-response ((:create session) member-id)})) ;; In this case, we wait until frontend app redirect user to ;; registeration page, the user is correctly registered and the ;; register mutation call us again with the same token to finally ;; create the corresponding team-profile relation from the first ;; condition of this if. - (assoc claims - :token token - :state :pending))) + :else + {:invitation-token token + :iss :team-invitation + :state :pending})) ;; --- Default diff --git a/backend/src/app/rpc/queries/files.clj b/backend/src/app/rpc/queries/files.clj index 025e9d66e6..d3e3bfd61c 100644 --- a/backend/src/app/rpc/queries/files.clj +++ b/backend/src/app/rpc/queries/files.clj @@ -256,12 +256,11 @@ ;; --- Helpers (defn decode-row - [{:keys [pages data changes] :as row}] + [{:keys [data changes] :as row}] (when row (cond-> row changes (assoc :changes (blob/decode changes)) - data (assoc :data (blob/decode data)) - pages (assoc :pages (vec (.getArray pages)))))) + data (assoc :data (blob/decode data))))) (def decode-row-xf (comp (map decode-row) diff --git a/backend/src/app/rpc/queries/profile.clj b/backend/src/app/rpc/queries/profile.clj index b02ca6ce91..59ed2e8b03 100644 --- a/backend/src/app/rpc/queries/profile.clj +++ b/backend/src/app/rpc/queries/profile.clj @@ -13,6 +13,7 @@ [app.common.spec :as us] [app.common.uuid :as uuid] [app.db :as db] + [app.db.sql :as sql] [app.util.services :as sv] [clojure.spec.alpha :as s] [cuerdas.core :as str])) @@ -71,6 +72,10 @@ {:default-team-id (:id team) :default-project-id (:id project)})) +(defn populate-additional-data + [conn profile] + (merge profile (retrieve-additional-data conn (:id profile)))) + (defn decode-profile-row [{:keys [props] :as row}] (cond-> row @@ -83,27 +88,21 @@ (defn retrieve-profile [conn id] - (let [profile (some-> (retrieve-profile-data conn id) - (strip-private-attrs) - (merge (retrieve-additional-data conn id)))] + (let [profile (some->> (retrieve-profile-data conn id) + (strip-private-attrs) + (populate-additional-data conn))] (when (nil? profile) (ex/raise :type :not-found :hint "Object doest not exists.")) profile)) - -(def sql:profile-by-email - "select * from profile - where email=? - and deleted_at is null") - (defn retrieve-profile-data-by-email [conn email] - (let [email (str/lower email)] - (-> (db/exec-one! conn [sql:profile-by-email email]) - (decode-profile-row)))) - + (let [sql (sql/select :profile {:email (str/lower email)}) + data (db/exec-one! conn sql)] + (when (and data (nil? (:deleted-at data))) + (decode-profile-row data)))) ;; --- Attrs Helpers diff --git a/backend/src/app/rpc/queries/recent_files.clj b/backend/src/app/rpc/queries/recent_files.clj index 24a5653eac..1791b784be 100644 --- a/backend/src/app/rpc/queries/recent_files.clj +++ b/backend/src/app/rpc/queries/recent_files.clj @@ -41,5 +41,3 @@ (teams/check-read-permissions! conn profile-id team-id) (let [files (db/exec! conn [sql:recent-files team-id])] (into [] decode-row-xf files)))) - - diff --git a/backend/src/app/sprops.clj b/backend/src/app/setup.clj similarity index 60% rename from backend/src/app/sprops.clj rename to backend/src/app/setup.clj index a46b7258e7..21853d79b1 100644 --- a/backend/src/app/sprops.clj +++ b/backend/src/app/setup.clj @@ -7,8 +7,8 @@ ;; ;; Copyright (c) 2020-2021 UXBOX Labs SL -(ns app.sprops - "Server props module." +(ns app.setup + "Initial data setup of instance." (:require [app.common.uuid :as uuid] [app.db :as db] @@ -17,42 +17,45 @@ [clojure.spec.alpha :as s] [integrant.core :as ig])) -(declare initialize) +(declare initialize-instance-id!) +(declare initialize-secret-key!) +(declare retrieve-all) (defmethod ig/pre-init-spec ::props [_] (s/keys :req-un [::db/pool])) (defmethod ig/init-key ::props - [_ cfg] - (initialize cfg)) + [_ {:keys [pool] :as cfg}] + (db/with-atomic [conn pool] + (let [cfg (assoc cfg :conn conn)] + (initialize-secret-key! cfg) + (initialize-instance-id! cfg) + (retrieve-all cfg)))) -(defn- initialize-secret-key +(defn- initialize-secret-key! [{:keys [conn] :as cfg}] (let [key (-> (bn/random-bytes 64) (bc/bytes->b64u) (bc/bytes->str))] - (db/exec-one! conn ["insert into server_prop (id, content) - values ('secret-key', ?) on conflict do nothing" - (db/tjson key)]))) + (db/insert! conn :server-prop + {:id "secret-key" + :preload true + :content (db/tjson key)} + {:on-conflict-do-nothing true}))) -(defn- initialize-instance-id +(defn- initialize-instance-id! [{:keys [conn] :as cfg}] (let [iid (uuid/random)] - (db/exec-one! conn ["insert into server_prop (id, content) - values ('instance-id', ?::jsonb) on conflict do nothing" - (db/tjson iid)]))) + + (db/insert! conn :server-prop + {:id "instance-id" + :preload true + :content (db/tjson iid)} + {:on-conflict-do-nothing true}))) (defn- retrieve-all [{:keys [conn] :as cfg}] (reduce (fn [acc row] (assoc acc (keyword (:id row)) (db/decode-transit-pgobject (:content row)))) {} - (db/exec! conn ["select * from server_prop;"]))) - -(defn- initialize - [{:keys [pool] :as cfg}] - (db/with-atomic [conn pool] - (let [cfg (assoc cfg :conn conn)] - (initialize-secret-key cfg) - (initialize-instance-id cfg) - (retrieve-all cfg)))) + (db/query conn :server-prop {:preload true}))) diff --git a/backend/src/app/setup/initial_data.clj b/backend/src/app/setup/initial_data.clj new file mode 100644 index 0000000000..281616c846 --- /dev/null +++ b/backend/src/app/setup/initial_data.clj @@ -0,0 +1,193 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2021 UXBOX Labs SL + +(ns app.setup.initial-data + (:refer-clojure :exclude [load]) + (:require + [app.common.data :as d] + [app.common.pages.migrations :as pmg] + [app.common.uuid :as uuid] + [app.config :as cfg] + [app.db :as db] + [app.rpc.mutations.projects :as projects] + [app.rpc.queries.profile :as profile] + [app.util.blob :as blob] + [app.util.time :as dt] + [clojure.walk :as walk])) + +;; --- DUMP GENERATION + +(def sql:file + "select * from file where project_id = ?") + +(def sql:file-library-rel + "with file_ids as (select id from file where project_id = ?) + select * + from file_library_rel + where file_id in (select id from file_ids)") + +(def sql:file-media-object + "with file_ids as (select id from file where project_id = ?) + select * + from file_media_object + where file_id in (select id from file_ids)") + +(defn dump + ([system project-id] (dump system project-id nil)) + ([system project-id {:keys [skey project-name] + :or {project-name "Penpot Onboarding"}}] + (db/with-atomic [conn (:app.db/pool system)] + (let [skey (or skey (cfg/get :initial-project-skey)) + files (db/exec! conn [sql:file project-id]) + flibs (db/exec! conn [sql:file-library-rel project-id]) + fmeds (db/exec! conn [sql:file-media-object project-id]) + data {:project-name project-name + :files files + :flibs flibs + :fmeds fmeds}] + + (db/delete! conn :server-prop {:id skey}) + (db/insert! conn :server-prop + {:id skey + :preload false + :content (db/tjson data)}) + skey)))) + + +;; --- DUMP LOADING + +(defn- process-file + [file index] + (letfn [(process-form [form] + (cond-> form + ;; Relink Components + (and (map? form) + (uuid? (:component-file form))) + (update :component-file #(get index % %)) + + ;; Relink Image Shapes + (and (map? form) + (map? (:metadata form)) + (= :image (:type form))) + (update-in [:metadata :id] #(get index % %)))) + + ;; A function responsible to analize all file data and + ;; replace the old :component-file reference with the new + ;; ones, using the provided file-index + (relink-shapes [data] + (walk/postwalk process-form data)) + + ;; A function responsible of process the :media attr of file + ;; data and remap the old ids with the new ones. + (relink-media [media] + (reduce-kv (fn [res k v] + (let [id (get index k)] + (if (uuid? id) + (-> res + (assoc id (assoc v :id id)) + (dissoc k)) + res))) + media + media))] + + (update file :data + (fn [data] + (-> data + (blob/decode) + (assoc :id (:id file)) + (pmg/migrate-data) + (update :pages-index relink-shapes) + (update :components relink-shapes) + (update :media relink-media) + (d/without-nils) + (blob/encode)))))) + +(defn- remap-id + [item index key] + (cond-> item + (contains? item key) + (assoc key (get index (get item key) (get item key))))) + +(defn- retrieve-data + [conn skey] + (when-let [row (db/exec-one! conn ["select content from server_prop where id = ?" skey])] + (when-let [content (:content row)] + (when (db/pgobject? content) + (db/decode-transit-pgobject content))))) + +(defn load-initial-project! + ([conn profile] (load-initial-project! conn profile nil)) + ([conn profile opts] + (let [skey (or (:skey opts) (cfg/get :initial-project-skey)) + data (retrieve-data conn skey)] + (when data + (let [project (projects/create-project conn {:profile-id (:id profile) + :team-id (:default-team-id profile) + :name (:project-name data)}) + + now (dt/now) + ignore (dt/plus now (dt/duration {:seconds 5})) + index (as-> {} index + (reduce #(assoc %1 (:id %2) (uuid/next)) index (:files data)) + (reduce #(assoc %1 (:id %2) (uuid/next)) index (:fmeds data))) + + flibs (->> (:flibs data) + (map #(remap-id % index :file-id)) + (map #(remap-id % index :library-file-id)) + (map #(assoc % :synced-at now)) + (map #(assoc % :created-at now))) + + files (->> (:files data) + (map #(assoc % :id (get index (:id %)))) + (map #(assoc % :project-id (:id project))) + (map #(assoc % :created-at now)) + (map #(assoc % :modified-at now)) + (map #(assoc % :ignore-sync-until ignore)) + (map #(process-file % index))) + + fmeds (->> (:fmeds data) + (map #(assoc % :id (get index (:id %)))) + (map #(assoc % :created-at now)) + (map #(remap-id % index :file-id))) + + fprofs (map #(array-map :file-id (:id %) + :profile-id (:id profile) + :is-owner true + :is-admin true + :can-edit true) files)] + + (projects/create-project-profile conn {:project-id (:id project) + :profile-id (:id profile)}) + + (projects/create-team-project-profile conn {:team-id (:default-team-id profile) + :project-id (:id project) + :profile-id (:id profile)}) + + ;; Re-insert into the database + (doseq [params files] + (db/insert! conn :file params)) + + (doseq [params fprofs] + (db/insert! conn :file-profile-rel params)) + + (doseq [params flibs] + (db/insert! conn :file-library-rel params)) + + (doseq [params fmeds] + (db/insert! conn :file-media-object params))))))) + +(defn load + [system {:keys [email] :as opts}] + (db/with-atomic [conn (:app.db/pool system)] + (when-let [profile (some->> email + (profile/retrieve-profile-data-by-email conn) + (profile/populate-additional-data conn))] + (load-initial-project! conn profile opts) + true))) + diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index c626a6defd..8dd2cf197f 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -6,7 +6,6 @@ [app.common.pages.migrations :as pmg] [app.config :as cfg] [app.db :as db] - [app.db.profile-initial-data :as pid] [app.main :refer [system]] [app.rpc.queries.profile :as prof] [app.srepl.dev :as dev] @@ -53,27 +52,6 @@ ;; (fn [{:keys [data] :as file}] ;; (update-in data [:pages-index #uuid "878278c0-3ef0-11eb-9d67-8551e7624f43" :objects] dissoc nil)))) -(def default-project-id #uuid "5761a890-3b81-11eb-9e7d-556a2f641513") - -(defn initial-data-dump - ([system file] (initial-data-dump system default-project-id file)) - ([system project-id path] - (db/with-atomic [conn (:app.db/pool system)] - (pid/create-initial-data-dump conn project-id path)))) - -(defn load-data-into-user - ([system user-email] - (if-let [file (:initial-data-file cfg/config)] - (load-data-into-user system file user-email) - (prn "Data file not found in configuration"))) - - ([system file user-email] - (db/with-atomic [conn (:app.db/pool system)] - (let [profile (prof/retrieve-profile-data-by-email conn user-email) - profile (merge profile (prof/retrieve-additional-data conn (:id profile)))] - (pid/create-profile-initial-data conn file profile))))) - - ;; Migrate (defn update-file-data-blob-format diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index 7e5371d502..94e7e3b703 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -308,7 +308,7 @@ (if-let [[groups total] (retrieve-deleted-objects conn)] (do (run! (partial delete-in-bulk conn) groups) - (recur (+ n total))) + (recur (+ n ^long total))) (do (log/infof "gc-deleted: processed %s items" n) {:deleted n}))))))) diff --git a/backend/src/app/storage/impl.clj b/backend/src/app/storage/impl.clj index 450ae718c5..00f356f569 100644 --- a/backend/src/app/storage/impl.clj +++ b/backend/src/app/storage/impl.clj @@ -165,7 +165,7 @@ (string->content data) (bytes? data) - (input-stream->content (ByteArrayInputStream. ^bytes data) (alength data)) + (input-stream->content (ByteArrayInputStream. ^bytes data) (alength ^bytes data)) (instance? InputStream data) (do diff --git a/backend/src/app/svgparse.clj b/backend/src/app/svgparse.clj index f9d658663e..9f1662f2d6 100644 --- a/backend/src/app/svgparse.clj +++ b/backend/src/app/svgparse.clj @@ -124,7 +124,7 @@ (try (with-open [istream (IOUtils/toInputStream data "UTF-8")] (xml/parse istream)) - (catch org.xml.sax.SAXParseException _e + (catch Exception _e (ex/raise :type :validation :code :invalid-svg-file)))) diff --git a/backend/src/app/tasks.clj b/backend/src/app/tasks.clj index 2a3eca68d5..9ac9a3a8ba 100644 --- a/backend/src/app/tasks.clj +++ b/backend/src/app/tasks.clj @@ -5,17 +5,19 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.tasks (:require [app.common.spec :as us] [app.common.uuid :as uuid] [app.db :as db] - ;; [app.metrics :as mtx] + [app.metrics :as mtx] [app.util.time :as dt] + [app.worker] [clojure.spec.alpha :as s] - [clojure.tools.logging :as log])) + [clojure.tools.logging :as log] + [integrant.core :as ig])) (s/def ::name ::us/string) (s/def ::delay @@ -41,11 +43,68 @@ interval (db/interval duration) props (db/tjson props) id (uuid/next)] - (log/infof "Submit task '%s' to be executed in '%s'." name (str duration)) + (log/debugf "submit task '%s' to be executed in '%s'" name (str duration)) (db/exec-one! conn [sql:insert-new-task id name props queue priority max-retries interval]) id)) -;; (mtx/instrument-with-counter! -;; {:var #'submit! -;; :id "tasks__submit_counter" -;; :help "Absolute task submit counter."}) +(defn- instrument! + [registry] + (mtx/instrument-vars! + [#'submit!] + {:registry registry + :type :counter + :labels ["name"] + :name "tasks_submit_counter" + :help "An absolute counter of task submissions." + :wrap (fn [rootf mobj] + (let [mdata (meta rootf) + origf (::original mdata rootf)] + (with-meta + (fn [conn params] + (let [tname (:name params)] + (mobj :inc [tname]) + (origf conn params))) + {::original origf})))}) + + (mtx/instrument-vars! + [#'app.worker/run-task] + {:registry registry + :type :summary + :quantiles [] + :name "tasks_checkout_timing" + :help "Latency measured between scheduld_at and execution time." + :wrap (fn [rootf mobj] + (let [mdata (meta rootf) + origf (::original mdata rootf)] + (with-meta + (fn [tasks item] + (let [now (inst-ms (dt/now)) + sat (inst-ms (:scheduled-at item))] + (mobj :observe (- now sat)) + (origf tasks item))) + {::original origf})))})) + +;; --- STATE INIT: REGISTRY + +(s/def ::tasks + (s/map-of keyword? fn?)) + +(defmethod ig/pre-init-spec ::registry [_] + (s/keys :req-un [::mtx/metrics ::tasks])) + +(defmethod ig/init-key ::registry + [_ {:keys [metrics tasks]}] + (instrument! (:registry metrics)) + (let [mobj (mtx/create + {:registry (:registry metrics) + :type :summary + :labels ["name"] + :quantiles [] + :name "tasks_timing" + :help "Background task execution timing."})] + (reduce-kv (fn [res k v] + (let [tname (name k)] + (log/debugf "registring task '%s'" tname) + (assoc res tname (mtx/wrap-summary v mobj [tname])))) + {} + tasks))) diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index a65a75b1ec..78fd470071 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -12,42 +12,32 @@ (:require [app.common.spec :as us] [app.db :as db] - [app.metrics :as mtx] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) -(declare handler) (declare handle-deletion) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool ::mtx/metrics])) + (s/keys :req-un [::db/pool])) (defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_delete_object_timing" - :help "delete object task timing"} - (mtx/instrument handler)))) + [_ {:keys [pool] :as cfg}] + (fn [{:keys [props] :as task}] + (us/verify ::props props) + (db/with-atomic [conn pool] + (handle-deletion conn props)))) (s/def ::type ::us/keyword) (s/def ::id ::us/uuid) (s/def ::props (s/keys :req-un [::id ::type])) -(defn- handler - [{:keys [pool]} {:keys [props] :as task}] - (us/verify ::props props) - (db/with-atomic [conn pool] - (handle-deletion conn props))) - (defmulti handle-deletion (fn [_ props] (:type props))) (defmethod handle-deletion :default [_conn {:keys [type]}] - (log/warnf "no handler found for %s" type)) + (log/warnf "no handler found for '%s'" type)) (defmethod handle-deletion :file [conn {:keys [id] :as props}] diff --git a/backend/src/app/tasks/delete_profile.clj b/backend/src/app/tasks/delete_profile.clj index 2f8a2a668d..923ccf8146 100644 --- a/backend/src/app/tasks/delete_profile.clj +++ b/backend/src/app/tasks/delete_profile.clj @@ -13,27 +13,16 @@ [app.common.spec :as us] [app.db :as db] [app.db.sql :as sql] - [app.metrics :as mtx] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) (declare delete-profile-data) -(declare handler) ;; --- INIT (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool ::mtx/metrics])) - -(defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_delete_profile_timing" - :help "delete profile task timing"} - (mtx/instrument handler)))) + (s/keys :req-un [::db/pool])) ;; This task is responsible to permanently delete a profile with all ;; the dependent data. As step (1) we delete all owned teams of the @@ -48,16 +37,17 @@ (s/def ::profile-id ::us/uuid) (s/def ::props (s/keys :req-un [::profile-id])) -(defn handler - [{:keys [pool]} {:keys [props] :as task}] - (us/verify ::props props) - (db/with-atomic [conn pool] - (let [id (:profile-id props) - profile (db/exec-one! conn (sql/select :profile {:id id} {:for-update true}))] - (if (or (:is-demo profile) - (:deleted-at profile)) - (delete-profile-data conn id) - (log/warnf "Profile %s does not match constraints for deletion" id))))) +(defmethod ig/init-key ::handler + [_ {:keys [pool] :as cfg}] + (fn [{:keys [props] :as task}] + (us/verify ::props props) + (db/with-atomic [conn pool] + (let [id (:profile-id props) + profile (db/exec-one! conn (sql/select :profile {:id id} {:for-update true}))] + (if (or (:is-demo profile) + (:deleted-at profile)) + (delete-profile-data conn id) + (log/warnf "profile '%s' does not match constraints for deletion" id)))))) ;; --- IMPL @@ -80,7 +70,7 @@ (defn- delete-profile-data [conn profile-id] - (log/infof "Proceding to delete all data related to profile id = %s" profile-id) + (log/debugf "proceding to delete all data related to profile '%s'" profile-id) (delete-teams conn profile-id) (delete-profile conn profile-id) true) diff --git a/backend/src/app/tasks/file_media_gc.clj b/backend/src/app/tasks/file_media_gc.clj index dd9939e0fa..eebd434b50 100644 --- a/backend/src/app/tasks/file_media_gc.clj +++ b/backend/src/app/tasks/file_media_gc.clj @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.tasks.file-media-gc "A maintenance task that is responsible to purge the unused media @@ -14,44 +14,34 @@ (:require [app.common.pages.migrations :as pmg] [app.db :as db] - [app.metrics :as mtx] [app.util.blob :as blob] [app.util.time :as dt] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) -(declare handler) (declare process-file) (declare retrieve-candidates) (s/def ::max-age ::dt/duration) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool ::mtx/metrics ::max-age])) + (s/keys :req-un [::db/pool ::max-age])) (defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_file_media_gc_timing" - :help "file media garbage collection task timing"} - (mtx/instrument handler)))) - -(defn- handler - [{:keys [pool] :as cfg} _] - (db/with-atomic [conn pool] - (let [cfg (assoc cfg :conn conn)] - (loop [n 0] - (let [files (retrieve-candidates cfg)] - (if (seq files) - (do - (run! (partial process-file cfg) files) - (recur (+ n (count files)))) - (do - (log/infof "finalized with total of %s processed files" n) - {:processed n}))))))) + [_ {:keys [pool] :as cfg}] + (fn [_] + (db/with-atomic [conn pool] + (let [cfg (assoc cfg :conn conn)] + (loop [n 0] + (let [files (retrieve-candidates cfg)] + (if (seq files) + (do + (run! (partial process-file cfg) files) + (recur (+ n (count files)))) + (do + (log/debugf "finalized with total of %s processed files" n) + {:processed n})))))))) (def ^:private sql:retrieve-candidates-chunk @@ -98,7 +88,7 @@ unused (->> (db/query conn :file-media-object {:file-id id}) (remove #(contains? used (:id %))))] - (log/infof "processing file: id='%s' age='%s' to-delete=%s" id age (count unused)) + (log/debugf "processing file: id='%s' age='%s' to-delete=%s" id age (count unused)) ;; Mark file as trimmed (db/update! conn :file diff --git a/backend/src/app/tasks/file_xlog_gc.clj b/backend/src/app/tasks/file_xlog_gc.clj index 4b90200c33..d333f2ac52 100644 --- a/backend/src/app/tasks/file_xlog_gc.clj +++ b/backend/src/app/tasks/file_xlog_gc.clj @@ -5,45 +5,36 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.tasks.file-xlog-gc "A maintenance task that performs a garbage collection of the file change (transaction) log." (:require [app.db :as db] - [app.metrics :as mtx] [app.util.time :as dt] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) -(declare handler) +(declare sql:delete-files-xlog) (s/def ::max-age ::dt/duration) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool ::mtx/metrics ::max-age])) + (s/keys :req-un [::db/pool ::max-age])) (defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_file_xlog_gc_timing" - :help "file changes garbage collection task timing"} - (mtx/instrument handler)))) + [_ {:keys [pool max-age] :as cfg}] + (fn [_] + (db/with-atomic [conn pool] + (let [interval (db/interval max-age) + result (db/exec-one! conn [sql:delete-files-xlog interval]) + result (:next.jdbc/update-count result)] + (log/debugf "removed %s rows from file-change table" result) + result)))) (def ^:private sql:delete-files-xlog "delete from file_change where created_at < now() - ?::interval") - -(defn- handler - [{:keys [pool max-age]} _] - (db/with-atomic [conn pool] - (let [interval (db/interval max-age) - result (db/exec-one! conn [sql:delete-files-xlog interval]) - result (:next.jdbc/update-count result)] - (log/infof "removed %s rows from file_change table" result) - nil))) diff --git a/backend/src/app/tasks/sendmail.clj b/backend/src/app/tasks/sendmail.clj index 78315a2b75..0619b75a23 100644 --- a/backend/src/app/tasks/sendmail.clj +++ b/backend/src/app/tasks/sendmail.clj @@ -10,13 +10,12 @@ (ns app.tasks.sendmail (:require [app.config :as cfg] - [app.metrics :as mtx] [app.util.emails :as emails] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) -(declare handler) +(declare send-console!) (s/def ::username ::cfg/smtp-username) (s/def ::password ::cfg/smtp-password) @@ -29,7 +28,7 @@ (s/def ::enabled ::cfg/smtp-enabled) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::enabled ::mtx/metrics] + (s/keys :req-un [::enabled] :opt-un [::username ::password ::tls @@ -40,13 +39,11 @@ ::default-reply-to])) (defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_sendmail_timing" - :help "sendmail task timing"} - (mtx/instrument handler)))) + [_ cfg] + (fn [{:keys [props] :as task}] + (if (:enabled cfg) + (emails/send! cfg props) + (send-console! cfg props)))) (defn- send-console! [cfg email] @@ -59,9 +56,3 @@ (println (.toString baos)) (println "******** end email "(:id email) "**********"))] (log/info out)))) - -(defn handler - [cfg {:keys [props] :as task}] - (if (:enabled cfg) - (emails/send! cfg props) - (send-console! cfg props))) diff --git a/backend/src/app/tasks/tasks_gc.clj b/backend/src/app/tasks/tasks_gc.clj index 975bfea8c5..3ff4e8db05 100644 --- a/backend/src/app/tasks/tasks_gc.clj +++ b/backend/src/app/tasks/tasks_gc.clj @@ -5,46 +5,36 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.tasks.tasks-gc "A maintenance task that performs a cleanup of already executed tasks from the database table." (:require [app.db :as db] - [app.metrics :as mtx] [app.util.time :as dt] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] [integrant.core :as ig])) -(declare handler) +(declare sql:delete-completed-tasks) (s/def ::max-age ::dt/duration) (defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool ::mtx/metrics ::max-age])) + (s/keys :req-un [::db/pool ::max-age])) (defmethod ig/init-key ::handler - [_ {:keys [metrics] :as cfg}] - (let [handler #(handler cfg %)] - (->> {:registry (:registry metrics) - :type :summary - :name "task_tasks_gc_timing" - :help "tasks garbage collection task timing"} - (mtx/instrument handler)))) + [_ {:keys [pool max-age] :as cfg}] + (fn [_] + (db/with-atomic [conn pool] + (let [interval (db/interval max-age) + result (db/exec-one! conn [sql:delete-completed-tasks interval]) + result (:next.jdbc/update-count result)] + (log/debugf "removed %s rows from tasks-completed table" result) + result)))) (def ^:private sql:delete-completed-tasks "delete from task_completed where scheduled_at < now() - ?::interval") - -(defn- handler - [{:keys [pool max-age]} _] - (db/with-atomic [conn pool] - (let [interval (db/interval max-age) - result (db/exec-one! conn [sql:delete-completed-tasks interval]) - result (:next.jdbc/update-count result)] - (log/infof "removed %s rows from tasks_completed table" result) - nil))) - diff --git a/backend/src/app/telemetry.clj b/backend/src/app/telemetry.clj index a8e5edae70..a5268ef23d 100644 --- a/backend/src/app/telemetry.clj +++ b/backend/src/app/telemetry.clj @@ -88,7 +88,7 @@ (catch Exception e ;; We don't want notify user of a error, just log it for posible ;; future investigation. - (log/warn e (str "Unexpected error on telemetry:\n" + (log/warn e (str "unexpected error on telemetry:\n" (when-let [edata (ex-data e)] (str "ex-data: \n" (with-out-str (pprint edata)))) @@ -118,4 +118,4 @@ data data]))) (catch Exception e - (log/errorf e "Error on procesing request.")))) + (log/errorf e "error on procesing request")))) diff --git a/backend/src/app/tokens.clj b/backend/src/app/tokens.clj index a71c49dae1..4abbca8550 100644 --- a/backend/src/app/tokens.clj +++ b/backend/src/app/tokens.clj @@ -60,11 +60,25 @@ (defmethod ig/pre-init-spec ::tokens [_] (s/keys :req-un [::sprops])) +(defn- generate-predefined + [cfg {:keys [iss profile-id] :as params}] + (case iss + :profile-identity + (do + (us/verify uuid? profile-id) + (generate cfg (assoc params + :exp (dt/in-future {:days 30})))) + + (ex/raise :type :internal + :code :not-implemented + :hint "no predefined token"))) + (defmethod ig/init-key ::tokens [_ {:keys [sprops] :as cfg}] (let [secret (derive-tokens-secret (:secret-key sprops)) cfg (assoc cfg ::secret secret)] (fn [action params] (case action + :generate-predefined (generate-predefined cfg params) :verify (verify cfg params) :generate (generate cfg params))))) diff --git a/backend/src/app/util/async.clj b/backend/src/app/util/async.clj index aa0952e262..c78ab70e2b 100644 --- a/backend/src/app/util/async.clj +++ b/backend/src/app/util/async.clj @@ -46,8 +46,7 @@ (fn [] (try (let [ret (try (f) (catch Exception e e))] - (when-not (nil? ret) - (a/>!! c ret))) + (when (some? ret) (a/>!! c ret))) (finally (a/close! c))))) c diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index 332aedaeb4..dd1624b1f9 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -37,7 +37,7 @@ (defn encode ([data] (encode data nil)) ([data {:keys [version] :or {version default-version}}] - (case version + (case (long version) 1 (encode-v1 data) 2 (encode-v2 data) (throw (ex-info "unsupported version" {:version version}))))) @@ -81,7 +81,7 @@ (defn- encode-v2 [data] (let [data (n/fast-freeze data) - dlen (alength data) + dlen (alength ^bytes data) mlen (Zstd/compressBound dlen) cdata (byte-array mlen) clen (Zstd/compressByteArray ^bytes cdata 0 mlen diff --git a/backend/src/app/util/emails.clj b/backend/src/app/util/emails.clj index 813cf43671..a2111d6f80 100644 --- a/backend/src/app/util/emails.clj +++ b/backend/src/app/util/emails.clj @@ -161,7 +161,7 @@ (.setDebug session debug) session)) -(defn smtp-message +(defn ^MimeMessage smtp-message [cfg message] (let [^Session session (smtp-session cfg)] (build-message cfg session message))) diff --git a/backend/src/app/util/json.clj b/backend/src/app/util/json.clj index 7b3013a972..042517c62d 100644 --- a/backend/src/app/util/json.clj +++ b/backend/src/app/util/json.clj @@ -16,10 +16,18 @@ [v] (j/write-value-as-string v j/keyword-keys-object-mapper)) +(defn encode + [v] + (j/write-value-as-bytes v j/keyword-keys-object-mapper)) + (defn decode-str [v] (j/read-value v j/keyword-keys-object-mapper)) +(defn decode + [v] + (j/read-value v j/keyword-keys-object-mapper)) + (defn read [v] (j/read-value v j/keyword-keys-object-mapper)) diff --git a/backend/src/app/util/redis.clj b/backend/src/app/util/redis.clj deleted file mode 100644 index 0be8b5b465..0000000000 --- a/backend/src/app/util/redis.clj +++ /dev/null @@ -1,166 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.util.redis - "Asynchronous posgresql client." - (:refer-clojure :exclude [run!]) - (:require - [clojure.core.async :as a] - [promesa.core :as p]) - (:import - io.lettuce.core.RedisClient - io.lettuce.core.RedisURI - io.lettuce.core.codec.StringCodec - io.lettuce.core.api.async.RedisAsyncCommands - io.lettuce.core.api.StatefulRedisConnection - io.lettuce.core.pubsub.RedisPubSubListener - io.lettuce.core.pubsub.StatefulRedisPubSubConnection - io.lettuce.core.pubsub.api.sync.RedisPubSubCommands - )) - -(defrecord Client [^RedisClient inner - ^RedisURI uri] - clojure.lang.IDeref - (deref [_] inner) - - java.lang.AutoCloseable - (close [_] - (.shutdown inner))) - -(defrecord Connection [^StatefulRedisConnection inner - ^RedisAsyncCommands cmd] - clojure.lang.IDeref - (deref [_] inner) - - java.lang.AutoCloseable - (close [_] - (.close ^StatefulRedisConnection inner))) - -(defn client - [uri] - (->Client (RedisClient/create) - (RedisURI/create uri))) - -(defn connect - [{:keys [uri] :as client}] - (let [conn (.connect ^RedisClient @client StringCodec/UTF8 ^RedisURI uri)] - (->Connection conn (.async ^StatefulRedisConnection conn)))) - -(defn- impl-subscribe - [topics xform ^StatefulRedisPubSubConnection conn] - (let [cmd (.sync conn) - output (a/chan 1 (comp (filter string?) xform)) - buffer (a/chan (a/sliding-buffer 64)) - sub (reify RedisPubSubListener - (message [it pattern channel message]) - (message [it channel message] - ;; There are no back pressure, so we use a slidding - ;; buffer for cases when the pubsub broker sends - ;; more messages that we can process. - (a/put! buffer message)) - (psubscribed [it pattern count]) - (punsubscribed [it pattern count]) - (subscribed [it channel count]) - (unsubscribed [it channel count]))] - - ;; Start message event-loop (with keepalive mechanism) - (a/go-loop [] - (let [[val port] (a/alts! [buffer (a/timeout 5000)]) - message (if (= port buffer) val ::keepalive)] - (if (a/>! output message) - (recur) - (do - (a/close! buffer) - (.removeListener conn sub) - (when (.isOpen conn) - (.close conn)))))) - - ;; Synchronously subscribe to topics - (.addListener conn sub) - (.subscribe ^RedisPubSubCommands cmd topics) - - ;; Return the output channel - output)) - -(defn subscribe - [{:keys [uri] :as client} {:keys [topics xform]}] - (let [topics (if (vector? topics) - (into-array String (map str topics)) - (into-array String [(str topics)]))] - (->> (.connectPubSub ^RedisClient @client StringCodec/UTF8 ^RedisURI uri) - (impl-subscribe topics xform)))) - -(defn- resolve-to-bool - [v] - (if (= v 1) - true - false)) - -(defmulti impl-run (fn [_ cmd _] cmd)) - -(defn run! - [conn cmd params] - (let [^RedisAsyncCommands conn (:cmd conn)] - (impl-run conn cmd params))) - -(defn run - [conn cmd params] - (let [res (a/chan 1)] - (if (instance? Connection conn) - (-> (run! conn cmd params) - (p/finally (fn [v e] - (if e - (a/offer! res e) - (a/offer! res v))))) - (a/close! res)) - res)) - -(defmethod impl-run :get - [conn _ {:keys [key]}] - (.get ^RedisAsyncCommands conn ^String key)) - -(defmethod impl-run :set - [conn _ {:keys [key val]}] - (.set ^RedisAsyncCommands conn ^String key ^String val)) - -(defmethod impl-run :smembers - [conn _ {:keys [key]}] - (-> (.smembers ^RedisAsyncCommands conn ^String key) - (p/then' #(into #{} %)))) - -(defmethod impl-run :sadd - [conn _ {:keys [key val]}] - (let [keys (into-array String [val])] - (-> (.sadd ^RedisAsyncCommands conn ^String key ^"[S;" keys) - (p/then resolve-to-bool)))) - -(defmethod impl-run :srem - [conn _ {:keys [key val]}] - (let [keys (into-array String [val])] - (-> (.srem ^RedisAsyncCommands conn ^String key ^"[S;" keys) - (p/then resolve-to-bool)))) - -(defmethod impl-run :publish - [conn _ {:keys [channel message]}] - (-> (.publish ^RedisAsyncCommands conn ^String channel ^String message) - (p/then resolve-to-bool))) - -(defmethod impl-run :hset - [^RedisAsyncCommands conn _ {:keys [key field value]}] - (.hset conn key field value)) - -(defmethod impl-run :hgetall - [^RedisAsyncCommands conn _ {:keys [key]}] - (.hgetall conn key)) - -(defmethod impl-run :hdel - [^RedisAsyncCommands conn _ {:keys [key field]}] - (let [fields (into-array String [field])] - (.hdel conn key fields))) - diff --git a/backend/src/app/util/services.clj b/backend/src/app/util/services.clj index e652182beb..edc8c1074e 100644 --- a/backend/src/app/util/services.clj +++ b/backend/src/app/util/services.clj @@ -21,7 +21,7 @@ ::spec sname ::name (name sname)) - sym (symbol (str "service-method-" (name sname)))] + sym (symbol (str "sm$" (name sname)))] `(do (def ~sym (fn ~args ~@body)) (reset-meta! (var ~sym) ~mdata)))) diff --git a/backend/src/app/util/svg.clj b/backend/src/app/util/svg.clj deleted file mode 100644 index 04d404a813..0000000000 --- a/backend/src/app/util/svg.clj +++ /dev/null @@ -1,101 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.util.svg - "Icons SVG parsing helpers." - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [clojure.spec.alpha :as s] - [cuerdas.core :as str]) - (:import - org.jsoup.Jsoup - org.jsoup.nodes.Attribute - org.jsoup.nodes.Element - org.jsoup.nodes.Document)) - -(s/def ::content string?) -(s/def ::width ::us/number) -(s/def ::height ::us/number) -(s/def ::name string?) -(s/def ::view-box (s/coll-of ::us/number :min-count 4 :max-count 4)) - -(s/def ::svg-entity - (s/keys :req-un [::content ::width ::height ::view-box] - :opt-un [::name])) - -;; --- Implementation - -(defn- parse-double - [data] - (s/assert ::us/string data) - (Double/parseDouble data)) - -(defn- parse-viewbox - [data] - (s/assert ::us/string data) - (mapv parse-double (str/split data #"\s+"))) - -(defn- parse-attrs - [^Element element] - (persistent! - (reduce (fn [acc ^Attribute attr] - (let [key (.getKey attr) - val (.getValue attr)] - (case key - "width" (assoc! acc :width (parse-double val)) - "height" (assoc! acc :height (parse-double val)) - "viewbox" (assoc! acc :view-box (parse-viewbox val)) - "sodipodi:docname" (assoc! acc :name val) - acc))) - (transient {}) - (.attributes element)))) - -(defn- impl-parse - [data] - (try - (let [document (Jsoup/parse ^String data) - element (some-> (.body ^Document document) - (.getElementsByTag "svg") - (first)) - content (.html element) - attrs (parse-attrs element)] - (assoc attrs :content content)) - (catch java.lang.IllegalArgumentException _e - (ex/raise :type :validation - :code ::invalid-input - :message "Input does not seems to be a valid svg.")) - (catch java.lang.NullPointerException _e - (ex/raise :type :validation - :code ::invalid-input - :message "Input does not seems to be a valid svg.")) - (catch org.jsoup.UncheckedIOException _e - (ex/raise :type :validation - :code ::invalid-input - :message "Input does not seems to be a valid svg.")) - (catch Exception _e - (ex/raise :type :internal - :code ::unexpected)))) - -;; --- Public Api - -(defn parse-string - "Parse SVG from a string." - [data] - (s/assert ::us/string data) - (let [result (impl-parse data)] - (if (s/valid? ::svg-entity result) - result - (ex/raise :type :validation - :code ::invalid-result - :message "The result does not conform valid svg entity.")))) - -(defn parse - [data] - (parse-string (slurp data))) diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj index be70128720..907098a775 100644 --- a/backend/src/app/util/time.clj +++ b/backend/src/app/util/time.clj @@ -93,6 +93,10 @@ [t1 t2] (Duration/between t1 t2)) +(defn instant + [ms] + (Instant/ofEpochMilli ms)) + (defn parse-duration [s] (Duration/parse s)) diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 145e1ce583..fe078ba02b 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -10,9 +10,9 @@ (ns app.worker "Async tasks abstraction (impl)." (:require + [app.common.exceptions :as ex] [app.common.spec :as us] [app.common.uuid :as uuid] - [app.config :as cfg] [app.db :as db] [app.util.async :as aa] [app.util.log4j :refer [update-thread-context!]] @@ -20,6 +20,7 @@ [clojure.core.async :as a] [clojure.spec.alpha :as s] [clojure.tools.logging :as log] + [cuerdas.core :as str] [integrant.core :as ig] [promesa.exec :as px]) (:import @@ -73,7 +74,7 @@ (s/def ::queue ::us/string) (s/def ::parallelism ::us/integer) (s/def ::batch-size ::us/integer) -(s/def ::tasks (s/map-of string? ::us/fn)) +(s/def ::tasks (s/map-of string? fn?)) (s/def ::poll-interval ::dt/duration) (defmethod ig/pre-init-spec ::worker [_] @@ -95,7 +96,7 @@ (defmethod ig/init-key ::worker [_ {:keys [pool poll-interval name queue] :as cfg}] - (log/infof "Starting worker '%s' on queue '%s'." name queue) + (log/infof "starting worker '%s' on queue '%s'" name queue) (let [cch (a/chan 1) poll-ms (inst-ms poll-interval)] (a/go-loop [] @@ -104,30 +105,30 @@ ;; Terminate the loop if close channel is closed or ;; event-loop-fn returns nil. (or (= port cch) (nil? val)) - (log/infof "Stop condition found. Shutdown worker: '%s'" name) + (log/infof "stop condition found; shutdown worker: '%s'" name) (db/pool-closed? pool) (do - (log/info "Worker eventloop is aborted because pool is closed.") + (log/info "worker eventloop is aborted because pool is closed") (a/close! cch)) (and (instance? java.sql.SQLException val) (contains? #{"08003" "08006" "08001" "08004"} (.getSQLState ^java.sql.SQLException val))) (do - (log/error "Connection error, trying resume in some instants.") + (log/error "connection error, trying resume in some instants") (a/= (:retry-num item) (:max-retries item)) {:status :failed :task item :error error} {:status :retry :task item :error error}))))) @@ -240,12 +237,12 @@ (defn- run-task [{:keys [tasks]} item] (try - (log/debugf "Started task '%s/%s/%s'." (:name item) (:id item) (:retry-num item)) + (log/debugf "started task '%s/%s/%s'" (:name item) (:id item) (:retry-num item)) (handle-task tasks item) (catch Exception e (handle-exception e item)) (finally - (log/debugf "Finished task '%s/%s/%s'." (:name item) (:id item) (:retry-num item))))) + (log/debugf "finished task '%s/%s/%s'" (:name item) (:id item) (:retry-num item))))) (def sql:select-next-tasks "select * from task as t @@ -294,21 +291,31 @@ (s/def ::id ::us/string) (s/def ::cron dt/cron?) (s/def ::props (s/nilable map?)) +(s/def ::task keyword?) (s/def ::scheduled-task-spec - (s/keys :req-un [::id ::cron ::fn] + (s/keys :req-un [::id ::cron ::task] :opt-un [::props])) -(s/def ::schedule - (s/coll-of (s/nilable ::scheduled-task-spec))) +(s/def ::schedule (s/coll-of (s/nilable ::scheduled-task-spec))) (defmethod ig/pre-init-spec ::scheduler [_] - (s/keys :req-un [::executor ::db/pool ::schedule])) + (s/keys :req-un [::executor ::db/pool ::schedule ::tasks])) (defmethod ig/init-key ::scheduler - [_ {:keys [schedule] :as cfg}] + [_ {:keys [schedule tasks] :as cfg}] (let [scheduler (Executors/newScheduledThreadPool (int 1)) - schedule (filter some? schedule) + schedule (->> schedule + (filter some?) + (map (fn [{:keys [task] :as item}] + (let [f (get tasks (name task))] + (when-not f + (ex/raise :type :internal + :code :task-not-found + :hint (str/fmt "task %s not configured" task))) + (-> item + (dissoc :task) + (assoc :fn f)))))) cfg (assoc cfg :scheduler scheduler :schedule schedule)] @@ -335,7 +342,7 @@ (defn- synchronize-schedule-item [conn {:keys [id cron]}] (let [cron (str cron)] - (log/debugf "initialize scheduled task '%s' (cron: '%s')." id cron) + (log/infof "initialize scheduled task '%s' (cron: '%s')" id cron) (db/exec-one! conn [sql:upsert-scheduled-task id cron cron]))) (defn- synchronize-schedule @@ -356,27 +363,16 @@ (letfn [(run-task [conn] (try (when (db/exec-one! conn [sql:lock-scheduled-task id]) - (log/info "Executing scheduled task" id) + (log/debugf "executing scheduled task '%s'" id) ((:fn task) task)) - (catch Exception e + (catch Throwable e e))) - (handle-task* [conn] - (let [result (run-task conn)] - (if (instance? Throwable result) - (do - (log/warnf result "unhandled exception on scheduled task '%s'" id) - (db/insert! conn :scheduled-task-history - {:id (uuid/next) - :task-id id - :is-error true - :reason (exception->string result)})) - (db/insert! conn :scheduled-task-history - {:id (uuid/next) - :task-id id})))) (handle-task [] (db/with-atomic [conn pool] - (handle-task* conn)))] + (let [result (run-task conn)] + (when (ex/exception? result) + (log/errorf result "unhandled exception on scheduled task '%s'" id)))))] (try (px/run! executor handle-task) diff --git a/backend/tests/app/tests/helpers.clj b/backend/tests/app/tests/helpers.clj index 3709b54657..1804a81b02 100644 --- a/backend/tests/app/tests/helpers.clj +++ b/backend/tests/app/tests/helpers.clj @@ -31,15 +31,23 @@ [environ.core :refer [env]] [expound.alpha :as expound] [integrant.core :as ig] + [mockery.core :as mk] [promesa.core :as p]) (:import org.postgresql.ds.PGSimpleDataSource)) (def ^:dynamic *system* nil) (def ^:dynamic *pool* nil) +(def config + (merge {:redis-uri "redis://redis/1" + :database-uri "postgresql://postgres/penpot_test" + :storage-fs-directory "/tmp/app/storage" + :migrations-verbose false} + cfg/config)) + (defn state-init [next] - (let [config (-> (main/build-system-config cfg/test-config) + (let [config (-> (main/build-system-config config) (dissoc :app.srepl/server :app.http/server :app.http/router @@ -99,48 +107,7 @@ [prefix & args] (uuid/namespaced uuid/zero (apply str prefix args))) - -(defn create-profile - [conn i] - (let [params {:id (mk-uuid "profile" i) - :fullname (str "Profile " i) - :email (str "profile" i ".test@nodomain.com") - :password "123123" - :demo? true}] - (->> (#'profile/create-profile conn params) - (#'profile/create-profile-relations conn)))) - -(defn create-team - [conn profile-id i] - (let [id (mk-uuid "team" i) - team (#'teams/create-team conn {:id id - :profile-id profile-id - :name (str "team" i)})] - (#'teams/create-team-profile conn - {:team-id id - :profile-id profile-id - :is-owner true - :is-admin true - :can-edit true}) - team)) - -(defn create-project - [conn profile-id team-id i] - (#'projects/create-project conn {:id (mk-uuid "project" i) - :profile-id profile-id - :team-id team-id - :name (str "project" i)})) - -(defn create-file - [conn profile-id project-id is-shared i] - (#'files/create-file conn {:id (mk-uuid "file" i) - :profile-id profile-id - :project-id project-id - :is-shared is-shared - :name (str "file" i)})) - - -;; --- NEW HELPERS +;; --- FACTORIES (defn create-profile* ([i] (create-profile* *pool* i {})) @@ -150,7 +117,7 @@ :fullname (str "Profile " i) :email (str "profile" i ".test@nodomain.com") :password "123123" - :demo? false} + :is-demo false} params)] (->> (#'profile/create-profile conn params) (#'profile/create-profile-relations conn))))) @@ -193,6 +160,60 @@ :can-edit true}) team))) +(defn link-file-to-library* + ([params] (link-file-to-library* *pool* params)) + ([conn {:keys [file-id library-id] :as params}] + (#'files/link-file-to-library conn {:file-id file-id :library-id library-id}))) + +(defn create-complaint-for + [conn {:keys [id created-at type]}] + (db/insert! conn :profile-complaint-report + {:profile-id id + :created-at (or created-at (dt/now)) + :type (name type) + :content (db/tjson {})})) + +(defn create-global-complaint-for + [conn {:keys [email type created-at]}] + (db/insert! conn :global-complaint-report + {:email email + :type (name type) + :created-at (or created-at (dt/now)) + :content (db/tjson {})})) + + +(defn create-team-permission* + ([params] (create-team-permission* *pool* params)) + ([conn {:keys [team-id profile-id is-owner is-admin can-edit] + :or {is-owner true is-admin true can-edit true}}] + (db/insert! conn :team-profile-rel {:team-id team-id + :profile-id profile-id + :is-owner is-owner + :is-admin is-admin + :can-edit can-edit}))) + +(defn create-project-permission* + ([params] (create-project-permission* *pool* params)) + ([conn {:keys [project-id profile-id is-owner is-admin can-edit] + :or {is-owner true is-admin true can-edit true}}] + (db/insert! conn :project-profile-rel {:project-id project-id + :profile-id profile-id + :is-owner is-owner + :is-admin is-admin + :can-edit can-edit}))) + +(defn create-file-permission* + ([params] (create-file-permission* *pool* params)) + ([conn {:keys [file-id profile-id is-owner is-admin can-edit] + :or {is-owner true is-admin true can-edit true}}] + (db/insert! conn :project-profile-rel {:file-id file-id + :profile-id profile-id + :is-owner is-owner + :is-admin is-admin + :can-edit can-edit}))) + + +;; --- RPC HELPERS (defn handle-error [^Throwable err] @@ -200,14 +221,6 @@ (handle-error (.getCause err)) err)) -(defmacro try-on - [expr] - `(try - (let [result# (deref ~expr)] - [nil result#]) - (catch Exception e# - [(handle-error e#) nil]))) - (defmacro try-on! [expr] `(try @@ -217,16 +230,6 @@ {:error (handle-error e#) :result nil}))) -(defmacro try! - [expr] - `(try - {:error nil - :result ~expr} - (catch Exception e# - {:error (handle-error e#) - :result nil}))) - - (defn mutation! [{:keys [::type] :as data}] (let [method-fn (get-in *system* [:app.rpc/rpc :methods :mutation type])] @@ -239,7 +242,7 @@ (try-on! (method-fn (dissoc data ::type))))) -;; --- Utils +;; --- UTILS (defn print-error! [error] @@ -300,3 +303,14 @@ (defn sleep [ms] (Thread/sleep ms)) + +(defn mock-config-get-with + "Helper for mock app.config/get" + [data] + (fn + ([key] (get (merge config data) key)) + ([key default] (get (merge config data) key default)))) + +(defn reset-mock! + [m] + (reset! m @(mk/make-mock {}))) diff --git a/backend/tests/app/tests/test_bounces_handling.clj b/backend/tests/app/tests/test_bounces_handling.clj new file mode 100644 index 0000000000..065ada03f0 --- /dev/null +++ b/backend/tests/app/tests/test_bounces_handling.clj @@ -0,0 +1,316 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2021 UXBOX Labs SL + +(ns app.tests.test-bounces-handling + (:require + [clojure.pprint :refer [pprint]] + [app.http.awsns :as awsns] + [app.emails :as emails] + [app.tests.helpers :as th] + [app.db :as db] + [app.util.time :as dt] + [mockery.core :refer [with-mocks]] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; (with-mocks [mock {:target 'app.tasks/submit! :return nil}] +;; Right now we have many different scenarios what can cause a +;; bounce/complain report. + +(defn- decode-row + [{:keys [content] :as row}] + (cond-> row + (db/pgobject? content) + (assoc :content (db/decode-transit-pgobject content)))) + +(defn bounce-report + [{:keys [token email] :or {email "user@example.com"}}] + {"notificationType" "Bounce", + "bounce" {"feedbackId""010701776d7dd251-c08d280d-9f47-41aa-b959-0094fec779d9-000000", + "bounceType" "Permanent", + "bounceSubType" "General", + "bouncedRecipients" [{"emailAddress" email, + "action" "failed", + "status" "5.1.1", + "diagnosticCode" "smtp; 550 5.1.1 user unknown"}] + "timestamp" "2021-02-04T14:41:38.000Z", + "remoteMtaIp" "22.22.22.22", + "reportingMTA" "dsn; b224-13.smtp-out.eu-central-1.amazonses.com"} + "mail" {"timestamp" "2021-02-04T14:41:37.020Z", + "source" "no-reply@penpot.app", + "sourceArn" "arn:aws:ses:eu-central-1:1111111111:identity/penpot.app", + "sourceIp" "22.22.22.22", + "sendingAccountId" "1111111111", + "messageId" "010701776d7dccfc-3c0094e7-01d7-458d-8100-893320186028-000000", + "destination" [email], + "headersTruncated" false, + "headers" [{"name" "Received","value" "from app-pre"}, + {"name" "Date","value" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)"}, + {"name" "From","value" "Penpot "}, + {"name" "Reply-To","value" "Penpot "}, + {"name" "To","value" email}, + {"name" "Message-ID","value" "<2054501.5.1612449696846@penpot.app>"}, + {"name" "Subject","value" "test"}, + {"name" "MIME-Version","value" "1.0"}, + {"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_3_1150363050.1612449696845\""}, + {"name" "X-Penpot-Data","value" token}], + "commonHeaders" {"from" ["Penpot "], + "replyTo" ["Penpot "], + "date" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)", + "to" [email], + "messageId" "<2054501.5.1612449696846@penpot.app>", + "subject" "test"}}}) + + +(defn complaint-report + [{:keys [token email] :or {email "user@example.com"}}] + {"notificationType" "Complaint", + "complaint" {"feedbackId" "0107017771528618-dcf4d61f-c889-4c8b-a6ff-6f0b6553b837-000000", + "complaintSubType" nil, + "complainedRecipients" [{"emailAddress" email}], + "timestamp" "2021-02-05T08:32:49.000Z", + "userAgent" "Yahoo!-Mail-Feedback/2.0", + "complaintFeedbackType" "abuse", + "arrivalDate" "2021-02-05T08:31:15.000Z"}, + "mail" {"timestamp" "2021-02-05T08:31:13.715Z", + "source" "no-reply@penpot.app", + "sourceArn" "arn:aws:ses:eu-central-1:111111111:identity/penpot.app", + "sourceIp" "22.22.22.22", + "sendingAccountId" "11111111111", + "messageId" "0107017771510f33-a0696d28-859c-4f08-9211-8392d1b5c226-000000", + "destination" ["user@yahoo.com"], + "headersTruncated" false, + "headers" [{"name" "Received","value" "from smtp"}, + {"name" "Date","value" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)"}, + {"name" "From","value" "Penpot "}, + {"name" "Reply-To","value" "Penpot "}, + {"name" "To","value" email}, + {"name" "Message-ID","value" "<1833063698.279.1612513873536@penpot.app>"}, + {"name" "Subject","value" "Verify email."}, + {"name" "MIME-Version","value" "1.0"}, + {"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_276_1174403980.1612513873535\""}, + {"name" "X-Penpot-Data","value" token}], + "commonHeaders" {"from" ["Penpot "], + "replyTo" ["Penpot "], + "date" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)", + "to" [email], + "messageId" "<1833063698.279.1612513873536@penpot.app>", + "subject" "Verify email."}}}) + +(t/deftest test-parse-bounce-report + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + cfg {:tokens tokens} + report (bounce-report {:token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + result (#'awsns/parse-notification cfg report)] + ;; (pprint result) + + (t/is (= "bounce" (:type result))) + (t/is (= "permanent" (:kind result))) + (t/is (= "general" (:category result))) + (t/is (= ["user@example.com"] (mapv :email (:recipients result)))) + (t/is (= (:id profile) (:profile-id result))) + )) + +(t/deftest test-parse-complaint-report + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + cfg {:tokens tokens} + report (complaint-report {:token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + result (#'awsns/parse-notification cfg report)] + ;; (pprint result) + (t/is (= "complaint" (:type result))) + (t/is (= "abuse" (:kind result))) + (t/is (= nil (:category result))) + (t/is (= ["user@example.com"] (into [] (:recipients result)))) + (t/is (= (:id profile) (:profile-id result))) + )) + +(t/deftest test-parse-complaint-report-without-token + (let [tokens (:app.tokens/tokens th/*system*) + cfg {:tokens tokens} + report (complaint-report {:token ""}) + result (#'awsns/parse-notification cfg report)] + (t/is (= "complaint" (:type result))) + (t/is (= "abuse" (:kind result))) + (t/is (= nil (:category result))) + (t/is (= ["user@example.com"] (into [] (:recipients result)))) + (t/is (= nil (:profile-id result))) + )) + +(t/deftest test-process-bounce-report + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + pool (:app.db/pool th/*system*) + cfg {:tokens tokens :pool pool} + report (bounce-report {:token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + report (#'awsns/parse-notification cfg report)] + + (#'awsns/process-report cfg report) + + (let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)}) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "bounce" (get-in rows [0 :type]))) + (t/is (= "2021-02-04T14:41:38.000Z" (get-in rows [0 :content :timestamp])))) + + (let [rows (->> (db/query pool :global-complaint-report :all) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "bounce" (get-in rows [0 :type]))) + (t/is (= "user@example.com" (get-in rows [0 :email])))) + + (let [prof (db/get-by-id pool :profile (:id profile))] + (t/is (false? (:is-muted prof)))) + + )) + +(t/deftest test-process-complaint-report + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + pool (:app.db/pool th/*system*) + cfg {:tokens tokens :pool pool} + report (complaint-report {:token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + report (#'awsns/parse-notification cfg report)] + + (#'awsns/process-report cfg report) + + (let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)}) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "complaint" (get-in rows [0 :type]))) + (t/is (= "2021-02-05T08:31:15.000Z" (get-in rows [0 :content :timestamp])))) + + + (let [rows (->> (db/query pool :global-complaint-report :all) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "complaint" (get-in rows [0 :type]))) + (t/is (= "user@example.com" (get-in rows [0 :email])))) + + + (let [prof (db/get-by-id pool :profile (:id profile))] + (t/is (false? (:is-muted prof)))) + + )) + +(t/deftest test-process-bounce-report-to-self + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + pool (:app.db/pool th/*system*) + cfg {:tokens tokens :pool pool} + report (bounce-report {:email (:email profile) + :token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + report (#'awsns/parse-notification cfg report)] + + (#'awsns/process-report cfg report) + + (let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})] + (t/is (= 1 (count rows)))) + + (let [rows (db/query pool :global-complaint-report :all)] + (t/is (= 1 (count rows)))) + + (let [prof (db/get-by-id pool :profile (:id profile))] + (t/is (true? (:is-muted prof)))))) + +(t/deftest test-process-complaint-report-to-self + (let [profile (th/create-profile* 1) + tokens (:app.tokens/tokens th/*system*) + pool (:app.db/pool th/*system*) + cfg {:tokens tokens :pool pool} + report (complaint-report {:email (:email profile) + :token (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})}) + report (#'awsns/parse-notification cfg report)] + + (#'awsns/process-report cfg report) + + (let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})] + (t/is (= 1 (count rows)))) + + (let [rows (db/query pool :global-complaint-report :all)] + (t/is (= 1 (count rows)))) + + (let [prof (db/get-by-id pool :profile (:id profile))] + (t/is (true? (:is-muted prof)))))) + +(t/deftest test-allow-send-messages-predicate-with-bounces + (with-mocks [mock {:target 'app.config/get + :return (th/mock-config-get-with + {:profile-bounce-threshold 3 + :profile-complaint-threshold 2})}] + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*)] + (th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})}) + (th/create-complaint-for pool {:type :bounce :id (:id profile)}) + (th/create-complaint-for pool {:type :bounce :id (:id profile)}) + + (t/is (true? (emails/allow-send-emails? pool profile))) + (t/is (= 4 (:call-count (deref mock)))) + + (th/create-complaint-for pool {:type :bounce :id (:id profile)}) + (t/is (false? (emails/allow-send-emails? pool profile)))))) + + +(t/deftest test-allow-send-messages-predicate-with-complaints + (with-mocks [mock {:target 'app.config/get + :return (th/mock-config-get-with + {:profile-bounce-threshold 3 + :profile-complaint-threshold 2})}] + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*)] + (th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})}) + (th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})}) + (th/create-complaint-for pool {:type :bounce :id (:id profile)}) + (th/create-complaint-for pool {:type :bounce :id (:id profile)}) + (th/create-complaint-for pool {:type :complaint :id (:id profile)}) + + (t/is (true? (emails/allow-send-emails? pool profile))) + (t/is (= 4 (:call-count (deref mock)))) + + (th/create-complaint-for pool {:type :complaint :id (:id profile)}) + (t/is (false? (emails/allow-send-emails? pool profile)))))) + +(t/deftest test-has-complaint-reports-predicate + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*)] + + (t/is (false? (emails/has-complaint-reports? pool (:email profile)))) + + (th/create-global-complaint-for pool {:type :bounce :email (:email profile)}) + (t/is (false? (emails/has-complaint-reports? pool (:email profile)))) + + (th/create-global-complaint-for pool {:type :complaint :email (:email profile)}) + (t/is (true? (emails/has-complaint-reports? pool (:email profile)))))) + +(t/deftest test-has-bounce-reports-predicate + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*)] + + (t/is (false? (emails/has-bounce-reports? pool (:email profile)))) + + (th/create-global-complaint-for pool {:type :complaint :email (:email profile)}) + (t/is (false? (emails/has-bounce-reports? pool (:email profile)))) + + (th/create-global-complaint-for pool {:type :bounce :email (:email profile)}) + (t/is (true? (emails/has-bounce-reports? pool (:email profile)))))) diff --git a/backend/tests/app/tests/test_emails.clj b/backend/tests/app/tests/test_emails.clj index 1ab3eca1e6..7381f510ac 100644 --- a/backend/tests/app/tests/test_emails.clj +++ b/backend/tests/app/tests/test_emails.clj @@ -11,7 +11,6 @@ (:require [clojure.test :as t] [promesa.core :as p] - [mockery.core :refer [with-mock]] [app.db :as db] [app.emails :as emails] [app.tests.helpers :as th])) diff --git a/backend/tests/app/tests/test_services_files.clj b/backend/tests/app/tests/test_services_files.clj index bfd87f2871..4de3d8c5f2 100644 --- a/backend/tests/app/tests/test_services_files.clj +++ b/backend/tests/app/tests/test_services_files.clj @@ -120,102 +120,209 @@ (t/is (= 0 (count result)))))) )) -(defn- create-file-media-object - [{:keys [profile-id file-id]}] - (let [mfile {:filename "sample.jpg" - :tempfile (th/tempfile "app/tests/_files/sample.jpg") - :content-type "image/jpeg" - :size 312043} - params {::th/type :upload-file-media-object - :profile-id profile-id - :file-id file-id - :is-local true - :name "testfile" - :content mfile} - out (th/mutation! params)] - (t/is (nil? (:error out))) - (:result out))) - -(defn- update-file - [{:keys [profile-id file-id changes revn] :or {revn 0}}] - (let [params {::th/type :update-file - :id file-id - :session-id (uuid/random) - :profile-id profile-id - :revn revn - :changes changes} - out (th/mutation! params)] - (t/is (nil? (:error out))) - (:result out))) - (t/deftest file-media-gc-task - (let [task (:app.tasks.file-media-gc/handler th/*system*) - storage (:app.storage/storage th/*system*) + (letfn [(create-file-media-object [{:keys [profile-id file-id]}] + (let [mfile {:filename "sample.jpg" + :tempfile (th/tempfile "app/tests/_files/sample.jpg") + :content-type "image/jpeg" + :size 312043} + params {::th/type :upload-file-media-object + :profile-id profile-id + :file-id file-id + :is-local true + :name "testfile" + :content mfile} + out (th/mutation! params)] + (t/is (nil? (:error out))) + (:result out))) - prof (th/create-profile* 1) - proj (th/create-project* 1 {:profile-id (:id prof) - :team-id (:default-team-id prof)}) - file (th/create-file* 1 {:profile-id (:id prof) - :project-id (:default-project-id prof) - :is-shared false}) + (update-file [{:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + :id file-id + :session-id (uuid/random) + :profile-id profile-id + :revn revn + :changes changes} + out (th/mutation! params)] + (t/is (nil? (:error out))) + (:result out)))] - fmo1 (create-file-media-object {:profile-id (:id prof) - :file-id (:id file)}) - fmo2 (create-file-media-object {:profile-id (:id prof) - :file-id (:id file)}) - shid (uuid/random) + (let [storage (:app.storage/storage th/*system*) - ures (update-file - {:file-id (:id file) - :profile-id (:id prof) - :revn 0 - :changes - [{:type :add-obj - :page-id (first (get-in file [:data :pages])) - :id shid - :parent-id uuid/zero - :frame-id uuid/zero - :obj {:id shid - :name "image" - :frame-id uuid/zero - :parent-id uuid/zero - :type :image - :metadata {:id (:id fmo1)}}}]})] + profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) - ;; run the task inmediatelly - (let [res (task {})] - (t/is (= 0 (:processed res)))) + fmo1 (create-file-media-object {:profile-id (:id profile) + :file-id (:id file)}) + fmo2 (create-file-media-object {:profile-id (:id profile) + :file-id (:id file)}) + shid (uuid/random) - ;; make the file ellegible for GC waiting 300ms - (th/sleep 300) + ures (update-file + {:file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + [{:type :add-obj + :page-id (first (get-in file [:data :pages])) + :id shid + :parent-id uuid/zero + :frame-id uuid/zero + :obj {:id shid + :name "image" + :frame-id uuid/zero + :parent-id uuid/zero + :type :image + :metadata {:id (:id fmo1)}}}]})] - ;; run the task again - (let [res (task {})] - (t/is (= 1 (:processed res)))) + ;; run the task inmediatelly + (let [task (:app.tasks.file-media-gc/handler th/*system*) + res (task {})] + (t/is (= 0 (:processed res)))) - ;; Retrieve file and check trimmed attribute - (let [row (db/exec-one! th/*pool* ["select * from file where id = ?" (:id file)])] - (t/is (:has-media-trimmed row))) + ;; make the file ellegible for GC waiting 300ms (configured + ;; timeout for testing) + (th/sleep 300) - ;; check file media objects - (let [fmos (db/exec! th/*pool* ["select * from file_media_object where file_id = ?" (:id file)])] - (t/is (= 1 (count fmos)))) + ;; run the task again + (let [task (:app.tasks.file-media-gc/handler th/*system*) + res (task {})] + (t/is (= 1 (:processed res)))) - ;; The underlying storage objects are still available. - (t/is (some? (sto/get-object storage (:media-id fmo2)))) - (t/is (some? (sto/get-object storage (:thumbnail-id fmo2)))) - (t/is (some? (sto/get-object storage (:media-id fmo1)))) - (t/is (some? (sto/get-object storage (:thumbnail-id fmo1)))) + ;; retrieve file and check trimmed attribute + (let [row (db/exec-one! th/*pool* ["select * from file where id = ?" (:id file)])] + (t/is (true? (:has-media-trimmed row)))) - ;; but if we pass the touched gc task two of them should disappear - (let [task (:app.storage/gc-touched-task th/*system*) - res (task {})] - (t/is (= 0 (:freeze res))) - (t/is (= 2 (:delete res))) + ;; check file media objects + (let [rows (db/exec! th/*pool* ["select * from file_media_object where file_id = ?" (:id file)])] + (t/is (= 1 (count rows)))) - (t/is (nil? (sto/get-object storage (:media-id fmo2)))) - (t/is (nil? (sto/get-object storage (:thumbnail-id fmo2)))) + ;; The underlying storage objects are still available. + (t/is (some? (sto/get-object storage (:media-id fmo2)))) + (t/is (some? (sto/get-object storage (:thumbnail-id fmo2)))) (t/is (some? (sto/get-object storage (:media-id fmo1)))) - (t/is (some? (sto/get-object storage (:thumbnail-id fmo1))))) + (t/is (some? (sto/get-object storage (:thumbnail-id fmo1)))) + + ;; but if we pass the touched gc task two of them should disappear + (let [task (:app.storage/gc-touched-task th/*system*) + res (task {})] + (t/is (= 0 (:freeze res))) + (t/is (= 2 (:delete res))) + + (t/is (nil? (sto/get-object storage (:media-id fmo2)))) + (t/is (nil? (sto/get-object storage (:thumbnail-id fmo2)))) + (t/is (some? (sto/get-object storage (:media-id fmo1)))) + (t/is (some? (sto/get-object storage (:thumbnail-id fmo1))))) + + ))) + +(t/deftest permissions-checks-creating-file + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + + data {::th/type :create-file + :profile-id (:id profile2) + :project-id (:default-project-id profile1) + :name "foobar" + :is-shared false} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-rename-file + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + + file (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)}) + data {::th/type :rename-file + :id (:id file) + :profile-id (:id profile2) + :name "foobar"} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-delete-file + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + + file (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)}) + data {::th/type :delete-file + :profile-id (:id profile2) + :id (:id file)} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-set-file-shared + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)}) + data {::th/type :set-file-shared + :profile-id (:id profile2) + :id (:id file) + :is-shared true} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-link-to-library-1 + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)}) + + data {::th/type :link-file-to-library + :profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-link-to-library-2 + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + + file2 (th/create-file* 2 {:project-id (:default-project-id profile2) + :profile-id (:id profile2)}) + + data {::th/type :link-file-to-library + :profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) - )) diff --git a/backend/tests/app/tests/test_services_profile.clj b/backend/tests/app/tests/test_services_profile.clj index ab32ca184e..f48fc88015 100644 --- a/backend/tests/app/tests/test_services_profile.clj +++ b/backend/tests/app/tests/test_services_profile.clj @@ -18,6 +18,9 @@ [app.rpc.mutations.profile :as profile] [app.tests.helpers :as th])) +;; TODO: profile deletion with teams +;; TODO: profile deletion with owner teams + (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -187,7 +190,198 @@ (t/testing "not allowed email domain" (t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) -;; TODO: profile deletion with teams -;; TODO: profile deletion with owner teams -;; TODO: profile registration -;; TODO: profile password recovery +(t/deftest test-register-when-registration-disabled + (with-mocks [mock {:target 'app.config/get + :return (th/mock-config-get-with + {:registration-enabled false})}] + (let [data {::th/type :register-profile + :email "user@example.com" + :password "foobar" + :fullname "foobar"} + out (th/mutation! data) + error (:error out) + edata (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type edata) :restriction)) + (t/is (= (:code edata) :registration-disabled))))) + +(t/deftest test-register-existing-profile + (let [profile (th/create-profile* 1) + data {::th/type :register-profile + :email (:email profile) + :password "foobar" + :fullname "foobar"} + out (th/mutation! data) + error (:error out) + edata (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type edata) :validation)) + (t/is (= (:code edata) :email-already-exists)))) + +(t/deftest test-register-profile + (with-mocks [mock {:target 'app.emails/send! + :return nil}] + (let [pool (:app.db/pool th/*system*) + data {::th/type :register-profile + :email "user@example.com" + :password "foobar" + :fullname "foobar"} + out (th/mutation! data)] + ;; (th/print-result! out) + (let [mock (deref mock) + [_ _ params] (:call-args mock)] + ;; (clojure.pprint/pprint params) + (t/is (:called? mock)) + (t/is (= (:email data) (:to params))) + (t/is (contains? params :extra-data)) + (t/is (contains? params :token))) + + (let [result (:result out)] + (t/is (false? (:is-demo result))) + (t/is (= (:email data) (:email result))) + (t/is (= "penpot" (:auth-backend result))) + (t/is (= "foobar" (:fullname result))) + (t/is (not (contains? result :password))))))) + +(t/deftest test-register-profile-with-bounced-email + (with-mocks [mock {:target 'app.emails/send! + :return nil}] + (let [pool (:app.db/pool th/*system*) + data {::th/type :register-profile + :email "user@example.com" + :password "foobar" + :fullname "foobar"} + _ (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"}) + out (th/mutation! data)] + ;; (th/print-result! out) + + (let [mock (deref mock)] + (t/is (false? (:called? mock)))) + + (let [error (:error out) + edata (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type edata) :validation)) + (t/is (= (:code edata) :email-has-permanent-bounces)))))) + +(t/deftest test-register-profile-with-complained-email + (with-mocks [mock {:target 'app.emails/send! :return nil}] + (let [pool (:app.db/pool th/*system*) + data {::th/type :register-profile + :email "user@example.com" + :password "foobar" + :fullname "foobar"} + _ (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"}) + out (th/mutation! data)] + + (let [mock (deref mock)] + (t/is (true? (:called? mock)))) + + (let [result (:result out)] + (t/is (= (:email data) (:email result))))))) + +(t/deftest test-email-change-request + (with-mocks [email-send-mock {:target 'app.emails/send! :return nil} + cfg-get-mock {:target 'app.config/get + :return (th/mock-config-get-with + {:smtp-enabled true})}] + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*) + data {::th/type :request-email-change + :profile-id (:id profile) + :email "user1@example.com"}] + + ;; without complaints + (let [out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (let [mock (deref email-send-mock)] + (t/is (= 1 (:call-count mock))) + (t/is (true? (:called? mock))))) + + ;; with complaints + (th/create-global-complaint-for pool {:type :complaint :email (:email data)}) + (let [out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 2 (:call-count (deref email-send-mock))))) + + ;; with bounces + (th/create-global-complaint-for pool {:type :bounce :email (:email data)}) + (let [out (th/mutation! data) + error (:error out)] + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :email-has-permanent-bounces)) + (t/is (= 2 (:call-count (deref email-send-mock)))))))) + + +(t/deftest test-email-change-request-without-smtp + (with-mocks [email-send-mock {:target 'app.emails/send! :return nil} + cfg-get-mock {:target 'app.config/get + :return (th/mock-config-get-with + {:smtp-enabled false})}] + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*) + data {::th/type :request-email-change + :profile-id (:id profile) + :email "user1@example.com"}] + + ;; without complaints + (let [out (th/mutation! data) + res (:result out)] + (t/is (= {:changed true} res)) + (let [mock (deref email-send-mock)] + (t/is (false? (:called? mock)))))))) + + +(t/deftest test-request-profile-recovery + (with-mocks [mock {:target 'app.emails/send! :return nil}] + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2 {:is-active true}) + pool (:app.db/pool th/*system*) + data {::th/type :request-profile-recovery}] + + ;; with invalid email + (let [data (assoc data :email "foo@bar.com") + out (th/mutation! data)] + (t/is (nil? (:result out))) + (t/is (= 0 (:call-count (deref mock))))) + + ;; with valid email inactive user + (let [data (assoc data :email (:email profile1)) + out (th/mutation! data) + error (:error out)] + (t/is (= 0 (:call-count (deref mock)))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :profile-not-verified))) + + ;; with valid email and active user + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 1 (:call-count (deref mock))))) + + ;; with valid email and active user with global complaints + (th/create-global-complaint-for pool {:type :complaint :email (:email profile2)}) + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 2 (:call-count (deref mock))))) + + ;; with valid email and active user with global bounce + (th/create-global-complaint-for pool {:type :bounce :email (:email profile2)}) + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data) + error (:error out)] + ;; (th/print-result! out) + (t/is (= 2 (:call-count (deref mock)))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :email-has-permanent-bounces))) + + ))) diff --git a/backend/tests/app/tests/test_services_projects.clj b/backend/tests/app/tests/test_services_projects.clj index 4d638a41ba..1b7f4f08bd 100644 --- a/backend/tests/app/tests/test_services_projects.clj +++ b/backend/tests/app/tests/test_services_projects.clj @@ -19,15 +19,15 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(t/deftest projects-crud - (let [prof (th/create-profile* 1) - team (th/create-team* 1 {:profile-id (:id prof)}) +(t/deftest projects-simple-crud + (let [profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) project-id (uuid/next)] ;; crate project (let [data {::th/type :create-project :id project-id - :profile-id (:id prof) + :profile-id (:id profile) :team-id (:id team) :name "test project"} out (th/mutation! data)] @@ -40,7 +40,7 @@ ;; query a list of projects (let [data {::th/type :projects :team-id (:id team) - :profile-id (:id prof)} + :profile-id (:id profile)} out (th/query! data)] ;; (th/print-result! out) @@ -54,7 +54,7 @@ (let [data {::th/type :rename-project :id project-id :name "renamed project" - :profile-id (:id prof)} + :profile-id (:id profile)} out (th/mutation! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -63,7 +63,7 @@ ;; retrieve project (let [data {::th/type :project :id project-id - :profile-id (:id prof)} + :profile-id (:id profile)} out (th/query! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -73,7 +73,7 @@ ;; delete project (let [data {::th/type :delete-project :id project-id - :profile-id (:id prof)} + :profile-id (:id profile)} out (th/mutation! data)] ;; (th/print-result! out) @@ -83,10 +83,75 @@ ;; query a list of projects after delete" (let [data {::th/type :projects :team-id (:id team) - :profile-id (:id prof)} + :profile-id (:id profile)} out (th/query! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) (let [result (:result out)] (t/is (= 0 (count result))))) )) + +(t/deftest permissions-checks-create-project + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + + data {::th/type :create-project + :profile-id (:id profile2) + :team-id (:default-team-id profile1) + :name "test project"} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-rename-project + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + project (th/create-project* 1 {:team-id (:default-team-id profile1) + :profile-id (:id profile1)}) + data {::th/type :rename-project + :id (:id project) + :profile-id (:id profile2) + :name "foobar"} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-delete-project + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + project (th/create-project* 1 {:team-id (:default-team-id profile1) + :profile-id (:id profile1)}) + data {::th/type :delete-project + :id (:id project) + :profile-id (:id profile2)} + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + +(t/deftest permissions-checks-delete-project + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + project (th/create-project* 1 {:team-id (:default-team-id profile1) + :profile-id (:id profile1)}) + data {::th/type :update-project-pin + :id (:id project) + :team-id (:default-team-id profile1) + :profile-id (:id profile2) + :is-pinned true} + + out (th/mutation! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)))) + diff --git a/backend/tests/app/tests/test_services_teams.clj b/backend/tests/app/tests/test_services_teams.clj new file mode 100644 index 0000000000..da6ddb6884 --- /dev/null +++ b/backend/tests/app/tests/test_services_teams.clj @@ -0,0 +1,88 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.tests.test-services-teams + (:require + [app.common.uuid :as uuid] + [app.db :as db] + [app.http :as http] + [app.storage :as sto] + [app.tests.helpers :as th] + [mockery.core :refer [with-mocks]] + [clojure.test :as t] + [datoteka.core :as fs])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest test-invite-team-member + (with-mocks [mock {:target 'app.emails/send! :return nil}] + (let [profile1 (th/create-profile* 1 {:is-active true}) + profile2 (th/create-profile* 2 {:is-active true}) + profile3 (th/create-profile* 3 {:is-active true :is-muted true}) + + team (th/create-team* 1 {:profile-id (:id profile1)}) + + pool (:app.db/pool th/*system*) + data {::th/type :invite-team-member + :team-id (:id team) + :role :editor + :profile-id (:id profile1)}] + + ;; (th/print-result! out) + + ;; invite external user without complaints + (let [data (assoc data :email "foo@bar.com") + out (th/mutation! data)] + (t/is (nil? (:result out))) + (t/is (= 1 (:call-count (deref mock))))) + + ;; invite internal user without complaints + (th/reset-mock! mock) + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data)] + (t/is (nil? (:result out))) + (t/is (= 1 (:call-count (deref mock))))) + + ;; invite user with complaint + (th/create-global-complaint-for pool {:type :complaint :email "foo@bar.com"}) + (th/reset-mock! mock) + (let [data (assoc data :email "foo@bar.com") + out (th/mutation! data)] + (t/is (nil? (:result out))) + (t/is (= 1 (:call-count (deref mock))))) + + ;; invite user with bounce + (th/reset-mock! mock) + (th/create-global-complaint-for pool {:type :bounce :email "foo@bar.com"}) + (let [data (assoc data :email "foo@bar.com") + out (th/mutation! data) + error (:error out)] + + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :email-has-permanent-bounces)) + (t/is (= 0 (:call-count (deref mock))))) + + ;; invite internal user that is muted + (th/reset-mock! mock) + (let [data (assoc data :email (:email profile3)) + out (th/mutation! data) + error (:error out)] + + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :member-is-muted)) + (t/is (= 0 (:call-count (deref mock))))) + + ))) + + + + diff --git a/backend/tests/app/tests/test_util_svg.clj b/backend/tests/app/tests/test_util_svg.clj deleted file mode 100644 index 929b8c8673..0000000000 --- a/backend/tests/app/tests/test_util_svg.clj +++ /dev/null @@ -1,62 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.tests.test-util-svg - (:require - [clojure.test :as t] - [clojure.java.io :as io] - [app.http :as http] - [app.util.svg :as svg] - [app.tests.helpers :as th])) - -(t/deftest parse-svg-1 - (let [result (-> (io/resource "app/tests/_files/sample1.svg") - (svg/parse))] - (t/is (contains? result :width)) - (t/is (contains? result :height)) - (t/is (contains? result :view-box)) - (t/is (contains? result :name)) - (t/is (contains? result :content)) - (t/is (= 500.0 (:width result))) - (t/is (= 500.0 (:height result))) - (t/is (= [0.0 0.0 500.00001 500.00001] (:view-box result))) - (t/is (= "lock.svg" (:name result))))) - -(t/deftest parse-svg-2 - (let [result (-> (io/resource "app/tests/_files/sample2.svg") - (svg/parse))] - (t/is (contains? result :width)) - (t/is (contains? result :height)) - (t/is (contains? result :view-box)) - (t/is (contains? result :name)) - (t/is (contains? result :content)) - (t/is (= 500.0 (:width result))) - (t/is (= 500.0 (:height result))) - (t/is (= [0.0 0.0 500.0 500.00001] (:view-box result))) - (t/is (= "play.svg" (:name result))))) - -(t/deftest parse-invalid-svg-1 - (let [image (io/resource "app/tests/_files/sample.jpg") - out (th/try! (svg/parse image))] - - (let [error (:error out)] - (t/is (th/ex-info? error)) - (t/is (th/ex-of-code? error ::svg/invalid-input))))) - -(t/deftest parse-invalid-svg-2 - (let [out (th/try! (svg/parse-string ""))] - (let [error (:error out)] - (t/is (th/ex-info? error)) - (t/is (th/ex-of-code? error ::svg/invalid-input))))) - -(t/deftest parse-invalid-svg-3 - (let [out (th/try! (svg/parse-string ""))] - (let [error (:error out)] - (t/is (th/ex-info? error)) - (t/is (th/ex-of-code? error ::svg/invalid-result))))) diff --git a/common/app/common/exceptions.cljc b/common/app/common/exceptions.cljc index 3891782557..96782de956 100644 --- a/common/app/common/exceptions.cljc +++ b/common/app/common/exceptions.cljc @@ -52,3 +52,7 @@ (defn ex-info? [v] (instance? #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) v)) + +(defn exception? + [v] + (instance? #?(:clj java.lang.Throwable :cljs js/Error) v)) diff --git a/common/app/common/pages/common.cljc b/common/app/common/pages/common.cljc index f56c55bbd7..784896e3b3 100644 --- a/common/app/common/pages/common.cljc +++ b/common/app/common/pages/common.cljc @@ -11,7 +11,7 @@ (:require [app.common.uuid :as uuid])) -(def file-version 5) +(def file-version 6) (def default-color "#b1b2b5") ;; $color-gray-20 (def root uuid/zero) @@ -42,6 +42,10 @@ :stroke-alignment :stroke-group :rx :radius-group :ry :radius-group + :r1 :radius-group + :r2 :radius-group + :r3 :radius-group + :r4 :radius-group :selrect :geometry-group :points :geometry-group :locked :geometry-group diff --git a/common/app/common/pages/helpers.cljc b/common/app/common/pages/helpers.cljc index ca025949f9..e630801da9 100644 --- a/common/app/common/pages/helpers.cljc +++ b/common/app/common/pages/helpers.cljc @@ -353,6 +353,7 @@ (let [frames (select-frames objects)] (or (->> frames + (reverse) (d/seek #(and position (gsh/has-point? % position))) :id) uuid/zero))) diff --git a/common/app/common/pages/migrations.cljc b/common/app/common/pages/migrations.cljc index c2f170a01f..28aa8682ed 100644 --- a/common/app/common/pages/migrations.cljc +++ b/common/app/common/pages/migrations.cljc @@ -13,6 +13,7 @@ [app.common.geom.shapes :as gsh] [app.common.geom.shapes.path :as gsp] [app.common.geom.matrix :as gmt] + [app.common.math :as mth] [app.common.uuid :as uuid] [app.common.data :as d])) @@ -137,3 +138,31 @@ (update data :pages-index #(d/mapm update-page %)))) +(defn fix-line-paths + "Fixes issues with selrect/points for shapes with width/height = 0 (line-like paths)" + [_ shape] + (if (= (:type shape) :path) + (let [{:keys [width height]} (gsh/points->rect (:points shape))] + (if (or (mth/almost-zero? width) (mth/almost-zero? height)) + (let [selrect (gsh/content->selrect (:content shape)) + points (gsh/rect->points selrect) + transform (gmt/matrix) + transform-inv (gmt/matrix)] + (assoc shape + :selrect selrect + :points points + :transform transform + :transform-inverse transform-inv)) + shape)) + shape)) + + +(defmethod migrate 6 + [data] + (letfn [(update-container [_ container] + (-> container + (update :objects #(d/mapm fix-line-paths %))))] + + (-> data + (update :components #(d/mapm update-container %)) + (update :pages-index #(d/mapm update-container %))))) diff --git a/common/app/common/pages/spec.cljc b/common/app/common/pages/spec.cljc index 25dc321f04..2feef1d719 100644 --- a/common/app/common/pages/spec.cljc +++ b/common/app/common/pages/spec.cljc @@ -220,6 +220,10 @@ (s/def :internal.shape/proportion-lock boolean?) (s/def :internal.shape/rx ::safe-number) (s/def :internal.shape/ry ::safe-number) +(s/def :internal.shape/r1 ::safe-number) +(s/def :internal.shape/r2 ::safe-number) +(s/def :internal.shape/r3 ::safe-number) +(s/def :internal.shape/r4 ::safe-number) (s/def :internal.shape/stroke-color string?) (s/def :internal.shape/stroke-color-gradient (s/nilable ::gradient)) (s/def :internal.shape/stroke-color-ref-file (s/nilable uuid?)) @@ -296,6 +300,10 @@ :internal.shape/proportion-lock :internal.shape/rx :internal.shape/ry + :internal.shape/r1 + :internal.shape/r2 + :internal.shape/r3 + :internal.shape/r4 :internal.shape/x :internal.shape/y :internal.shape/exports diff --git a/docker/devenv/docker-compose.yaml b/docker/devenv/docker-compose.yaml index 68bc4d3eda..bc384ebf91 100644 --- a/docker/devenv/docker-compose.yaml +++ b/docker/devenv/docker-compose.yaml @@ -43,6 +43,29 @@ services: - PENPOT_DATABASE_PASSWORD=penpot - PENPOT_REDIS_URI=redis://redis/0 - EXTERNAL_UID=${CURRENT_USER_ID} + # STMP setup + - PENPOT_SMTP_ENABLED=true + - PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com + - PENPOT_SMTP_DEFAULT_REPLY_TO=no-reply@example.com + - PENPOT_SMTP_HOST=mailer + - PENPOT_SMTP_PORT=1025 + - PENPOT_SMTP_USERNAME= + - PENPOT_SMTP_PASSWORD= + - PENPOT_SMTP_SSL=false + - PENPOT_SMTP_TLS=false + + # LDAP setup + - PENPOT_LDAP_HOST=ldap + - PENPOT_LDAP_PORT=10389 + - PENPOT_LDAP_SSL=false + - PENPOT_LDAP_STARTTLS=false + - PENPOT_LDAP_BASE_DN=ou=people,dc=planetexpress,dc=com + - PENPOT_LDAP_BIND_DN=cn=admin,dc=planetexpress,dc=com + - PENPOT_LDAP_BIND_PASSWORD=GoodNewsEveryone + - PENPOT_LDAP_ATTRS_USERNAME=uid + - PENPOT_LDAP_ATTRS_EMAIL=mail + - PENPOT_LDAP_ATTRS_FULLNAME=cn + - PENPOT_LDAP_ATTRS_PHOTO=jpegPhoto postgres: image: postgres:13 @@ -61,7 +84,28 @@ services: - postgres_data:/var/lib/postgresql/data redis: - image: redis:6 + image: redis:5.0.7 hostname: "penpot-devenv-redis" container_name: "penpot-devenv-redis" restart: always + + mailer: + image: sj26/mailcatcher:latest + hostname: mautic-mailer + container_name: mautic-mailer + restart: always + expose: + - '1025' + ports: + - "1080:1080" + + ldap: + image: rroemhild/test-openldap:2.1 + container_name: mautic-ldap + hostname: mautic-ldap + expose: + - '10389' + - '10636' + ports: + - "10389:10389" + - "10636:10636" diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 04c8fd453c..a4b13cb73a 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -99,6 +99,10 @@ http { proxy_pass http://127.0.0.1:6060/api; } + location /webhooks { + proxy_pass http://127.0.0.1:6060/webhooks; + } + location /dbg { proxy_pass http://127.0.0.1:6060/dbg; } diff --git a/docker/images/files/config.js b/docker/images/files/config.js index 6211080733..0ac491bbb8 100644 --- a/docker/images/files/config.js +++ b/docker/images/files/config.js @@ -7,3 +7,4 @@ //var penpotGitlabClientID = ""; //var penpotGithubClientID = ""; //var penpotLoginWithLDAP = ; +//var penpotRegistrationEnabled = ; diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 6c0a428f51..e99e697872 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -79,6 +79,16 @@ update_login_with_ldap() { fi } + +update_registration_enabled() { + if [ -n "$PENPOT_REGISTRATION_ENABLED" ]; then + log "Updating Registration Enabled: $PENPOT_REGISTRATION_ENABLED" + sed -i \ + -e "s|^//var penpotRegistrationEnabled = .*;|var penpotRegistrationEnabled = $PENPOT_REGISTRATION_ENABLED;|g" \ + "$1" + fi +} + update_public_uri /var/www/app/js/config.js update_demo_warning /var/www/app/js/config.js update_allow_demo_users /var/www/app/js/config.js @@ -86,5 +96,6 @@ update_google_client_id /var/www/app/js/config.js update_gitlab_client_id /var/www/app/js/config.js update_github_client_id /var/www/app/js/config.js update_login_with_ldap /var/www/app/js/config.js +update_registration_enabled /var/www/app/js/config.js exec "$@"; diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 0ba877fa75..f9811f7149 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.browser (:require [lambdaisland.glogi :as log] diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 24c352113c..993c9555c4 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.config (:require ["process" :as process] diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index a9414c3243..2a20b0f67c 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.core (:require [lambdaisland.glogi :as log] diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index 03c2390871..cc63a7d539 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.http (:require [app.http.export :refer [export-handler]] diff --git a/exporter/src/app/http/export_bitmap.cljs b/exporter/src/app/http/export_bitmap.cljs index b2c46c9e23..09eb81e16b 100644 --- a/exporter/src/app/http/export_bitmap.cljs +++ b/exporter/src/app/http/export_bitmap.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.http.export-bitmap (:require [cuerdas.core :as str] diff --git a/exporter/src/app/http/export_svg.cljs b/exporter/src/app/http/export_svg.cljs index a5e9e89557..33264b0427 100644 --- a/exporter/src/app/http/export_svg.cljs +++ b/exporter/src/app/http/export_svg.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.http.export-svg (:require [cuerdas.core :as str] diff --git a/exporter/src/app/http/impl.cljs b/exporter/src/app/http/impl.cljs index c04c461a7c..f51c703703 100644 --- a/exporter/src/app/http/impl.cljs +++ b/exporter/src/app/http/impl.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.http.impl (:require ["http" :as http] diff --git a/exporter/src/app/util/transit.cljs b/exporter/src/app/util/transit.cljs index e4d487ffd9..80ccfea157 100644 --- a/exporter/src/app/util/transit.cljs +++ b/exporter/src/app/util/transit.cljs @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 app Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.util.transit (:require diff --git a/exporter/src/app/zipfile.cljs b/exporter/src/app/zipfile.cljs index ac9c16fe6b..8c9cbeff0e 100644 --- a/exporter/src/app/zipfile.cljs +++ b/exporter/src/app/zipfile.cljs @@ -1,3 +1,12 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + (ns app.zipfile (:require ["jszip" :as jszip])) diff --git a/frontend/locales.clj b/frontend/locales.clj index e5eb867808..a80a0a38a4 100644 --- a/frontend/locales.clj +++ b/frontend/locales.clj @@ -12,7 +12,9 @@ 'java.nio.file.Path 'java.nio.file.Files 'java.nio.file.SimpleFileVisitor - 'java.nio.file.FileVisitResult) + 'java.nio.file.FileVisitResult + 'com.fasterxml.jackson.databind.ObjectMapper + 'com.fasterxml.jackson.databind.SerializationFeature) (defmulti task first) @@ -62,19 +64,17 @@ (defn- read-json-file [path] (when (fs/regular-file? path) - (let [content (json/read-value (slurp (io/as-file path)))] - (into (sorted-map) content)))) - -(defn- read-edn-file - [path] - (when (fs/regular-file? path) - (let [content (edn/read-string (slurp (io/as-file path)))] - (into (sorted-map) content)))) - + (let [content (json/read-value (io/as-file path))] + (reduce-kv (fn [res k v] + (let [v (into (sorted-map) v) + v (update v "translations" #(into (sorted-map) %))] + (assoc res k v))) + (sorted-map) + content)))) (defn- add-translation [data {:keys [code file line] :as translation}] - (let [rpath (str file ":" line)] + (let [rpath (str file)] (if (contains? data code) (update data code (fn [state] (if (get state "permanent") @@ -82,7 +82,7 @@ (-> state (dissoc "unused") (update "used-in" conj rpath))))) - (assoc data code {"translations" {"en" nil "fr" nil "es" nil "ru" nil} + (assoc data code {"translations" (sorted-map "en" nil "es" nil) "used-in" [rpath]})))) (defn- clean-removed-translations @@ -110,10 +110,10 @@ (defn- synchronize-translations [data translations] - (loop [data (initial-cleanup data) + (loop [data (initial-cleanup data) imported #{} - c (first translations) - r (rest translations)] + c (first translations) + r (rest translations)] (if (nil? c) (clean-removed-translations data imported) (recur (add-translation data c) @@ -121,29 +121,20 @@ (first r) (rest r))))) -(defn- synchronize-legacy-translations - [data legacy-data lang] - (reduce-kv (fn [data k v] - (if (contains? data k) - (update-in data [k "translations"] assoc lang v) - data)) - data - legacy-data)) - (defn- write-result! [data output-path] (binding [*out* (io/writer (fs/path output-path))] - (let [mapper (json/object-mapper {:pretty true})] + (let [mapper (doto (ObjectMapper.) + (.enable SerializationFeature/ORDER_MAP_ENTRIES_BY_KEYS)) + mapper (json/object-mapper {:pretty true :mapper mapper})] (println (json/write-value-as-string data mapper)) (flush)))) (defn- update-translations [{:keys [find-directory output-path] :as props}] - (let [ - data (read-json-file output-path) + (let [data (read-json-file output-path) translations (collect-translations find-directory) - data (synchronize-translations data translations) - ] + data (synchronize-translations data translations)] (write-result! data output-path))) (defmethod task "collect" @@ -151,12 +142,4 @@ (update-translations {:find-directory in-path :output-path out-path})) - -(defmethod task "merge-with-legacy" - [[_ path lang legacy-path]] - (let [ldata (read-edn-file legacy-path) - data (read-json-file path) - data (synchronize-legacy-translations data ldata lang)] - (write-result! data path))) - (task *command-line-args*) diff --git a/frontend/package.json b/frontend/package.json index 112e8669fb..fa6e8c17ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "defaults" ], "scripts": { - "collect-locales": "clojure -Adev locales.clj collect src/app/main/ resources/locales.json" + "collect-locales": "clojure -M:dev locales.clj collect src/app/main/ resources/locales.json" }, "devDependencies": { "autoprefixer": "^10.1.0", diff --git a/frontend/resources/images/icons/radius-1.svg b/frontend/resources/images/icons/radius-1.svg new file mode 100644 index 0000000000..f1ca422cf9 --- /dev/null +++ b/frontend/resources/images/icons/radius-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/radius-4.svg b/frontend/resources/images/icons/radius-4.svg new file mode 100644 index 0000000000..121940d51a --- /dev/null +++ b/frontend/resources/images/icons/radius-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 46f51b44e8..b7e0d744af 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -1,4823 +1,5202 @@ { "auth.already-have-account" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:128" ], "translations" : { + "ca" : "Ja tens un compte?", "en" : "Already have an account?", - "fr" : "Vous avez déjà un compte?", + "es" : "¿Tienes ya una cuenta?", + "fr" : "Vous avez déjà un compte ?", "ru" : "Уже есть аккаунт?", - "es" : "¿Tienes ya una cuenta?" - } + "zh_cn" : "已经有账号了?" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] + }, + "auth.check-your-email" : { + "translations" : { + "ca" : "Revisa el teu email i fes click al link per verificar i començar a utilitzar Penpot.", + "en" : "Check your email and click on the link to verify and start using Penpot.", + "fr" : "Vérifiez votre e‑mail et cliquez sur le lien pour vérifier et commencer à utiliser Penpot.", + "zh_cn" : "请检查你的电子邮箱,点击邮件中的超链接来验证,然后开始使用Penpot。" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.confirm-password" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:77" ], "translations" : { + "ca" : "Confirmar contrasenya", "en" : "Confirm password", - "fr" : "Confirmez mot de passe", + "es" : "Confirmar contraseña", + "fr" : "Confirmez le mot de passe", "ru" : "Подтвердите пароль", - "es" : "Confirmar contraseña" - } + "zh_cn" : "确认密码" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] }, "auth.create-demo-account" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:161", "src/app/main/ui/auth/register.cljs:138" ], "translations" : { + "ca" : "Crea un compte de proba", "en" : "Create demo account", - "fr" : "Vous voulez juste essayer?", + "es" : "Crear cuenta de prueba", + "fr" : "Créer un compte de démonstration", "ru" : "Хотите попробовать?", - "es" : "Crear cuanta de prueba" - } + "zh_cn" : "创建演示账号" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.create-demo-profile" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:158", "src/app/main/ui/auth/register.cljs:135" ], "translations" : { + "ca" : "Vols probar-ho?", "en" : "Just wanna try it?", - "fr" : "Vous voulez juste essayer?", + "es" : "¿Quieres probar?", + "fr" : "Vous voulez juste essayer ?", "ru" : "Хотите попробовать?", - "es" : "¿Quieres probar?" - } - }, - "auth.verification-email-sent": { - "translations": { - "en": "We've sent a verification email to", - "fr": "Nous avons envoyé un e-mail de vérification à" - } - }, - - "auth.check-your-email": { - "translations": { - "en": "Check your email and click on the link to verify and start using Penpot.", - "fr": "Vérifiez votre email et cliquez sur le lien pour vérifier et commencer à utiliser Penpot." - } + "zh_cn" : "只是想试试?" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.demo-warning" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:33" ], "translations" : { + "ca" : "Aquest es un servei de PROBA. NO HO UTILITZIS per feina real, els projectes seran esborrats periòdicament.", "en" : "This is a DEMO service, DO NOT USE for real work, the projects will be periodicaly wiped.", - "fr" : "Il s'agit d'un service DEMO, NE PAS UTILISER pour un travail réel, les projets seront périodiquement supprimés.", + "es" : "Este es un servicio de DEMOSTRACIÓN. NO USAR para trabajo real, los proyectos serán borrados periodicamente.", + "fr" : "Il s’agit d’un service DEMO, NE PAS UTILISER pour un travail réel, les projets seront périodiquement supprimés.", "ru" : "Это ДЕМОНСТРАЦИЯ, НЕ ИСПОЛЬЗУЙТЕ для работы, проекты будут периодически удаляться.", - "es" : "Este es un servicio de DEMOSTRACIÓN. NO USAR para trabajo real, los proyectos serán borrados periodicamente." - } + "zh_cn" : "这是一个演示服务,请【不要】用于真实工作,这些项目将被周期性地抹除。" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.email" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:99", "src/app/main/ui/auth/register.cljs:101", "src/app/main/ui/auth/recovery_request.cljs:47" ], "translations" : { + "ca" : "Correu electrònic", "en" : "Email", - "fr" : "Adresse email", + "es" : "Correo electrónico", + "fr" : "Adresse e‑mail", "ru" : "Email", - "es" : "Correo electrónico" - } + "zh_cn" : "电子邮件" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/recovery_request.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.forgot-password" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:128" ], "translations" : { + "ca" : "Has oblidat la contrasenya?", "en" : "Forgot password?", - "fr" : "Mot de passe oublié?", + "es" : "¿Olvidaste tu contraseña?", + "fr" : "Mot de passe oublié ?", "ru" : "Забыли пароль?", - "es" : "¿Olvidaste tu contraseña?" - } + "zh_cn" : "忘记密码?" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.fullname" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:94" ], "translations" : { + "ca" : "Nom complet", "en" : "Full Name", + "es" : "Nombre completo", "fr" : "Nom complet", "ru" : "Полное имя", - "es" : "Nombre completo" - } + "zh_cn" : "全名" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.go-back-to-login" : { - "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:68" ], "translations" : { + "ca" : "Tornar", "en" : "Go back!", - "fr" : "Retour!", + "es" : "Volver", + "fr" : "Retour !", "ru" : "Назад!", - "es" : "Volver" - } - }, - "auth.goodbye-title" : { - "used-in" : [ "src/app/main/ui/auth.cljs:35" ], - "translations" : { - "en" : "Goodbye!", - "fr" : "Au revoir!", - "ru" : "Пока!", - "es" : "¡Adiós!" - } + "zh_cn" : "返回!" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.login-here" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:131" ], "translations" : { + "ca" : "Inicia sessió aquí", "en" : "Login here", + "es" : "Entra aquí", "fr" : "Se connecter ici", "ru" : "Войти здесь", - "es" : "Entra aquí" - } + "zh_cn" : "在这里登录" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.login-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:108" ], "translations" : { + "ca" : "Accedir", "en" : "Sign in", + "es" : "Entrar", "fr" : "Se connecter", "ru" : "Вход", - "es" : "Entrar" - } + "zh_cn" : "登录" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.login-subtitle" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:120" ], "translations" : { + "ca" : "Introdueix les teves dades aquí", "en" : "Enter your details below", - "fr" : "Entrez vos informations ci-dessous", + "es" : "Introduce tus datos aquí", + "fr" : "Entrez vos informations ci‑dessous", "ru" : "Введите информацию о себе ниже", - "es" : "Introduce tus datos aquí" - } + "zh_cn" : "请在下面输入你的详细信息" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.login-title" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:119" ], "translations" : { + "ca" : "Encantats de tornar a veure't", "en" : "Great to see you again!", - "fr" : "Ravi de vous revoir!", + "es" : "Encantados de volverte a ver", + "fr" : "Ravi de vous revoir !", "ru" : "Рады видеть Вас снова!", - "es" : "Encantados de volverte a ver" - } + "zh_cn" : "很高兴又见到你!" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.login-with-github-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:153" ], "translations" : { + "ca" : "Accedir amb Github", "en" : "Login with Github", + "es" : "Entrar con Github", "fr" : "Se connecter via Github", "ru" : "Вход через Gitnub", - "es" : "Entrar con Github" - } + "zh_cn" : "使用Github登录" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.login-with-gitlab-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:146" ], "translations" : { + "ca" : "Accedir amb Gitlab", "en" : "Login with Gitlab", + "es" : "Entrar con Gitlab", "fr" : "Se connecter via Gitlab", "ru" : "Вход через Gitlab", - "es" : "Entrar con Gitlab" - } + "zh_cn" : "使用Gitlab登录" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.login-with-ldap-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:112" ], "translations" : { + "ca" : "Accedir amb LDAP", "en" : "Sign in with LDAP", + "es" : "Entrar con LDAP", "fr" : "Se connecter via LDAP", "ru" : "Вход через LDAP", - "es" : "Entrar con LDAP" - } + "zh_cn" : "使用LDAP登录" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.new-password" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:72" ], "translations" : { + "ca" : "Introdueix la nova contrasenya", "en" : "Type a new password", + "es" : "Introduce la nueva contraseña", "fr" : "Saisissez un nouveau mot de passe", "ru" : "Введите новый пароль", - "es" : "Introduce la nueva contraseña" - } - }, - "auth.notifications.profile-not-verified": { - "translations": { - "en": "Profile is not verified, please verify profile before continue.", - "fr": "Le profil n'est pas vérifié, veuillez vérifier le profil avant de continuer.", - "es": "El perfil aun no ha sido verificado, por favor valida el perfil antes de continuar." - } + "zh_cn" : "输入新的密码" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] }, "auth.notifications.invalid-token-error" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:47" ], "translations" : { + "ca" : "El codi de recuperació no és vàlid", "en" : "The recovery token is invalid.", - "fr" : "Le code de récupération n'est pas valide.", + "es" : "El código de recuperación no es válido.", + "fr" : "Le code de récupération n’est pas valide.", "ru" : "Неверный код восстановления.", - "es" : "El código de recuperación no es válido." - } + "zh_cn" : "恢复令牌无效。" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] }, "auth.notifications.password-changed-succesfully" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:51" ], "translations" : { + "ca" : "La contrasenya s'ha canviat correctament", "en" : "Password successfully changed", + "es" : "La contraseña ha sido cambiada", "fr" : "Mot de passe changé avec succès", "ru" : "Пароль изменен успешно", - "es" : "La contraseña ha sido cambiada" - } + "zh_cn" : "密码修改成功" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] + }, + "auth.notifications.profile-not-verified" : { + "translations" : { + "ca" : "El perfil encara no s'ha verificat, si us plau verifica-ho abans de continuar.", + "en" : "Profile is not verified, please verify profile before continue.", + "es" : "El perfil aun no ha sido verificado, por favor valida el perfil antes de continuar.", + "fr" : "Le profil n’est pas vérifié. Veuillez vérifier le profil avant de continuer.", + "zh_cn" : "个人资料未验证,请于验证后继续。" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.notifications.recovery-token-sent" : { - "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:30" ], "translations" : { + "ca" : "Hem enviat un link de recuperació de contrasenya al teu email.", "en" : "Password recovery link sent to your inbox.", + "es" : "Hemos enviado a tu buzón un enlace para recuperar tu contraseña.", "fr" : "Lien de récupération de mot de passe envoyé.", "ru" : "Ссылка для восстановления пароля отправлена на почту.", - "es" : "Hemos enviado a tu buzón un enlace para recuperar tu contraseña." - } + "zh_cn" : "找回密码链接已发至你的收件箱。" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.notifications.team-invitation-accepted" : { - "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:55", "src/app/main/ui/auth/register.cljs:50" ], "translations" : { + "ca" : "T'has unit al equip", "en" : "Joined the team succesfully", - "fr" : "Équipe rejoint avec succès", - "es" : "Te uniste al equipo" - } + "es" : "Te uniste al equipo", + "fr" : "Vous avez rejoint l’équipe avec succès", + "zh_cn" : "成功加入团队" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs" ] }, "auth.password" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:106", "src/app/main/ui/auth/register.cljs:106" ], "translations" : { + "ca" : "Contrasenya", "en" : "Password", + "es" : "Contraseña", "fr" : "Mot de passe", "ru" : "Пароль", - "es" : "Contraseña" - } + "zh_cn" : "密码" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.password-length-hint" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:105" ], "translations" : { + "ca" : "Com a mínim 8 caràcters", "en" : "At least 8 characters", + "es" : "8 caracteres como mínimo", "fr" : "Au moins 8 caractères", "ru" : "Минимум 8 символов", - "es" : "8 caracteres como mínimo" - } + "zh_cn" : "至少8位字符" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.recovery-request-submit" : { - "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:52" ], "translations" : { + "ca" : "Recuperar contrasenya", "en" : "Recover Password", + "es" : "Recuperar contraseña", "fr" : "Récupérer le mot de passe", "ru" : "Восстановить пароль", - "es" : "Recuperar contraseña" - } + "zh_cn" : "找回密码" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.recovery-request-subtitle" : { - "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:62" ], "translations" : { + "ca" : "T'enviarem un correu electrónic amb instruccions", "en" : "We'll send you an email with instructions", - "fr" : "Nous vous enverrons un e-mail avec des instructions", + "es" : "Te enviaremos un correo electrónico con instrucciones", + "fr" : "Nous vous enverrons un e‑mail avec des instructions", "ru" : "Письмо с инструкциями отправлено на почту.", - "es" : "Te enviaremos un correo electrónico con instrucciones" - } + "zh_cn" : "我们将给你发送一封带有说明的电子邮件" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.recovery-request-title" : { - "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:61" ], "translations" : { + "ca" : "Has oblidat la teva contrasenya?", "en" : "Forgot password?", - "fr" : "Vous avez oublié votre mot de passe?", + "es" : "¿Olvidaste tu contraseña?", + "fr" : "Mot de passe oublié ?", "ru" : "Забыли пароль?", - "es" : "¿Olvidaste tu contraseña?" - } + "zh_cn" : "忘记密码?" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs" ] }, "auth.recovery-submit" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:80" ], "translations" : { + "ca" : "Canvia la teva contrasenya", "en" : "Change your password", + "es" : "Cambiar tu contraseña", "fr" : "Changez votre mot de passe", "ru" : "Изменить пароль", - "es" : "Cambiar tu contraseña" - } + "zh_cn" : "修改密码" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] }, "auth.register" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:131" ], "translations" : { + "ca" : "Encara no tens compte?", "en" : "No account yet?", - "fr" : "Pas encore de compte?", + "es" : "¿No tienes una cuenta?", + "fr" : "Pas encore de compte ?", "ru" : "Еще нет аккаунта?", - "es" : "¿No tienes una cuenta?" - } + "zh_cn" : "现在还没有账号?" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "auth.register-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:134", "src/app/main/ui/auth/register.cljs:110" ], "translations" : { + "ca" : "Crea un compte", "en" : "Create an account", + "es" : "Crear una cuenta", "fr" : "Créer un compte", "ru" : "Создать аккаунт", - "es" : "Crear una cuenta" - } + "zh_cn" : "创建账号" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/login.cljs" ] }, "auth.register-subtitle" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:118" ], "translations" : { + "ca" : "Es gratuit, es Open Source", "en" : "It's free, it's Open Source", - "fr" : "C'est gratuit, c'est Open Source", + "es" : "Es gratis, es Open Source", + "fr" : "C’est gratuit, c’est Open Source", "ru" : "Это бесплатно, это Open Source", - "es" : "Es gratis, es Open Source" - } + "zh_cn" : "它免费,它开源" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.register-title" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:117" ], "translations" : { + "ca" : "Crea un compte", "en" : "Create an account", + "es" : "Crear una cuenta", "fr" : "Créer un compte", "ru" : "Создать аккаунт", - "es" : "Crear una cuenta" - } + "zh_cn" : "创建账号" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "auth.sidebar-tagline" : { - "used-in" : [ "src/app/main/ui/auth.cljs:46" ], "translations" : { + "ca" : "La solució de codi obert per disenyar i prototipar", "en" : "The open-source solution for design and prototyping.", + "es" : "La solución de código abierto para diseñar y prototipar", "fr" : "La solution Open Source pour la conception et le prototypage.", "ru" : "Open Source решение для дизайна и прототипирования.", - "es" : "La solución de código abierto para diseñar y prototipar" - } + "zh_cn" : "设计与原型的开源解决方案" + }, + "used-in" : [ "src/app/main/ui/auth.cljs" ] + }, + "auth.verification-email-sent" : { + "translations" : { + "ca" : "Em enviat un correu de verificació a", + "en" : "We've sent a verification email to", + "fr" : "Nous avons envoyé un e-mail de vérification à", + "zh_cn" : "我们已经发送了一封验证邮件到" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "dashboard.add-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:228", "src/app/main/ui/dashboard/grid.cljs:182" ], "translations" : { + "ca" : "Afegeix una Biblioteca Compartida", "en" : "Add as Shared Library", + "es" : "Añadir como Biblioteca Compartida", "fr" : "Ajouter une Bibliothèque Partagée", "ru" : "", - "es" : "Añadir como Biblioteca Compartida" - } + "zh_cn" : "添加为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "dashboard.change-email" : { - "used-in" : [ "src/app/main/ui/settings/profile.cljs:79" ], "translations" : { + "ca" : "Canviar correu", "en" : "Change email", - "fr" : "Changer adresse e-mail", + "es" : "Cambiar correo", + "fr" : "Changer adresse e‑mail", "ru" : "Сменить email адрес", - "es" : "Cambiar correo" - } + "zh_cn" : "修改电子邮箱" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs" ] }, "dashboard.create-new-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:171" ], "translations" : { + "ca" : "+ Crear un nou equip", "en" : "+ Create new team", + "es" : "+ Crear nuevo equipo", "fr" : "+ Créer nouvelle équipe", - "es" : "+ Crear nuevo equipo" - } + "zh_cn" : "+ 创建新团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.default-team-name" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:341" ], "translations" : { + "ca" : "El teu Penpot", "en" : "Your Penpot", + "es" : "Tu Penpot", "fr" : "Votre Penpot", - "es" : "Tu Penpot" - } + "zh_cn" : "你的Penpot" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.delete-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:325" ], "translations" : { + "ca" : "Suprimir equip", "en" : "Delete team", - "fr" : "Supprimer l'équipe", - "es" : "Eliminar equipo" - } + "es" : "Eliminar equipo", + "fr" : "Supprimer l’équipe", + "zh_cn" : "删除团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.draft-title" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:72" ], "translations" : { + "ca" : "Esborrany", "en" : "Draft", + "es" : "Borrador", "fr" : "Brouillon", "ru" : "Черновик", - "es" : "Borrador" - } + "zh_cn" : "草稿" + }, + "used-in" : [ "src/app/main/ui/dashboard/files.cljs" ] }, "dashboard.empty-files" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:188" ], "translations" : { + "ca" : "Encara no hi ha cap arxiu aquí", "en" : "You still have no files here", - "fr" : "Vous n'avez encore aucun fichier ici", + "es" : "Todavía no hay ningún archivo aquí", + "fr" : "Vous n’avez encore aucun fichier ici", "ru" : "Файлов пока нет", - "es" : "Todavía no hay ningún archivo aquí" - } + "zh_cn" : "暂无文档" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "dashboard.invite-profile" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:72" ], "translations" : { + "ca" : "Convidar a l'equip", "en" : "Invite to team", - "fr" : "Inviter à l'équipe", - "es" : "Invitar al equipo" - } + "es" : "Invitar al equipo", + "fr" : "Inviter dans l’équipe", + "zh_cn" : "邀请加入团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.leave-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:318", "src/app/main/ui/dashboard/sidebar.cljs:321" ], "translations" : { + "ca" : "Abandonar l'equip", "en" : "Leave team", - "fr" : "Quitter l'équipe", - "es" : "Abandonar equipo" - } + "es" : "Abandonar equipo", + "fr" : "Quitter l’équipe", + "zh_cn" : "退出团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.libraries-title" : { - "used-in" : [ "src/app/main/ui/dashboard/libraries.cljs:40" ], "translations" : { + "ca" : "Biblioteques Compartides", "en" : "Shared Libraries", + "es" : "Bibliotecas Compartidas", "fr" : "Bibliothèques Partagées", "ru" : "", - "es" : "Bibliotecas Compartidas" - } - }, - "dashboard.library.add-item.icons" : { - "translations" : { - "en" : "+ New icon", - "fr" : "+ Nouvel icône", - "ru" : "+ Новая иконка", - "es" : "+ Nuevo icono" + "zh_cn" : "共享库" }, - "unused" : true - }, - "dashboard.library.add-item.images" : { - "translations" : { - "en" : "+ New image", - "fr" : "+ Nouvelle image", - "ru" : "+ Новое изображение", - "es" : "+ Nueva imagen" - }, - "unused" : true - }, - "dashboard.library.add-item.palettes" : { - "translations" : { - "en" : "+ New color", - "fr" : "+ Nouvelle couleur", - "ru" : "+ Новый цвет", - "es" : "+ Nuevo color" - }, - "unused" : true - }, - "dashboard.library.add-library.icons" : { - "translations" : { - "en" : "+ New icon library", - "fr" : "+ Nouvelle bibliothèque d'icônes", - "ru" : "+ Новая библиотека иконок", - "es" : "+ Nueva biblioteca de iconos" - }, - "unused" : true - }, - "dashboard.library.add-library.images" : { - "translations" : { - "en" : "+ New image library", - "fr" : "+ Nouvelle bibliothèque d'image", - "ru" : "+ Новая библиотека изображений", - "es" : "+ Nueva biblioteca de imágenes" - }, - "unused" : true - }, - "dashboard.library.add-library.palettes" : { - "translations" : { - "en" : "+ New palette", - "fr" : "+ Nouvelle palette", - "ru" : "+ Новая палитра", - "es" : "+ Nueva paleta" - }, - "unused" : true - }, - "dashboard.library.menu.icons" : { - "translations" : { - "en" : "Icons", - "fr" : "Icônes", - "ru" : "Иконки", - "es" : "Iconos" - }, - "unused" : true - }, - "dashboard.library.menu.images" : { - "translations" : { - "en" : "Images", - "fr" : "Images", - "ru" : "Изображения", - "es" : "Imágenes" - }, - "unused" : true - }, - "dashboard.library.menu.palettes" : { - "translations" : { - "en" : "Palettes", - "fr" : "Palettes", - "ru" : "Палитры", - "es" : "Paletas" - }, - "unused" : true + "used-in" : [ "src/app/main/ui/dashboard/libraries.cljs" ] }, "dashboard.loading-files" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:194" ], "translations" : { - "en" : "loading your files ...", - "fr" : "chargement de vos fichiers ...", - "es" : "cargando tus ficheros ..." - } + "ca" : "carregan els teus fitxers", + "en" : "loading your files …", + "es" : "cargando tus ficheros …", + "fr" : "chargement de vos fichiers…", + "zh_cn" : "正在加载文档…" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "dashboard.new-file" : { - "used-in" : [ "src/app/main/ui/dashboard/projects.cljs:108", "src/app/main/ui/dashboard/files.cljs:87" ], "translations" : { + "ca" : "+ Nou Arxiu", "en" : "+ New File", + "es" : "+ Nuevo Archivo", "fr" : "+ Nouveau fichier", "ru" : "+ Новый файл", - "es" : "+ Nuevo Archivo" - } + "zh_cn" : "+ 新文档" + }, + "used-in" : [ "src/app/main/ui/dashboard/projects.cljs", "src/app/main/ui/dashboard/files.cljs" ] }, "dashboard.new-project" : { - "used-in" : [ "src/app/main/ui/dashboard/projects.cljs:35" ], "translations" : { + "ca" : "+ Nou projecte", "en" : "+ New project", + "es" : "+ Nuevo proyecto", "fr" : "+ Nouveau projet", "ru" : "+ Новый проект", - "es" : "+ Nuevo proyecto" - } + "zh_cn" : "+ 新项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/projects.cljs" ] }, "dashboard.no-matches-for" : { - "used-in" : [ "src/app/main/ui/dashboard/search.cljs:54" ], "translations" : { + "ca" : "No s'ha trobat cap coincidència amb “%s“", "en" : "No matches found for “%s“", - "fr" : "Aucune correspondance pour “%s“", + "es" : "No se encuentra “%s“", + "fr" : "Aucune correspondance pour « %s »", "ru" : "Совпадений для “%s“ не найдено", - "es" : "No se encuentra “%s“" - } + "zh_cn" : "没有找到“%s”的匹配项" + }, + "used-in" : [ "src/app/main/ui/dashboard/search.cljs" ] }, "dashboard.no-projects-placeholder" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:436" ], "translations" : { + "ca" : "Els projectes fixats apareixeran aquí", "en" : "Pinned projects will appear here", + "es" : "Los proyectos fijados aparecerán aquí", "fr" : "Les projets épinglés apparaîtront ici", - "es" : "Los proyectos fijados aparecerán aquí" - } + "zh_cn" : "被钉住的项目会显示在这儿" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.notifications.email-changed-successfully" : { - "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:42" ], "translations" : { + "ca" : "La teva adreça de correu s'ha actualizat", "en" : "Your email address has been updated successfully", - "fr" : "Votre adresse e-mail a été mise à jour avec succès", + "es" : "Tu dirección de correo ha sido actualizada", + "fr" : "Votre adresse e‑mail a été mise à jour avec succès", "ru" : "Ваш email адрес успешно обновлен", - "es" : "Tu dirección de correo ha sido actualizada" - } + "zh_cn" : "已经成功更新你的电子邮件" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs" ] }, "dashboard.notifications.email-verified-successfully" : { - "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:36" ], "translations" : { + "ca" : "La teva adreça de correu ha sigut verificada", "en" : "Your email address has been verified successfully", - "fr" : "Votre adresse e-mail a été vérifiée avec succès", + "es" : "Tu dirección de correo ha sido verificada", + "fr" : "Votre adresse e‑mail a été vérifiée avec succès", "ru" : "Ваш email адрес успешно подтвержден", - "es" : "Tu dirección de correo ha sido verificada" - } + "zh_cn" : "已经成功验证你的电子邮件" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs" ] }, "dashboard.notifications.password-saved" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:36" ], "translations" : { + "ca" : "La contrasenya s'ha desat correctament", "en" : "Password saved successfully!", - "fr" : "Mot de passe enregistré avec succès!", + "es" : "¡Contraseña guardada!", + "fr" : "Mot de passe enregistré avec succès !", "ru" : "Пароль успешно сохранен!", - "es" : "¡Contraseña guardada!" - } + "zh_cn" : "已经成功保存密码!" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] }, "dashboard.num-of-members" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:305" ], "translations" : { + "ca" : "%s membres", "en" : "%s members", + "es" : "%s integrantes", "fr" : "%s membres", - "es" : "%s integrantes" - } + "zh_cn" : "成员%s人" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.password-change" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:76" ], "translations" : { + "ca" : "Canvia la contrasenya", "en" : "Change password", - "fr" : "Changement de mot de passe", + "es" : "Cambiar contraseña", + "fr" : "Changer le mot de passe", "ru" : "Изменить пароль", - "es" : "Cambiar contraseña" - } + "zh_cn" : "修改密码" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] }, "dashboard.projects-title" : { - "used-in" : [ "src/app/main/ui/dashboard/projects.cljs:33" ], "translations" : { + "ca" : "Projectes", "en" : "Projects", + "es" : "Proyectos", "fr" : "Projets", "ru" : "Проекты", - "es" : "Proyectos" - } + "zh_cn" : "项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/projects.cljs" ] }, "dashboard.promote-to-owner" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:202" ], "translations" : { + "ca" : "Promoure a propietari", "en" : "Promote to owner", - "fr" : "Promouvoir en propriétaire", - "es" : "Promover a dueño" - } + "es" : "Promover a dueño", + "fr" : "Promouvoir propriétaire", + "zh_cn" : "晋级为所有者" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.remove-account" : { - "used-in" : [ "src/app/main/ui/settings/profile.cljs:87" ], "translations" : { + "ca" : "Vols esborrar el teu compte?", "en" : "Want to remove your account?", - "fr" : "Vous souhaitez supprimer votre compte?", + "es" : "¿Quieres borrar tu cuenta?", + "fr" : "Vous souhaitez supprimer votre compte ?", "ru" : "Хотите удалить свой аккаунт?", - "es" : "¿Quieres borrar tu cuenta?" - } + "zh_cn" : "希望注销您的账号?" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs" ] }, "dashboard.remove-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:226", "src/app/main/ui/dashboard/grid.cljs:181" ], "translations" : { + "ca" : "Elimina com Biblioteca Compartida", "en" : "Remove as Shared Library", + "es" : "Eliminar como Biblioteca Compartida", "fr" : "Retirer en tant que Bibliothèque Partagée", "ru" : "", - "es" : "Eliminar como Biblioteca Compartida" - } + "zh_cn" : "不再作为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "dashboard.search-placeholder" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:120" ], "translations" : { - "en" : "Search...", - "fr" : "Rechercher...", - "ru" : "Поиск ...", - "es" : "Buscar..." - } + "ca" : "Cerca…", + "en" : "Search…", + "es" : "Buscar…", + "fr" : "Rechercher…", + "ru" : "Поиск …", + "zh_cn" : "搜索…" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.searching-for" : { - "used-in" : [ "src/app/main/ui/dashboard/search.cljs:49" ], "translations" : { - "en" : "Searching for “%s“...", - "fr" : "Recherche de “%s“...", - "ru" : "Ищу “%s“...", - "es" : "Buscando “%s“..." - } + "ca" : "S'está cercant “%s“…", + "en" : "Searching for “%s“…", + "es" : "Buscando “%s“…", + "fr" : "Recherche de « %s »…", + "ru" : "Ищу “%s“…", + "zh_cn" : "正在搜索“%s”" + }, + "used-in" : [ "src/app/main/ui/dashboard/search.cljs" ] }, "dashboard.select-ui-language" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:61" ], "translations" : { + "ca" : "Selecciona la llengua de la interfície", "en" : "Select UI language", - "fr" : "Sélectionner la langue de l'interface", + "es" : "Cambiar el idioma de la interfaz", + "fr" : "Sélectionnez la langue de l’interface", "ru" : "Выберите язык интерфейса", - "es" : "Cambiar el idioma de la interfaz" - } + "zh_cn" : "选择界面语言" + }, + "used-in" : [ "src/app/main/ui/settings/options.cljs" ] }, "dashboard.select-ui-theme" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:67" ], "translations" : { + "ca" : "Selecciona un tema", "en" : "Select theme", + "es" : "Selecciona un tema", "fr" : "Sélectionnez un thème", "ru" : "Выберите тему", - "es" : "Selecciona un tema" - } + "zh_cn" : "选择界面主题" + }, + "used-in" : [ "src/app/main/ui/settings/options.cljs" ] }, "dashboard.show-all-files" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:262" ], "translations" : { + "ca" : "Veure tots els fitxers", "en" : "Show all files", + "es" : "Ver todos los ficheros", "fr" : "Voir tous les fichiers", - "es" : "Ver todos los ficheros" - } - }, - "dashboard.sidebar.recent" : { - "translations" : { - "en" : "Recent", - "fr" : "Récent", - "ru" : "Недавние", - "es" : "Reciente" + "zh_cn" : "显示全部文档" }, - "unused" : true + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "dashboard.switch-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:156" ], "translations" : { + "ca" : "Cambiar d'equip", "en" : "Switch team", - "fr" : "Changer d'équipe", - "es" : "Cambiar equipo" - } + "es" : "Cambiar equipo", + "fr" : "Changer d’équipe", + "zh_cn" : "切换团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "dashboard.team-info" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:288" ], "translations" : { + "ca" : "Informació de l'equip", "en" : "Team info", - "fr" : "Information de l'équipe", - "es" : "Información del equipo" - } + "es" : "Información del equipo", + "fr" : "Information de l’équipe", + "zh_cn" : "团队信息" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.team-members" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:299" ], "translations" : { + "ca" : "Membres de l'equip", "en" : "Team members", - "fr" : "Membres de l'équipe", - "es" : "Integrantes del equipo" - } + "es" : "Integrantes del equipo", + "fr" : "Membres de l’équipe", + "zh_cn" : "团队成员" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.team-projects" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:308" ], "translations" : { + "ca" : "Projectes de l'equip", "en" : "Team projects", - "fr" : "Projets de l'équipe", - "es" : "Proyectos del equipo" - } + "es" : "Proyectos del equipo", + "fr" : "Projets de l’équipe", + "zh_cn" : "团队项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "dashboard.theme-change" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:65" ], "translations" : { + "ca" : "Tema de l'interfície", "en" : "UI theme", - "fr" : "Thème de l'interface", + "es" : "Tema visual", + "fr" : "Thème de l’interface", "ru" : "Тема интерфейса пользователя", - "es" : "Tema visual" - } + "zh_cn" : "界面主题" + }, + "used-in" : [ "src/app/main/ui/settings/options.cljs" ] }, "dashboard.title-search" : { - "used-in" : [ "src/app/main/ui/dashboard/search.cljs:37" ], "translations" : { + "ca" : "Membres de l'equip", "en" : "Search results", + "es" : "Resultados de búsqueda", "fr" : "Résultats de recherche", "ru" : "Результаты поиска", - "es" : "Resultados de búsqueda" - } + "zh_cn" : "搜索结果" + }, + "used-in" : [ "src/app/main/ui/dashboard/search.cljs" ] }, "dashboard.type-something" : { - "used-in" : [ "src/app/main/ui/dashboard/search.cljs:44" ], "translations" : { + "ca" : "Escriu per cercar resultats", "en" : "Type to search results", + "es" : "Escribe algo para buscar", "fr" : "Écrivez pour rechercher", "ru" : "Введите для поиска", - "es" : "Escribe algo para buscar" - } + "zh_cn" : "输入关键词进行搜索" + }, + "used-in" : [ "src/app/main/ui/dashboard/search.cljs" ] }, "dashboard.update-settings" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:72", "src/app/main/ui/settings/profile.cljs:82", "src/app/main/ui/settings/password.cljs:96" ], "translations" : { + "ca" : "Actualitzar opcions", "en" : "Update settings", + "es" : "Actualizar opciones", "fr" : "Mettre à jour les paramètres", "ru" : "Обновить настройки", - "es" : "Actualizar opciones" - } + "zh_cn" : "保存设置" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs", "src/app/main/ui/settings/password.cljs", "src/app/main/ui/settings/options.cljs" ] }, "dashboard.your-account-title" : { - "used-in" : [ "src/app/main/ui/settings.cljs:29" ], "translations" : { + "ca" : "El teu compte", "en" : "Your account", + "es" : "Tu cuenta", "fr" : "Votre compte", - "es" : "Su cuenta" - } + "zh_cn" : "你的账号" + }, + "used-in" : [ "src/app/main/ui/settings.cljs" ] }, "dashboard.your-email" : { - "used-in" : [ "src/app/main/ui/settings/profile.cljs:74" ], "translations" : { + "ca" : "Correu electrónic", "en" : "Email", - "fr" : "E-mail", + "es" : "Correo", + "fr" : "E‑mail", "ru" : "Email", - "es" : "Correo" - } + "zh_cn" : "电子邮件" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs" ] }, "dashboard.your-name" : { - "used-in" : [ "src/app/main/ui/settings/profile.cljs:66" ], "translations" : { + "ca" : "El teu nom", "en" : "Your name", + "es" : "Tu nombre", "fr" : "Votre nom complet", "ru" : "Ваше имя", - "es" : "Tu nombre" - } + "zh_cn" : "你的姓名" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs" ] }, "dashboard.your-penpot" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:160" ], "translations" : { + "ca" : "El teu Penpot", "en" : "Your Penpot", + "es" : "Tu Penpot", "fr" : "Votre Penpot", - "es" : "Tu Penpot" - } - }, - "ds.accept" : { - "translations" : { - "en" : "Accept", - "fr" : "Accepter", - "ru" : "Принять", - "es" : "Aceptar" + "zh_cn" : "你的Penpot" }, - "unused" : true - }, - "ds.button.delete" : { - "translations" : { - "en" : "Delete", - "fr" : "Supprimer", - "ru" : "Удалить", - "es" : "Borrar" - }, - "unused" : true - }, - "ds.button.rename" : { - "translations" : { - "en" : "Rename", - "fr" : "Renommer", - "ru" : "Переименовать", - "es" : "Renombrar" - }, - "unused" : true - }, - "ds.button.save" : { - "translations" : { - "en" : "Save", - "fr" : "Enregistrer", - "ru" : "Сохранить", - "es" : "Guardar" - }, - "unused" : true + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "ds.confirm-cancel" : { - "used-in" : [ "src/app/main/ui/confirm.cljs:39" ], "translations" : { + "ca" : "Cancel·lar", "en" : "Cancel", + "es" : "Cancelar", "fr" : "Annuler", "ru" : "Отмена", - "es" : "Cancelar" - } + "zh_cn" : "取消" + }, + "used-in" : [ "src/app/main/ui/confirm.cljs" ] }, "ds.confirm-ok" : { - "used-in" : [ "src/app/main/ui/confirm.cljs:40" ], "translations" : { + "ca" : "Ok", "en" : "Ok", + "es" : "Ok", "fr" : "Ok", "ru" : "Ok", - "es" : "Ok" - } + "zh_cn" : "OK" + }, + "used-in" : [ "src/app/main/ui/confirm.cljs" ] }, "ds.confirm-title" : { - "used-in" : [ "src/app/main/ui/confirm.cljs:38", "src/app/main/ui/confirm.cljs:42" ], "translations" : { + "ca" : "Estàs segur?", "en" : "Are you sure?", - "fr" : "Êtes-vous sûr?", + "es" : "¿Seguro?", + "fr" : "Êtes‑vous sûr ?", "ru" : "Вы уверены?", - "es" : "¿Seguro?" - } + "zh_cn" : "你确定?" + }, + "used-in" : [ "src/app/main/ui/confirm.cljs", "src/app/main/ui/confirm.cljs" ] }, "ds.updated-at" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:59" ], "translations" : { + "ca" : "Actualitzat: %s", "en" : "Updated: %s", - "fr" : "Mis à jour: %s", + "es" : "Actualizado: %s", + "fr" : "Mise à jour : %s", "ru" : "Обновлено: %s", - "es" : "Actualizado: %s" - } + "zh_cn" : "更新了:%s" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] + }, + "errors.clipboard-not-implemented" : { + "translations" : { + "ca" : "El teu navegador no pot realitzar aquesta operació", + "en" : "Your browser cannot do this operation", + "es" : "Tu navegador no puede realizar esta operación", + "fr" : "Votre navigateur ne peut pas effectuer cette opération", + "ru" : "", + "zh_cn" : "你的浏览器不支持该操作" + }, + "used-in" : [ "src/app/main/data/workspace.cljs" ] + }, + "errors.email-already-exists" : { + "translations" : { + "ca" : "El correu ja està en ús", + "en" : "Email already used", + "es" : "Este correo ya está en uso", + "fr" : "Adresse e‑mail déjà utilisée", + "ru" : "Такой email уже используется", + "zh_cn" : "电子邮件已被占用" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs", "src/app/main/ui/settings/change_email.cljs" ] + }, + "errors.email-already-validated" : { + "translations" : { + "ca" : "El correu ja està validat", + "en" : "Email already validated.", + "es" : "Este correo ya está validado.", + "fr" : "Adresse e‑mail déjà validée.", + "ru" : "Электронная почта уже подтверждена.", + "zh_cn" : "电子邮件已经验证通过" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs" ] + }, + "errors.email-has-permanent-bounces" : { + "translations" : { + "ca" : "El correu «%s» té molts informes de rebot permanents", + "en" : "The email «%s» has many permanent bounce reports.", + "es" : "El email «%s» tiene varios reportes de rebote permanente.", + "zh_cn" : "电子邮件“%s”收到了非常多的永久退信报告" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs", "src/app/main/ui/auth/recovery_request.cljs", "src/app/main/ui/settings/change_email.cljs", "src/app/main/ui/dashboard/team.cljs" ] + }, + "errors.email-invalid-confirmation" : { + "translations" : { + "ca" : "El correu de confirmació ha de coincidir", + "en" : "Confirmation email must match", + "es" : "El correo de confirmación debe coincidir", + "fr" : "L’adresse e‑mail de confirmation doit correspondre", + "ru" : "Email для подтверждения должен совпадать", + "zh_cn" : "确认电子邮件必须保持一致" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] + }, + "errors.generic" : { + "translations" : { + "ca" : "Alguna cosa ha anat malament", + "en" : "Something wrong has happened.", + "es" : "Ha ocurrido algún error.", + "fr" : "Un problème s’est produit.", + "ru" : "Что-то пошло не так.", + "zh_cn" : "发生了某种错误。" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs", "src/app/main/ui/settings/profile.cljs", "src/app/main/ui/settings/feedback.cljs", "src/app/main/ui/settings/options.cljs", "src/app/main/ui/dashboard/team.cljs" ] }, "errors.google-auth-not-enabled" : { "translations" : { + "ca" : "L'autenticació amb google ha estat desactivada a aquest servidor", "en" : "Authentication with google disabled on backend", - "es" : "Autenticación con google esta dehabilitada en el servidor" - } + "es" : "Autenticación con google esta dehabilitada en el servidor", + "zh_cn" : "后端禁用了Google授权" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, - "errors.auth.unauthorized" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:89" ], + "errors.ldap-disabled" : { "translations" : { - "en" : "Username or password seems to be wrong.", - "fr" : "Le nom d'utilisateur ou le mot de passe semble être faux.", - "ru" : "Неверное имя пользователя или пароль.", - "es" : "El nombre o la contraseña parece incorrecto." - } - }, - "errors.clipboard-not-implemented" : { - "used-in" : [ "src/app/main/data/workspace.cljs:1394" ], - "translations" : { - "en" : "Your browser cannot do this operation", - "fr" : "Votre navigateur ne peut pas effectuer cette opération", - "ru" : "", - "es" : "Tu navegador no puede realizar esta operación" - } - }, - "errors.email-already-exists" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:47", "src/app/main/ui/auth/verify_token.cljs:80" ], - "translations" : { - "en" : "Email already used", - "fr" : "Adresse e-mail déjà utilisée", - "ru" : "Такой email уже используется", - "es" : "Este correo ya está en uso" - } - }, - "errors.email-already-validated" : { - "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:85" ], - "translations" : { - "en" : "Email already validated.", - "fr" : "Adresse e-mail déjà validé.", - "ru" : "Электронная почта уже подтверждена.", - "es" : "Este correo ya está validado." - } - }, - "errors.email-invalid-confirmation" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:37" ], - "translations" : { - "en" : "Confirmation email must match", - "fr" : "L'adresse e-mail de confirmation doit correspondre", - "ru" : "Email для подтверждения должен совпадать", - "es" : "El correo de confirmación debe coincidir" - } - }, - "errors.generic" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:32", "src/app/main/ui/settings/profile.cljs:42", "src/app/main/ui/auth/verify_token.cljs:89" ], - "translations" : { - "en" : "Something wrong has happened.", - "fr" : "Quelque chose c'est mal passé.", - "ru" : "Что-то пошло не так.", - "es" : "Ha ocurrido algún error." - } + "en" : "LDAP authentication is disabled.", + "es" : "La autheticacion via LDAP esta deshabilitada.", + "zh_cn" : "仅用了LDAP授权。" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "errors.media-format-unsupported" : { - "used-in" : [ "src/app/main/data/media.cljs:55" ], "translations" : { + "ca" : "El format d'imatge no està suportat (deu ser svg, jpg o png),", "en" : "The image format is not supported (must be svg, jpg or png).", - "fr" : "Le format d'image n'est pas supporté (doit être svg, jpg ou png).", + "es" : "No se reconoce el formato de imagen (debe ser svg, jpg o png).", + "fr" : "Le format d’image n’est pas supporté (doit être svg, jpg ou png).", "ru" : "Формат изображения не поддерживается (должен быть svg, jpg или png).", - "es" : "No se reconoce el formato de imagen (debe ser svg, jpg o png)." - } + "zh_cn" : "不支持该图片格式(只能是svg、jpg或png)。" + }, + "unused" : true }, "errors.media-too-large" : { - "used-in" : [ "src/app/main/data/media.cljs:53" ], "translations" : { + "ca" : "La imatge es massa gran (ha de tenir menys de 5 mb).", "en" : "The image is too large to be inserted (must be under 5mb).", - "fr" : "L'image est trop grande (doit être inférieure à 5 Mo).", + "es" : "La imagen es demasiado grande (debe tener menos de 5mb).", + "fr" : "L’image est trop grande (doit être inférieure à 5 Mo).", "ru" : "Изображение слишком большое для вставки (должно быть меньше 5mb).", - "es" : "La imagen es demasiado grande (debe tener menos de 5mb)." - } + "zh_cn" : "图片尺寸过大,故无法插入(不能超过5MB)。" + }, + "used-in" : [ "src/app/main/data/workspace/persistence.cljs" ] }, "errors.media-type-mismatch" : { - "used-in" : [ "src/app/main/data/media.cljs:78", "src/app/main/data/workspace/persistence.cljs:426" ], "translations" : { + "ca" : "Sembla que el contingut de la imatge no coincideix amb l'extensió del arxiu", "en" : "Seems that the contents of the image does not match the file extension.", - "fr" : "Il semble que le contenu de l'image ne correspond pas à l'extension de fichier.", + "es" : "Parece que el contenido de la imagen no coincide con la etensión del archivo.", + "fr" : "Il semble que le contenu de l’image ne correspond pas à l’extension de fichier.", "ru" : "", - "es" : "Parece que el contenido de la imagen no coincide con la etensión del archivo." - } + "zh_cn" : "图片内容好像与文档扩展名不匹配。" + }, + "used-in" : [ "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/media.cljs" ] }, "errors.media-type-not-allowed" : { - "used-in" : [ "src/app/main/data/media.cljs:75", "src/app/main/data/workspace/persistence.cljs:423" ], "translations" : { + "ca" : "La imatge no sembla pas vàlida", "en" : "Seems that this is not a valid image.", - "fr" : "Il semble que ce n'est pas une image valide.", + "es" : "Parece que no es una imagen válida.", + "fr" : "L’image ne semble pas être valide.", "ru" : "", - "es" : "Parece que no es una imagen válida." - } + "zh_cn" : "该图片好像不可用。" + }, + "used-in" : [ "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/media.cljs" ] + }, + "errors.member-is-muted" : { + "translations" : { + "ca" : "El perfil que estàs invitant té els emails mutejats (per informes de spam o rebots alts", + "en" : "The profile you inviting has emails muted (spam reports or high bounces).", + "es" : "El perfil que esta invitando tiene los emails silenciados (por reportes de spam o alto indice de rebote).", + "zh_cn" : "你邀请的人设置了邮件免打扰(报告垃圾邮件或者多次退信)。" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "errors.network" : { "translations" : { + "ca" : "Impossible connectar amb el servidor principal", "en" : "Unable to connect to backend server.", + "es" : "Ha sido imposible conectar con el servidor principal.", "fr" : "Impossible de se connecter au serveur principal.", "ru" : "Невозможно подключиться к серверу.", - "es" : "Ha sido imposible conectar con el servidor principal." + "zh_cn" : "无法连接到后端服务器。" }, "unused" : true }, "errors.password-invalid-confirmation" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:58" ], "translations" : { + "ca" : "La contrasenya de confirmació ha de coincidir", "en" : "Confirmation password must match", + "es" : "La contraseña de confirmación debe coincidir", "fr" : "Le mot de passe de confirmation doit correspondre", "ru" : "Пароль для подтверждения должен совпадать", - "es" : "La contraseña de confirmación debe coincidir" - } + "zh_cn" : "确认密码必须保持一致。" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] }, "errors.password-too-short" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:61" ], "translations" : { + "ca" : "La contrasenya ha de tenir 8 com a mínim 8 caràcters", "en" : "Password should at least be 8 characters", + "es" : "La contraseña debe tener 8 caracteres como mínimo", "fr" : "Le mot de passe doit contenir au moins 8 caractères", "ru" : "Пароль должен быть минимум 8 символов", - "es" : "La contraseña debe tener 8 caracteres como mínimo" - } + "zh_cn" : "密码最少需要8位字符。" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] + }, + "errors.profile-is-muted" : { + "translations" : { + "ca" : "El teu perfil te els emails mutejats (per informes de spam o rebots alts).", + "en" : "Your profile has emails muted (spam reports or high bounces).", + "es" : "Tu perfil tiene los emails silenciados (por reportes de spam o alto indice de rebote).", + "zh_cn" : "你设置了邮件免打扰(报告垃圾邮件或者多次退信)。" + }, + "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs", "src/app/main/ui/settings/change_email.cljs", "src/app/main/ui/dashboard/team.cljs" ] }, "errors.registration-disabled" : { - "used-in" : [ "src/app/main/ui/auth/register.cljs:39" ], "translations" : { + "ca" : "El registre està desactivat actualment", "en" : "The registration is currently disabled.", - "fr" : "L'enregistrement est actuellement désactivé.", + "es" : "El registro está actualmente desactivado.", + "fr" : "L’enregistrement est actuellement désactivé.", "ru" : "Регистрация сейчас отключена.", - "es" : "El registro está actualmente desactivado." - } + "zh_cn" : "当前禁止注册。" + }, + "used-in" : [ "src/app/main/ui/auth/register.cljs" ] }, "errors.unexpected-error" : { - "used-in" : [ "src/app/main/data/media.cljs:81", "src/app/main/ui/auth/register.cljs:45", "src/app/main/ui/workspace/sidebar/options/exports.cljs:75", "src/app/main/ui/handoff/exports.cljs:41" ], "translations" : { + "ca" : "S'ha produït un error inesperat.", "en" : "An unexpected error occurred.", - "fr" : "Une erreur inattendue c'est produite", + "es" : "Ha ocurrido un error inesperado.", + "fr" : "Une erreur inattendue s’est produite", "ru" : "Произошла ошибка.", - "es" : "Ha ocurrido un error inesperado." - } + "zh_cn" : "发生了意料之外的错误。" + }, + "used-in" : [ "src/app/main/data/media.cljs", "src/app/main/ui/workspace/sidebar/options/exports.cljs", "src/app/main/ui/handoff/exports.cljs" ] + }, + "errors.unexpected-token" : { + "translations" : { + "ca" : "Token desconegut", + "en" : "Unknown token", + "es" : "Token desconocido", + "zh_cn" : "未知的TOKEN。" + }, + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs" ] + }, + "errors.wrong-credentials" : { + "translations" : { + "ca" : "El nom d'usuari o la contrasenya sembla incorrecte", + "en" : "Username or password seems to be wrong.", + "es" : "El nombre o la contraseña parece incorrecto.", + "fr" : "Le nom d’utilisateur ou le mot de passe semble être faux.", + "ru" : "Неверное имя пользователя или пароль.", + "zh_cn" : "用户名或密码错误。" + }, + "used-in" : [ "src/app/main/ui/auth/login.cljs" ] }, "errors.wrong-old-password" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:28" ], "translations" : { + "ca" : "La contrasenya anterior no és correcte", "en" : "Old password is incorrect", - "fr" : "L'ancien mot de passe est incorrect", + "es" : "La contraseña anterior no es correcta", + "fr" : "L’ancien mot de passe est incorrect", "ru" : "Старый пароль неверный", - "es" : "La contraseña anterior no es correcta" - } + "zh_cn" : "旧密码不正确" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] }, "feedback.chat-start" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:112" ], "translations" : { + "ca" : "Uneix-te al xat.", "en" : "Join the chat", - "es" : "Unirse al chat" - } + "es" : "Unirse al chat", + "zh_cn" : "加入聊天" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.chat-subtitle" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:109" ], "translations" : { + "ca" : "Et ve de gust parlar? Xateja amb nosaltres a Gitter", "en" : "Feeling like talking? Chat with us at Gitter", - "es" : "¿Deseas conversar? Entra al nuestro chat de la comunidad en Gitter" - } + "es" : "¿Deseas conversar? Entra al nuestro chat de la comunidad en Gitter", + "zh_cn" : "想说两句?来Gitter和我们聊聊" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.description" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:88" ], "translations" : { + "ca" : "Descripció", "en" : "Description", - "es" : "Descripción" - } + "es" : "Descripción", + "zh_cn" : "描述" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.discussions-go-to" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:104" ], "translations" : { + "ca" : "", "en" : "Go to discussions", - "es" : "Ir a las discussiones" - } + "es" : "Ir a las discusiones", + "zh_cn" : "前往讨论" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.discussions-subtitle1" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:99" ], "translations" : { + "ca" : "Uneix-te al fòrum colaboratiu de Penpot.", "en" : "Join Penpot team collaborative communication forum.", - "es" : "Entra al foro colaborativo de Penpot" - } + "es" : "Entra al foro colaborativo de Penpot", + "zh_cn" : "加入Penpot团队协作交流论坛。" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.discussions-subtitle2" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:100" ], "translations" : { + "ca" : "Pots fer i respondre preguntes, tenir converses obertes i seguir les decisións que afecten al projecte", "en" : "You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.", - "es" : "" - } + "es" : "", + "zh_cn" : "你可以提问、回答问题,来一场开放的对话,并对影响项目的决策保持关注。" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.discussions-title" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:98" ], "translations" : { + "ca" : "", "en" : "Team discussions", - "es" : "" - } + "es" : "", + "zh_cn" : "团队讨论" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.subject" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:84" ], "translations" : { + "ca" : "Tema", "en" : "Subject", - "es" : "Asunto" - } + "es" : "Asunto", + "zh_cn" : "话题" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.subtitle" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:81" ], "translations" : { + "ca" : "Si us plau descriu la raó del teu correu, especificant si es una incidència, una idea o un dubte. Un membre del nostre equip respondrà tan aviat como pugui.", "en" : "Please describe the reason of your email, specifying if is an issue, an idea or a doubt. A member of our team will respond as soon as possible.", - "es" : "" - } + "es" : "", + "zh_cn" : "请描述你发来邮件的原因,详细说明这是一个问题反馈,一个点子或者一个疑问。 我们会尽快回复。" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "feedback.title" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:80" ], "translations" : { + "ca" : "Correu electrònic", "en" : "Email", + "es" : "Correo electrónico", "fr" : "Adresse email", "ru" : "Email", - "es" : "Correo electrónico" - } + "zh_cn" : "电子邮件" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, "generic.error" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:31" ], "translations" : { + "ca" : "S'ha produït un error", "en" : "An error has occurred", - "fr" : "Une erreur c'est produite", + "es" : "Ha ocurrido un error", + "fr" : "Une erreur s’est produite", "ru" : "Произошла ошибка", - "es" : "Ha ocurrido un error" - } + "zh_cn" : "发生了一个错误" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] }, "handoff.attributes.blur" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs:34" ], "translations" : { "en" : "Blur", + "es" : "Desenfocado", "fr" : "Flou", - "es" : "Desenfocado" - } + "zh_cn" : "模糊" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs" ] }, "handoff.attributes.blur.value" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs:40" ], "translations" : { "en" : "Value", + "es" : "Valor", "fr" : "Valeur", - "es" : "Valor" - } + "zh_cn" : "值" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs" ] }, "handoff.attributes.color.hex" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:70" ], "translations" : { "en" : "HEX", + "es" : "HEX", "fr" : "HEX", - "es" : "HEX" - } + "zh_cn" : "HEX" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs" ] }, "handoff.attributes.color.hsla" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:76" ], "translations" : { "en" : "HSLA", + "es" : "HSLA", "fr" : "HSLA", - "es" : "HSLA" - } + "zh_cn" : "HSLA" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs" ] }, "handoff.attributes.color.rgba" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:73" ], "translations" : { "en" : "RGBA", + "es" : "RGBA", "fr" : "RGBA", - "es" : "RGBA" - } - }, - "handoff.attributes.content" : { - "translations" : { - "en" : "Content", - "fr" : "Contenu", - "es" : "Contenido" + "zh_cn" : "RGBA" }, - "unused" : true + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs" ] }, "handoff.attributes.fill" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/fill.cljs:57" ], "translations" : { "en" : "Fill", + "es" : "Relleno", "fr" : "Remplir", - "es" : "Relleno" - } + "zh_cn" : "填充" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/fill.cljs" ] }, "handoff.attributes.image.download" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:50" ], "translations" : { - "en" : "Dowload source image", - "fr" : "Télécharger l'image source", - "es" : "Descargar imagen original" - } + "en" : "Download source image", + "es" : "Descargar imagen original", + "fr" : "Télécharger l’image source", + "zh_cn" : "下载原图" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs" ] }, "handoff.attributes.image.height" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:38" ], "translations" : { "en" : "Height", + "es" : "Altura", "fr" : "Hauteur", - "es" : "Altura" - } + "zh_cn" : "高" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs" ] }, "handoff.attributes.image.width" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:33" ], "translations" : { "en" : "Width", + "es" : "Ancho", "fr" : "Largeur", - "es" : "Ancho" - } + "zh_cn" : "宽" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs" ] }, "handoff.attributes.layout" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:76" ], "translations" : { "en" : "Layout", - "fr" : "Disposition", - "es" : "Estructura" - } + "es" : "Estructura", + "fr" : "Mise en page", + "zh_cn" : "布局" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.height" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:43" ], "translations" : { "en" : "Height", + "es" : "Altura", "fr" : "Hauteur", - "es" : "Altura" - } + "zh_cn" : "高" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.left" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:49" ], "translations" : { "en" : "Left", + "es" : "Izquierda", "fr" : "Gauche", - "es" : "Izquierda" - } + "zh_cn" : "左" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.radius" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:61" ], "translations" : { "en" : "Radius", + "es" : "Derecha", "fr" : "Rayon", - "es" : "Derecha" - } + "zh_cn" : "圆角半径" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs", "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.rotation" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:67" ], "translations" : { "en" : "Rotation", + "es" : "Rotación", "fr" : "Rotation", - "es" : "Rotación" - } + "zh_cn" : "旋转" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.top" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:55" ], "translations" : { "en" : "Top", + "es" : "Arriba", "fr" : "Haut", - "es" : "Arriba" - } + "zh_cn" : "顶" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.layout.width" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:38" ], "translations" : { "en" : "Width", + "es" : "Ancho", "fr" : "Largeur", - "es" : "Ancho" - } + "zh_cn" : "宽" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs" ] }, "handoff.attributes.shadow" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:71" ], "translations" : { "en" : "Shadow", + "es" : "Sombra", "fr" : "Ombre", - "es" : "Sombra" - } + "zh_cn" : "阴影" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs" ] }, "handoff.attributes.shadow.shorthand.blur" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:53" ], "translations" : { "en" : "B", + "es" : "B", "fr" : "B", - "es" : "B" - } + "zh_cn" : "B" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs" ] }, "handoff.attributes.shadow.shorthand.offset-x" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:45" ], "translations" : { "en" : "X", + "es" : "X", "fr" : "X", - "es" : "X" - } + "zh_cn" : "X" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs" ] }, "handoff.attributes.shadow.shorthand.offset-y" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:49" ], "translations" : { "en" : "Y", + "es" : "Y", "fr" : "Y", - "es" : "Y" - } + "zh_cn" : "Y" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs" ] }, "handoff.attributes.shadow.shorthand.spread" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:57" ], "translations" : { "en" : "S", + "es" : "S", "fr" : "S", - "es" : "S" - } - }, - "handoff.attributes.shadow.style.drop-shadow" : { - "translations" : { - "en" : "Drop", - "fr" : "Portée", - "es" : "Arrojar" + "zh_cn" : "S" }, - "unused" : true - }, - "handoff.attributes.shadow.style.inner-shadow" : { - "translations" : { - "en" : "Inner", - "fr" : "Interne", - "es" : "Interna" - }, - "unused" : true + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs" ] }, "handoff.attributes.stroke" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs:75" ], "translations" : { "en" : "Stroke", - "fr" : "Trait", - "es" : "Borde" - } - }, - "handoff.attributes.stroke.alignment.center" : { - "translations" : { - "en" : "Center", - "fr" : "Centré", - "es" : "Centrado" + "es" : "Borde", + "fr" : "Contour", + "zh_cn" : "边框" }, - "unused" : true - }, - "handoff.attributes.stroke.alignment.inner" : { - "translations" : { - "en" : "Inner", - "fr" : "Intérieur", - "es" : "Interno" - }, - "unused" : true - }, - "handoff.attributes.stroke.alignment.outer" : { - "translations" : { - "en" : "Outer", - "fr" : "Extérieur", - "es" : "Externo" - }, - "unused" : true - }, - "handoff.attributes.stroke.style.dashed" : { - "translations" : { - "en" : "Dashed", - "fr" : "Tiret", - "es" : "Discontinuo" - }, - "unused" : true + "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs" ] }, "handoff.attributes.stroke.style.dotted" : { "translations" : { "en" : "Dotted", + "es" : "Punteado", "fr" : "Pointillé", - "es" : "Punteado" + "zh_cn" : "虚线" }, "unused" : true }, "handoff.attributes.stroke.style.mixed" : { "translations" : { "en" : "Mixed", + "es" : "Mixto", "fr" : "Mixte", - "es" : "Mixto" + "zh_cn" : "混合" }, "unused" : true }, "handoff.attributes.stroke.style.none" : { "translations" : { "en" : "None", + "es" : "Ninguno", "fr" : "Aucun", - "es" : "Ninguno" + "zh_cn" : "无" }, "unused" : true }, "handoff.attributes.stroke.style.solid" : { "translations" : { "en" : "Solid", + "es" : "Sólido", "fr" : "Solide", - "es" : "Sólido" + "zh_cn" : "实线" }, "unused" : true }, "handoff.attributes.stroke.width" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs:63" ], "translations" : { "en" : "Width", - "fr" : "Largeur", - "es" : "Ancho" - } + "es" : "Ancho", + "fr" : "Épaisseur", + "zh_cn" : "宽" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs" ] }, "handoff.attributes.typography" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:189" ], "translations" : { "en" : "Typography", + "es" : "Tipografía", "fr" : "Typographie", - "es" : "Tipografía" - } + "zh_cn" : "排版" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.font-family" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:120" ], "translations" : { "en" : "Font Family", + "es" : "Familia tipográfica", "fr" : "Police de caractères", - "es" : "Familia tipográfica" - } + "zh_cn" : "字体" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.font-size" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:132" ], "translations" : { "en" : "Font Size", + "es" : "Tamaño de fuente", "fr" : "Taille de police", - "es" : "Tamaño de fuente" - } + "zh_cn" : "字号" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.font-style" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:126" ], "translations" : { "en" : "Font Style", + "es" : "Estilo de fuente", "fr" : "Style de police", - "es" : "Estilo de fuente" - } + "zh_cn" : "文字风格" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.letter-spacing" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:144" ], "translations" : { "en" : "Letter Spacing", - "fr" : "Espacement des lettres", - "es" : "Espaciado de letras" - } + "es" : "Espaciado de letras", + "fr" : "Interlettrage", + "zh_cn" : "字距" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.line-height" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:138" ], "translations" : { "en" : "Line Height", - "fr" : "Hauteur de ligne", - "es" : "Interlineado" - } + "es" : "Interlineado", + "fr" : "Interlignage", + "zh_cn" : "行高" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.text-decoration" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:150" ], "translations" : { "en" : "Text Decoration", + "es" : "Decoración de texto", "fr" : "Décoration de texte", - "es" : "Decoración de texto" - } + "zh_cn" : "文字装饰" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.text-decoration.none" : { "translations" : { "en" : "None", + "es" : "Ninguna", "fr" : "Aucune", - "es" : "Ninguna" + "zh_cn" : "无" }, "unused" : true }, "handoff.attributes.typography.text-decoration.strikethrough" : { "translations" : { "en" : "Strikethrough", + "es" : "Tachar", "fr" : "Barré", - "es" : "Tachar" + "zh_cn" : "删除线" }, "unused" : true }, "handoff.attributes.typography.text-decoration.underline" : { "translations" : { "en" : "Underline", - "fr" : "Sousligné", - "es" : "Subrayar" + "es" : "Subrayar", + "fr" : "Soulignage", + "zh_cn" : "下划线" }, "unused" : true }, "handoff.attributes.typography.text-transform" : { - "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:156" ], "translations" : { "en" : "Text Transform", + "es" : "Transformación de texto", "fr" : "Transformation de texte", - "es" : "Transformación de texto" - } + "zh_cn" : "文本变换" + }, + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs" ] }, "handoff.attributes.typography.text-transform.lowercase" : { "translations" : { "en" : "Lower Case", + "es" : "Minúsculas", "fr" : "Minuscule", - "es" : "Minúsculas" + "zh_cn" : "小写" }, "unused" : true }, "handoff.attributes.typography.text-transform.none" : { "translations" : { "en" : "None", + "es" : "Ninguna", "fr" : "Aucune", - "es" : "Ninguna" + "zh_cn" : "无" }, "unused" : true }, "handoff.attributes.typography.text-transform.titlecase" : { "translations" : { "en" : "Title Case", - "fr" : "Première lettre en majuscule", - "es" : "Primera en mayúscula" + "es" : "Primera en mayúscula", + "fr" : "Premières Lettres en Capitales", + "zh_cn" : "首字母大写" }, "unused" : true }, "handoff.attributes.typography.text-transform.uppercase" : { "translations" : { "en" : "Upper Case", - "fr" : "Majuscule", - "es" : "Mayúsculas" + "es" : "Mayúsculas", + "fr" : "Capitales", + "zh_cn" : "大写" }, "unused" : true }, "handoff.tabs.code" : { - "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:65" ], "translations" : { "en" : "Code", + "es" : "Código", "fr" : "Code", - "es" : "Código" - } + "zh_cn" : "码" + }, + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs" ] }, "handoff.tabs.code.selected.circle" : { "translations" : { "en" : "Circle", + "es" : "Círculo", "fr" : "Cercle", - "es" : "Círculo" + "zh_cn" : "圆" }, "unused" : true }, "handoff.tabs.code.selected.curve" : { "translations" : { "en" : "Curve", + "es" : "Curva", "fr" : "Courbe", - "es" : "Curva" + "zh_cn" : "曲线" }, "unused" : true }, "handoff.tabs.code.selected.frame" : { "translations" : { "en" : "Artboard", + "es" : "Mesa de trabajo", "fr" : "Plan de travail", - "es" : "Mesa de trabajo" + "zh_cn" : "画板" }, "unused" : true }, "handoff.tabs.code.selected.group" : { "translations" : { "en" : "Group", + "es" : "Grupo", "fr" : "Groupe", - "es" : "Grupo" + "zh_cn" : "编组" }, "unused" : true }, "handoff.tabs.code.selected.image" : { "translations" : { "en" : "Image", + "es" : "Imagen", "fr" : "Image", - "es" : "Imagen" + "zh_cn" : "图片" }, "unused" : true }, "handoff.tabs.code.selected.multiple" : { - "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:48" ], "translations" : { "en" : "%s Selected", + "es" : "%s Seleccionado", "fr" : "%s Sélectionné", - "es" : "%s Seleccionado" - } + "zh_cn" : "已选中%s项" + }, + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs" ] }, "handoff.tabs.code.selected.path" : { "translations" : { "en" : "Path", + "es" : "Trazado", "fr" : "Chemin", - "es" : "Trazado" + "zh_cn" : "路径" }, "unused" : true }, "handoff.tabs.code.selected.rect" : { "translations" : { "en" : "Rectangle", + "es" : "Rectángulo", "fr" : "Rectangle", - "es" : "Rectángulo" + "zh_cn" : "矩形" }, "unused" : true }, "handoff.tabs.code.selected.svg-raw" : { "translations" : { "en" : "SVG", + "es" : "SVG", "fr" : "SVG", - "es" : "SVG" + "zh_cn" : "SVG" }, "unused" : true }, "handoff.tabs.code.selected.text" : { "translations" : { "en" : "Text", + "es" : "Texto", "fr" : "Texte", - "es" : "Texto" + "zh_cn" : "文本" }, "unused" : true }, "handoff.tabs.info" : { - "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:59" ], "translations" : { "en" : "Info", + "es" : "Información", "fr" : "Information", - "es" : "Información" - } + "zh_cn" : "信息" + }, + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs" ] }, "history.alert-message" : { "translations" : { "en" : "You are seeing version %s", + "es" : "Estás viendo la versión %s", "fr" : "Vous voyez la version %s", "ru" : "Ваша версия %s", - "es" : "Estás viendo la versión %s" + "zh_cn" : "你正在查看%s版本" + }, + "unused" : true + }, + "labels.accept" : { + "translations" : { + "ca" : "Acceptar", + "en" : "Accept", + "es" : "Aceptar", + "fr" : "Accepter", + "ru" : "Принять", + "zh_cn" : "接受" }, "unused" : true }, "labels.admin" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:85", "src/app/main/ui/dashboard/team.cljs:178", "src/app/main/ui/dashboard/team.cljs:194" ], "translations" : { "en" : "Admin", + "es" : "Administración", "fr" : "Administration", - "es" : "Administración" - } + "zh_cn" : "管理员" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs" ] }, "labels.all" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:161" ], "translations" : { "en" : "All", + "es" : "Todo", "fr" : "Tous", - "es" : "Todo" - } + "zh_cn" : "全部" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs" ] }, "labels.bad-gateway.desc-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:58" ], "translations" : { "en" : "Looks like you need to wait a bit and retry; we are performing small maintenance of our servers.", - "fr" : "Il semble que vous deviez attendre un peu et réessayer; nous effectuons une petite maintenance de nos serveurs.", - "es" : "Parece que necesitas esperar un poco y volverlo a intentar; estamos realizando operaciones de mantenimiento en nuestros servidores." - } + "es" : "Parece que necesitas esperar un poco y volverlo a intentar; estamos realizando operaciones de mantenimiento en nuestros servidores.", + "fr" : "Il semble que vous deviez attendre un peu et réessayer ; nous effectuons une petite maintenance de nos serveurs.", + "zh_cn" : "请过会儿再来试试,我们正在对服务器进行一些简单维护。" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] }, "labels.bad-gateway.main-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:57" ], "translations" : { "en" : "Bad Gateway", + "es" : "Bad Gateway", "fr" : "Bad Gateway", - "es" : "Bad Gateway" - } + "zh_cn" : "网关错误" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] }, "labels.cancel" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:215" ], "translations" : { "en" : "Cancel", + "es" : "Cancelar", "fr" : "Annuler", "ru" : "Отмена", - "es" : "Cancelar" - } + "zh_cn" : "取消" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.centered" : { + "translations" : { + "en" : "Center", + "es" : "Centrado", + "fr" : "Centré", + "zh_cn" : "中心" + }, + "unused" : true }, "labels.comments" : { - "used-in" : [ "src/app/main/ui/dashboard/comments.cljs:71" ], "translations" : { "en" : "Comments", + "es" : "Comentarios", "fr" : "Commentaires", - "es" : "Comentarios" - } + "zh_cn" : "评论" + }, + "used-in" : [ "src/app/main/ui/dashboard/comments.cljs" ] }, "labels.confirm-password" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:93" ], "translations" : { "en" : "Confirm password", - "fr" : "Confirmer mot de passe", + "es" : "Confirmar contraseña", + "fr" : "Confirmer le mot de passe", "ru" : "Подтвердите пароль", - "es" : "Confirmar contraseña" - } + "zh_cn" : "确认密码" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] + }, + "labels.content" : { + "translations" : { + "en" : "Content", + "es" : "Contenido", + "fr" : "Contenu", + "zh_cn" : "内容" + }, + "unused" : true + }, + "labels.create-team" : { + "translations" : { + "en" : "Create new team", + "es" : "Crea un nuevo equipo", + "zh_cn" : "创建新团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/team_form.cljs", "src/app/main/ui/dashboard/team_form.cljs" ] }, "labels.dashboard" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:62" ], "translations" : { "en" : "Dashboard", + "es" : "Panel", "fr" : "Tableau de bord", - "es" : "Panel" - } + "zh_cn" : "面板" + }, + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs" ] }, "labels.delete" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:85", "src/app/main/ui/dashboard/grid.cljs:179" ], "translations" : { "en" : "Delete", + "es" : "Borrar", "fr" : "Supprimer", "ru" : "Удалить", - "es" : "Borrar" - } + "zh_cn" : "删除" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs", "src/app/main/ui/dashboard/files.cljs" ] }, "labels.delete-comment" : { - "used-in" : [ "src/app/main/ui/comments.cljs:278" ], "translations" : { "en" : "Delete comment", - "fr" : "Supprimer commentaire", - "es" : "Eliminar comentario" - } + "es" : "Eliminar comentario", + "fr" : "Supprimer le commentaire", + "zh_cn" : "删除该评论" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "labels.delete-comment-thread" : { - "used-in" : [ "src/app/main/ui/comments.cljs:277" ], "translations" : { "en" : "Delete thread", + "es" : "Eliminar hilo", "fr" : "Supprimer le fil", - "es" : "Eliminar hilo" - } + "zh_cn" : "删除该讨论串" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "labels.drafts" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:416" ], "translations" : { "en" : "Drafts", + "es" : "Borradores", "fr" : "Brouillons", "ru" : "Черновики", - "es" : "Borradores" - } + "zh_cn" : "草稿" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "labels.edit" : { - "used-in" : [ "src/app/main/ui/comments.cljs:275" ], "translations" : { "en" : "Edit", - "fr" : "Editer", - "es" : "Editar" - } + "es" : "Editar", + "fr" : "Modifier", + "zh_cn" : "编辑" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "labels.editor" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:86", "src/app/main/ui/dashboard/team.cljs:181", "src/app/main/ui/dashboard/team.cljs:195" ], "translations" : { "en" : "Editor", - "fr" : "Editeur", - "es" : "Editor" - } + "es" : "Editor", + "fr" : "Éditeur", + "zh_cn" : "编辑者" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs" ] }, "labels.email" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:116", "src/app/main/ui/dashboard/team.cljs:221" ], "translations" : { "en" : "Email", - "fr" : "Adresse email", + "es" : "Correo electrónico", + "fr" : "Adresse e‑mail", "ru" : "Email", - "es" : "Correo electrónico" - } + "zh_cn" : "电子邮件" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs" ] }, - "labels.give-feedback" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:231", "src/app/main/ui/dashboard/sidebar.cljs:471" ], - "translations" : { - "en" : "Give feedback", - "fr" : "Donnez votre avis", - "ru" : "Дать обратную связь", - "es" : "Danos tu opinión" - } - }, - "labels.hide-resolved-comments" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:176", "src/app/main/ui/workspace/comments.cljs:129" ], - "translations" : { - "en" : "Hide resolved comments", - "fr" : "Masquer les commentaires résolus", - "es" : "Ocultar comentarios resueltos" - } - }, - "labels.internal-error.desc-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:92" ], - "translations" : { - "en" : "Something bad happened. Please retry the operation and if the problem persists, contact with support.", - "fr" : "Quelque chose d'étrange est arrivé. Veuillez réessayer l'opération, et si le problème persiste, contactez le service technique.", - "es" : "Ha ocurrido algo extraño. Por favor, reintenta la operación, y si el problema persiste, contacta con el servicio técnico." - } - }, - "labels.internal-error.main-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:91" ], - "translations" : { - "en" : "Internal Error", - "fr" : "Erreur interne", - "es" : "Error interno" - } - }, - "labels.language" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:54" ], - "translations" : { - "en" : "Language", - "fr" : "Langue", - "ru" : "Язык", - "es" : "Idioma" - } - }, - "labels.logout" : { - "used-in" : [ "src/app/main/ui/settings.cljs:31", "src/app/main/ui/dashboard/sidebar.cljs:468" ], - "translations" : { - "en" : "Logout", - "fr" : "Quitter", - "ru" : "Выход", - "es" : "Salir" - } - }, - "labels.members" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:311", "src/app/main/ui/dashboard/team.cljs:60", "src/app/main/ui/dashboard/team.cljs:66" ], - "translations" : { - "en" : "Members", - "fr" : "Membres", - "es" : "Integrantes" - } - }, - "labels.name" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:220" ], - "translations" : { - "en" : "Name", - "fr" : "Nom", - "ru" : "Имя", - "es" : "Nombre" - } - }, - "labels.new-password" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:87" ], - "translations" : { - "en" : "New password", - "fr" : "Nouveau mot de passe", - "ru" : "Новый пароль", - "es" : "Nueva contraseña" - } - }, - "labels.no-comments-available" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:186", "src/app/main/ui/dashboard/comments.cljs:96" ], - "translations" : { - "en" : "You have no pending comment notifications", - "fr" : "Vous n'avez aucune notification de commentaire en attente", - "es" : "No tienes notificaciones de comentarios pendientes" - } - }, - "labels.not-found.auth-info" : { - "used-in" : [ "src/app/main/ui/static.cljs:42" ], - "translations" : { - "en" : "You’re signed in as", - "fr" : "Vous êtes connecté en tant que", - "es" : "Estás identificado como" - } - }, - "labels.not-found.desc-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:40" ], - "translations" : { - "en" : "This page might not exist or you don’t have permissions to access to it.", - "fr" : "Cette page n'existe pas ou vous ne disposez pas des permissions nécessaires pour y accéder.", - "es" : "Esta página no existe o no tienes permisos para verla." - } - }, - "labels.not-found.main-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:39" ], - "translations" : { - "en" : "Oops!", - "fr" : "Oups!", - "es" : "¡Huy!" - } - }, - "labels.num-of-files" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:314" ], - "translations" : { - "en" : [ "1 file", "%s files" ], - "fr" : [ "1 fichier", "%s fichiers" ], - "es" : [ "1 archivo", "%s archivos" ] - } - }, - "labels.num-of-projects" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:311" ], - "translations" : { - "en" : [ "1 project", "%s projects" ], - "fr" : [ "1 projet", "%s projets" ], - "es" : [ "1 proyecto", "%s proyectos" ] - } - }, - "labels.old-password" : { - "used-in" : [ "src/app/main/ui/settings/password.cljs:81" ], - "translations" : { - "en" : "Old password", - "fr" : "Ancien mot de passe", - "ru" : "Старый пароль", - "es" : "Contraseña anterior" - } - }, - "labels.only-yours" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:162" ], - "translations" : { - "en" : "Only yours", - "fr" : "Seulement les votres", - "es" : "Sólo los tuyos" - } - }, - "labels.owner" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:175", "src/app/main/ui/dashboard/team.cljs:302" ], - "translations" : { - "en" : "Owner", - "fr" : "Propriétaire", - "es" : "Dueño" - } - }, - "labels.password" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:75", "src/app/main/ui/dashboard/sidebar.cljs:465" ], - "translations" : { - "en" : "Password", - "fr" : "Mot de passe", - "ru" : "Пароль", - "es" : "Contraseña" - } - }, - "labels.permissions" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:222" ], - "translations" : { - "en" : "Permissions", - "fr" : "Permissions", - "es" : "Permisos" - } - }, - "labels.profile" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:70", "src/app/main/ui/dashboard/sidebar.cljs:462" ], - "translations" : { - "en" : "Profile", - "fr" : "Profil", - "ru" : "Профиль", - "es" : "Perfil" - } - }, - "labels.projects" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:412" ], - "translations" : { - "en" : "Projects", - "fr" : "Projets", - "ru" : "Проекты", - "es" : "Proyectos" - } - }, - "labels.remove" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:92", "src/app/main/ui/dashboard/team.cljs:208" ], - "translations" : { - "en" : "Remove", - "fr" : "Retirer", - "ru" : "", - "es" : "Quitar" - } - }, - "labels.rename" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:314", "src/app/main/ui/dashboard/files.cljs:84", "src/app/main/ui/dashboard/grid.cljs:178" ], - "translations" : { - "en" : "Rename", - "fr" : "Renommer", - "es" : "Renombrar" - } - }, - "labels.retry" : { - "used-in" : [ "src/app/main/ui/static.cljs:62", "src/app/main/ui/static.cljs:79", "src/app/main/ui/static.cljs:96" ], - "translations" : { - "en" : "Retry", - "fr" : "Réessayer", - "es" : "Reintentar" - } - }, - "labels.role" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:84" ], - "translations" : { - "en" : "Role", - "fr" : "Rôle", - "es" : "Cargo" - } - }, - "labels.send" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:93" ], - "translations" : { - "en" : "Send", - "es" : "Enviar" - } - }, - "labels.sending" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs:93" ], - "translations" : { - "en" : "Sending...", - "es" : "Enviando..." - } - }, - "labels.feedback-disabled" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ], "translations" : { "en" : "Feedback disabled", - "es" : "El modulo de recepción de opiniones esta deshabilitado." - } + "es" : "El modulo de recepción de opiniones esta deshabilitado.", + "zh_cn" : "反馈被禁止" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, - "labels.feedback-sent" : { - "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ], "translations" : { "en" : "Feedback sent", - "es" : "Opinión enviada" - } + "es" : "Opinión enviada", + "zh_cn" : "反馈已发出" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] + }, + "labels.give-feedback" : { + "translations" : { + "en" : "Give feedback", + "es" : "Danos tu opinión", + "fr" : "Donnez votre avis", + "ru" : "Дать обратную связь", + "zh_cn" : "提交反馈" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/settings/sidebar.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.hide-resolved-comments" : { + "translations" : { + "en" : "Hide resolved comments", + "es" : "Ocultar comentarios resueltos", + "fr" : "Masquer les commentaires résolus", + "zh_cn" : "隐藏已决定的评论" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs", "src/app/main/ui/viewer/header.cljs" ] + }, + "labels.icons" : { + "translations" : { + "en" : "Icons", + "es" : "Iconos", + "fr" : "Icônes", + "ru" : "Иконки", + "zh_cn" : "图标" + }, + "unused" : true + }, + "labels.images" : { + "translations" : { + "en" : "Images", + "es" : "Imágenes", + "fr" : "Images", + "ru" : "Изображения", + "zh_cn" : "图片" + }, + "unused" : true + }, + "labels.internal-error.desc-message" : { + "translations" : { + "en" : "Something bad happened. Please retry the operation and if the problem persists, contact with support.", + "es" : "Ha ocurrido algo extraño. Por favor, reintenta la operación, y si el problema persiste, contacta con el servicio técnico.", + "fr" : "Un problème s’est produit. Veuillez réessayer l’opération et, si le problème persiste, contacter le service technique.", + "zh_cn" : "发生了一些不妙的事。请尝试重新操作。如果问题仍然存在,请联系我们以取得支持。" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] + }, + "labels.internal-error.main-message" : { + "translations" : { + "en" : "Internal Error", + "es" : "Error interno", + "fr" : "Erreur interne", + "zh_cn" : "内部错误" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] + }, + "labels.language" : { + "translations" : { + "en" : "Language", + "es" : "Idioma", + "fr" : "Langue", + "ru" : "Язык", + "zh_cn" : "语言" + }, + "used-in" : [ "src/app/main/ui/settings/options.cljs" ] + }, + "labels.logout" : { + "translations" : { + "en" : "Logout", + "es" : "Salir", + "fr" : "Se déconnecter", + "ru" : "Выход", + "zh_cn" : "登出" + }, + "used-in" : [ "src/app/main/ui/settings.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.members" : { + "translations" : { + "en" : "Members", + "es" : "Integrantes", + "fr" : "Membres", + "zh_cn" : "成员" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.name" : { + "translations" : { + "en" : "Name", + "es" : "Nombre", + "fr" : "Nom", + "ru" : "Имя", + "zh_cn" : "名字" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.new-password" : { + "translations" : { + "en" : "New password", + "es" : "Nueva contraseña", + "fr" : "Nouveau mot de passe", + "ru" : "Новый пароль", + "zh_cn" : "新密码" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] + }, + "labels.no-comments-available" : { + "translations" : { + "en" : "You have no pending comment notifications", + "es" : "No tienes notificaciones de comentarios pendientes", + "fr" : "Vous n’avez aucune notification de commentaire en attente", + "zh_cn" : "没有待表决的评论通知" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs", "src/app/main/ui/dashboard/comments.cljs" ] + }, + "labels.not-found.auth-info" : { + "translations" : { + "en" : "You’re signed in as", + "es" : "Estás identificado como", + "fr" : "Vous êtes connecté en tant que", + "zh_cn" : "你已登陆为" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] + }, + "labels.not-found.desc-message" : { + "translations" : { + "en" : "This page might not exist or you don’t have permissions to access to it.", + "es" : "Esta página no existe o no tienes permisos para verla.", + "fr" : "Cette page n’existe pas ou vous ne disposez pas des permissions nécessaires pour y accéder.", + "zh_cn" : "可能该页面不存在,也可能你没有访问权限。" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] + }, + "labels.not-found.main-message" : { + "translations" : { + "en" : "Oops!", + "es" : "¡Huy!", + "fr" : "Oups !", + "zh_cn" : "嚯!" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] + }, + "labels.num-of-files" : { + "translations" : { + "en" : [ "1 file", "%s files" ], + "es" : [ "1 archivo", "%s archivos" ], + "fr" : [ "1 fichier", "%s fichiers" ], + "zh_cn" : [ "1 个文档", "共 %s 个文档" ] + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.num-of-projects" : { + "translations" : { + "en" : [ "1 project", "%s projects" ], + "es" : [ "1 proyecto", "%s proyectos" ], + "fr" : [ "1 projet", "%s projets" ], + "zh_cn" : [ "1 个项目", "共 %s 个项目" ] + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.old-password" : { + "translations" : { + "en" : "Old password", + "es" : "Contraseña anterior", + "fr" : "Ancien mot de passe", + "ru" : "Старый пароль", + "zh_cn" : "旧密码" + }, + "used-in" : [ "src/app/main/ui/settings/password.cljs" ] + }, + "labels.only-yours" : { + "translations" : { + "en" : "Only yours", + "es" : "Sólo los tuyos", + "fr" : "Seulement les vôtres", + "zh_cn" : "仅你的" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs" ] + }, + "labels.owner" : { + "translations" : { + "en" : "Owner", + "es" : "Dueño", + "fr" : "Propriétaire", + "zh_cn" : "所有者" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.password" : { + "translations" : { + "en" : "Password", + "es" : "Contraseña", + "fr" : "Mot de passe", + "ru" : "Пароль", + "zh_cn" : "密码" + }, + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.permissions" : { + "translations" : { + "en" : "Permissions", + "es" : "Permisos", + "fr" : "Permissions", + "zh_cn" : "许可" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.profile" : { + "translations" : { + "en" : "Profile", + "es" : "Perfil", + "fr" : "Profil", + "ru" : "Профиль", + "zh_cn" : "个人资料" + }, + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.projects" : { + "translations" : { + "en" : "Projects", + "es" : "Proyectos", + "fr" : "Projets", + "ru" : "Проекты", + "zh_cn" : "项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] + }, + "labels.recent" : { + "translations" : { + "ca" : "Recent", + "en" : "Recent", + "es" : "Reciente", + "fr" : "Récent", + "ru" : "Недавние", + "zh_cn" : "最近" + }, + "unused" : true + }, + "labels.remove" : { + "translations" : { + "en" : "Remove", + "es" : "Quitar", + "fr" : "Retirer", + "ru" : "", + "zh_cn" : "移除" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs", "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.rename" : { + "translations" : { + "en" : "Rename", + "es" : "Renombrar", + "fr" : "Renommer", + "zh_cn" : "重命名" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs", "src/app/main/ui/dashboard/sidebar.cljs", "src/app/main/ui/dashboard/files.cljs" ] + }, + "labels.rename-team" : { + "translations" : { + "en" : "Rename team", + "es" : "Renomba el equipo", + "zh_cn" : "重命名团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/team_form.cljs" ] + }, + "labels.retry" : { + "translations" : { + "en" : "Retry", + "es" : "Reintentar", + "fr" : "Réessayer", + "zh_cn" : "重试" + }, + "used-in" : [ "src/app/main/ui/static.cljs", "src/app/main/ui/static.cljs", "src/app/main/ui/static.cljs" ] + }, + "labels.role" : { + "translations" : { + "en" : "Role", + "es" : "Cargo", + "fr" : "Rôle", + "zh_cn" : "角色" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] + }, + "labels.save" : { + "translations" : { + "ca" : "Desa", + "en" : "Save", + "es" : "Guardar", + "fr" : "Enregistrer", + "ru" : "Сохранить", + "zh_cn" : "保存" + }, + "unused" : true + }, + "labels.send" : { + "translations" : { + "en" : "Send", + "es" : "Enviar", + "zh_cn" : "发送" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] + }, + "labels.sending" : { + "translations" : { + "en" : "Sending...", + "es" : "Enviando...", + "zh_cn" : "正在发送…" + }, + "used-in" : [ "src/app/main/ui/settings/feedback.cljs" ] }, - "labels.service-unavailable.desc-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:75" ], "translations" : { "en" : "We are in programmed maintenance of our systems.", + "es" : "Estamos en una operación de mantenimiento programado de nuestros sistemas.", "fr" : "Nous sommes en maintenance planifiée de nos systèmes.", - "es" : "Estamos en una operación de mantenimiento programado de nuestros sistemas." - } + "zh_cn" : "我们正在进行系统的程序维护。" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] }, "labels.service-unavailable.main-message" : { - "used-in" : [ "src/app/main/ui/static.cljs:74" ], "translations" : { "en" : "Service Unavailable", + "es" : "El servicio no está disponible", "fr" : "Service non disponible", - "es" : "El servicio no está disponible" - } + "zh_cn" : "服务不可用" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] }, "labels.settings" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:80", "src/app/main/ui/dashboard/sidebar.cljs:312", "src/app/main/ui/dashboard/team.cljs:61", "src/app/main/ui/dashboard/team.cljs:68" ], "translations" : { "en" : "Settings", + "es" : "Configuración", "fr" : "Configuration", "ru" : "Параметры", - "es" : "Configuración" - } + "zh_cn" : "设置" + }, + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs", "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/team.cljs", "src/app/main/ui/dashboard/sidebar.cljs" ] }, "labels.shared-libraries" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:421" ], "translations" : { "en" : "Shared Libraries", + "es" : "Bibliotecas Compartidas", "fr" : "Bibliothèques Partagées", "ru" : "", - "es" : "Bibliotecas Compartidas" - } + "zh_cn" : "共享库" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "labels.show-all-comments" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:164", "src/app/main/ui/workspace/comments.cljs:117" ], "translations" : { "en" : "Show all comments", + "es" : "Mostrar todos los comentarios", "fr" : "Afficher tous les commentaires", - "es" : "Mostrar todos los comentarios" - } + "zh_cn" : "显示所有评论" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs", "src/app/main/ui/viewer/header.cljs" ] }, "labels.show-your-comments" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:169", "src/app/main/ui/workspace/comments.cljs:122" ], "translations" : { "en" : "Show only yours comments", + "es" : "Mostrar sólo tus comentarios", "fr" : "Afficher uniquement vos commentaires", - "es" : "Mostrar sólo tus comentarios" - } + "zh_cn" : "只显示你的评论" + }, + "used-in" : [ "src/app/main/ui/workspace/comments.cljs", "src/app/main/ui/viewer/header.cljs" ] }, "labels.sign-out" : { - "used-in" : [ "src/app/main/ui/static.cljs:45" ], "translations" : { "en" : "Sign out", - "fr" : "Quitter", + "es" : "Salir", + "fr" : "Se déconnecter", "ru" : "Выход", - "es" : "Salir" - } + "zh_cn" : "登出" + }, + "used-in" : [ "src/app/main/ui/static.cljs" ] }, "labels.update" : { - "used-in" : [ "src/app/main/ui/settings/profile.cljs:104" ], "translations" : { "en" : "Update", + "es" : "Actualizar", "fr" : "Actualiser", "ru" : "Обновить", - "es" : "Actualizar" - } + "zh_cn" : "更新" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs" ] + }, + "labels.update-team" : { + "translations" : { + "en" : "Update team", + "es" : "Actualiza el equipo", + "zh_cn" : "更新团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/team_form.cljs" ] }, "labels.viewer" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:184" ], "translations" : { "en" : "Viewer", - "fr" : "Téléspectateur", - "es" : "Visualizador" - } + "es" : "Visualizador", + "fr" : "Spectateur", + "zh_cn" : "查看者" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "labels.write-new-comment" : { - "used-in" : [ "src/app/main/ui/comments.cljs:154" ], "translations" : { "en" : "Write new comment", + "es" : "Escribir un nuevo comentario", "fr" : "Écrire un nouveau commentaire", - "es" : "Escribir un nuevo comentario" - } + "zh_cn" : "写一条新评论" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "media.loading" : { - "used-in" : [ "src/app/main/data/media.cljs:60", "src/app/main/data/workspace/persistence.cljs:504", "src/app/main/data/workspace/persistence.cljs:559" ], "translations" : { - "en" : "Loading image...", - "fr" : "Chargement de l'image...", - "ru" : "Загружаю изображение...", - "es" : "Cargando imagen..." - } - }, - "modal.create-color.new-color" : { - "translations" : { - "en" : "New Color", - "fr" : "Nouvelle couleur", - "ru" : "Новый цвет", - "es" : "Nuevo color" + "en" : "Loading image…", + "es" : "Cargando imagen…", + "fr" : "Chargement de l’image…", + "ru" : "Загружаю изображение…", + "zh_cn" : "正在加载图片…" }, - "unused" : true + "used-in" : [ "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/workspace/persistence.cljs", "src/app/main/data/media.cljs" ] }, "modals.add-shared-confirm.accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:114", "src/app/main/ui/dashboard/grid.cljs:116" ], "translations" : { "en" : "Add as Shared Library", + "es" : "Añadir como Biblioteca Compartida", "fr" : "Ajouter comme Bibliothèque Partagée", "ru" : "", - "es" : "Añadir como Biblioteca Compartida" - } + "zh_cn" : "添加为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.add-shared-confirm.hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:112", "src/app/main/ui/dashboard/grid.cljs:114" ], "translations" : { "en" : "Once added as Shared Library, the assets of this file library will be available to be used among the rest of your files.", - "fr" : "Une fois ajoutés en tant que Bibliothèque Partagée, les ressources de cette bibliothèque de fichiers seront disponibles pour être utilisés parmi le reste de vos fichiers.", + "es" : "Una vez añadido como Biblioteca Compartida, los recursos de este archivo estarán disponibles para ser usado por el resto de tus archivos.", + "fr" : "Une fois ajoutées en tant que Bibliothèque Partagée, les ressources de cette bibliothèque de fichiers seront disponibles pour être utilisées parmi le reste de vos fichiers.", "ru" : "", - "es" : "Una vez añadido como Biblioteca Compartida, los recursos de este archivo estarán disponibles para ser usado por el resto de tus archivos." - } + "zh_cn" : "一旦添加为共享库,此文档库中的素材就可被用于你的其他文档中。" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.add-shared-confirm.message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:111", "src/app/main/ui/dashboard/grid.cljs:113" ], "translations" : { "en" : "Add “%s” as Shared Library", - "fr" : "Ajouter “%s” comme Bibliothèque Partagée", + "es" : "Añadir “%s” como Biblioteca Compartida", + "fr" : "Ajouter « %s » comme Bibliothèque Partagée", "ru" : "", - "es" : "Añadir “%s” como Biblioteca Compartida" - } + "zh_cn" : "将“%s”添加为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.change-email.confirm-email" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:103" ], "translations" : { "en" : "Verify new email", - "fr" : "Vérifier la nouvelle adresse e-mail", + "es" : "Verificar el nuevo correo", + "fr" : "Vérifier la nouvelle adresse e‑mail", "ru" : "Подтвердить новый email адрес", - "es" : "Verificar el nuevo correo" - } + "zh_cn" : "验证新的邮件" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "modals.change-email.info" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:93" ], "translations" : { "en" : "We'll send you an email to your current email “%s” to verify your identity.", - "fr" : "Nous vous enverrons un e-mail à votre adresse actuelle “%s” pour vérifier votre identité.", + "es" : "Enviaremos un mensaje a tu correo actual “%s” para verificar tu identidad.", + "fr" : "Nous enverrons un e‑mail à votre adresse actuelle « %s » pour vérifier votre identité.", "ru" : "Мы отправим письмо для подтверждения подлиности на текущий email адрес “%s”.", - "es" : "Enviaremos un mensaje a tu correo actual “%s” para verificar tu identidad." - } + "zh_cn" : "我们会发送一封信的邮件到当前的电子邮件“%s”,以验证你的身份。" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "modals.change-email.new-email" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:98" ], "translations" : { "en" : "New email", - "fr" : "Nouvel e-mail", + "es" : "Nuevo correo", + "fr" : "Nouvel e‑mail", "ru" : "Новый email адрес", - "es" : "Nuevo correo" - } + "zh_cn" : "新电子邮件" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "modals.change-email.submit" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:109" ], "translations" : { "en" : "Change email", - "fr" : "Changer adresse e-mail", + "es" : "Cambiar correo", + "fr" : "Changer adresse e‑mail", "ru" : "Сменить email адрес", - "es" : "Cambiar correo" - } + "zh_cn" : "修改点子邮件" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "modals.change-email.title" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:86" ], "translations" : { "en" : "Change your email", - "fr" : "Changer adresse e-mail", + "es" : "Cambiar tu correo", + "fr" : "Changez votre adresse e‑mail", "ru" : "Сменить email адрес", - "es" : "Cambiar tu correo" - } + "zh_cn" : "修改你的电子邮件" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "modals.delete-account.cancel" : { - "used-in" : [ "src/app/main/ui/settings/delete_account.cljs:69" ], "translations" : { "en" : "Cancel and keep my account", + "es" : "Cancelar y mantener mi cuenta", "fr" : "Annuler et conserver mon compte", "ru" : "Отменить и сохранить мой аккаунт", - "es" : "Cancelar y mantener mi cuenta" - } + "zh_cn" : "取消操作并保留我的账号" + }, + "used-in" : [ "src/app/main/ui/settings/delete_account.cljs" ] }, "modals.delete-account.confirm" : { - "used-in" : [ "src/app/main/ui/settings/delete_account.cljs:67" ], "translations" : { "en" : "Yes, delete my account", - "fr" : "Oui, supprimez mon compte", + "es" : "Si, borrar mi cuenta", + "fr" : "Oui, supprimer mon compte", "ru" : "Да, удалить мой аккаунт", - "es" : "Si, borrar mi cuenta" - } + "zh_cn" : "是的,删除我的账号" + }, + "used-in" : [ "src/app/main/ui/settings/delete_account.cljs" ] }, "modals.delete-account.info" : { - "used-in" : [ "src/app/main/ui/settings/delete_account.cljs:62" ], "translations" : { "en" : "By removing your account you’ll lose all your current projects and archives.", - "fr" : "En supprimant votre compte, vous perdrez tous vos projets et archives actuels.", + "es" : "Si borras tu cuenta perderás todos tus proyectos y archivos.", + "fr" : "En supprimant votre compte, vous perdrez tous vos projets et archives actuelles.", "ru" : "Удалив аккаунт Вы потеряете все прокты и архивы.", - "es" : "Si borras tu cuenta perderás todos tus proyectos y archivos." - } + "zh_cn" : "删除账号后,你会失去所有项目和存档。" + }, + "used-in" : [ "src/app/main/ui/settings/delete_account.cljs" ] }, "modals.delete-account.title" : { - "used-in" : [ "src/app/main/ui/settings/delete_account.cljs:55" ], "translations" : { "en" : "Are you sure you want to delete your account?", - "fr" : "Voulez-vous vraiment supprimer votre compte?", + "es" : "¿Seguro que quieres borrar tu cuenta?", + "fr" : "Êtes‑vous sûr de vouloir supprimer votre compte ?", "ru" : "Вы уверены, что хотите удалить аккаунт?", - "es" : "¿Seguro que quieres borrar tu cuenta?" - } + "zh_cn" : "你确定想要删除你的账号?" + }, + "used-in" : [ "src/app/main/ui/settings/delete_account.cljs" ] }, "modals.delete-comment-thread.accept" : { - "used-in" : [ "src/app/main/ui/comments.cljs:227" ], "translations" : { "en" : "Delete conversation", + "es" : "Eliminar conversación", "fr" : "Supprimer la conversation", - "es" : "Eliminar conversación" - } + "zh_cn" : "删除对话" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "modals.delete-comment-thread.message" : { - "used-in" : [ "src/app/main/ui/comments.cljs:226" ], "translations" : { "en" : "Are you sure you want to delete this conversation? All comments in this thread will be deleted.", - "fr" : "Voulez-vous vraiment supprimer cette conversation? Tous les commentaires de ce fil seront supprimés.", - "es" : "¿Seguro que quieres eliminar esta conversación? Todos los comentarios en este hilo serán eliminados." - } + "es" : "¿Seguro que quieres eliminar esta conversación? Todos los comentarios en este hilo serán eliminados.", + "fr" : "Êtes‑vous sûr de vouloir supprimer cette conversation ? Tous les commentaires de ce fil seront supprimés.", + "zh_cn" : "你确定想要删除这个对话?该讨论串里的所有评论都会被一同删除。" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "modals.delete-comment-thread.title" : { - "used-in" : [ "src/app/main/ui/comments.cljs:225" ], "translations" : { "en" : "Delete conversation", - "fr" : "Supprimer la conversation", - "es" : "Eliminar conversación" - } + "es" : "Eliminar conversación", + "fr" : "Supprimer une conversation", + "zh_cn" : "删除对话" + }, + "used-in" : [ "src/app/main/ui/comments.cljs" ] }, "modals.delete-file-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:82" ], "translations" : { "en" : "Delete file", + "es" : "Eliminar archivo", "fr" : "Supprimer le fichier", - "es" : "Eliminar archivo" - } + "zh_cn" : "删除文档" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.delete-file-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:81" ], "translations" : { "en" : "Are you sure you want to delete this file?", - "fr" : "Êtes-vous sûr de vouloir supprimer ce fichier?", - "es" : "¿Seguro que quieres eliminar este archivo?" - } + "es" : "¿Seguro que quieres eliminar este archivo?", + "fr" : "Êtes‑vous sûr de vouloir supprimer ce fichier ?", + "zh_cn" : "你确定想要删除这个文档?" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.delete-file-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:80" ], "translations" : { "en" : "Deleting file", - "fr" : "Supprimer le fichier", - "es" : "Eliminando archivo" - } + "es" : "Eliminando archivo", + "fr" : "Supprimer un fichier", + "zh_cn" : "正在删除文档" + }, + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.delete-page.body" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:60" ], "translations" : { "en" : "Are you sure you want to delete this page?", - "fr" : "Êtes-vous sûr de vouloir supprimer cette page?", - "es" : "¿Seguro que quieres borrar esta página?" - } + "es" : "¿Seguro que quieres borrar esta página?", + "fr" : "Êtes‑vous sûr de vouloir supprimer cette page ?", + "zh_cn" : "你确定想要删除这个页面?" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs" ] }, "modals.delete-page.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:59" ], "translations" : { "en" : "Delete page", - "fr" : "Supprimer la page", - "es" : "Borrar página" - } + "es" : "Borrar página", + "fr" : "Supprimer une page", + "zh_cn" : "删除页面" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs" ] }, "modals.delete-project-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:58" ], "translations" : { "en" : "Delete project", + "es" : "Eliminar proyecto", "fr" : "Supprimer le projet", - "es" : "Eliminar proyecto" - } + "zh_cn" : "删除项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/files.cljs" ] }, "modals.delete-project-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:57" ], "translations" : { "en" : "Are you sure you want to delete this project?", - "fr" : "Êtes-vous sûr de vouloir supprimer ce projet?", - "es" : "¿Seguro que quieres eliminar este proyecto?" - } + "es" : "¿Seguro que quieres eliminar este proyecto?", + "fr" : "Êtes‑vous sûr de vouloir supprimer ce projet ?", + "zh_cn" : "你确定想要删除这个项目?" + }, + "used-in" : [ "src/app/main/ui/dashboard/files.cljs" ] }, "modals.delete-project-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:56" ], "translations" : { "en" : "Delete project", - "fr" : "Supprimer le projet", - "es" : "Eliminar proyecto" - } + "es" : "Eliminar proyecto", + "fr" : "Supprimer un projet", + "zh_cn" : "删除项目" + }, + "used-in" : [ "src/app/main/ui/dashboard/files.cljs" ] }, "modals.delete-team-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:301" ], "translations" : { "en" : "Delete team", - "fr" : "Supprimer l'équipe", - "es" : "Eliminar equipo" - } + "es" : "Eliminar equipo", + "fr" : "Supprimer l’équipe", + "zh_cn" : "删除团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.delete-team-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:300" ], "translations" : { "en" : "Are you sure you want to delete this team? All projects and files associated with team will be permanently deleted.", - "fr" : "Voulez-vous vraiment supprimer cette équipe? Tous les projets et fichiers associés à l'équipe seront définitivement supprimés.", - "es" : "¿Seguro que quieres eliminar este equipo? Todos los proyectos y archivos asociados con el equipo serán eliminados permamentemente." - } + "es" : "¿Seguro que quieres eliminar este equipo? Todos los proyectos y archivos asociados con el equipo serán eliminados permamentemente.", + "fr" : "Êtes‑vous sûr de vouloir supprimer cette équipe ? Tous les projets et fichiers associés à l’équipe seront définitivement supprimés.", + "zh_cn" : "你确定想要删除这个团队?与该团队关联的所有项目和文档都会被永久删除。" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.delete-team-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:299" ], "translations" : { "en" : "Deleting team", - "fr" : "Suppression de l'équipe", - "es" : "Eliminando equipo" - } + "es" : "Eliminando equipo", + "fr" : "Suppression d’une équipe", + "zh_cn" : "正在删除团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.delete-team-member-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:164" ], "translations" : { "en" : "Delete member", + "es" : "Eliminando miembro", "fr" : "Supprimer le membre", - "es" : "Eliminando miembro" - } + "zh_cn" : "删除成员" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.delete-team-member-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:163" ], "translations" : { "en" : "Are you sure you want to delete this member from the team?", - "fr" : "Voulez-vous vraiment supprimer ce membre de l'équipe?", - "es" : "¿Seguro que quieres eliminar este integrante del equipo?" - } + "es" : "¿Seguro que quieres eliminar este integrante del equipo?", + "fr" : "Êtes‑vous sûr de vouloir supprimer ce membre de l’équipe ?", + "zh_cn" : "你确定想要从团队中删除这个成员?" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.delete-team-member-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:162" ], "translations" : { "en" : "Delete team member", - "fr" : "Supprimer le membre de l'équipe", - "es" : "Eliminar integrante del equipo" - } + "es" : "Eliminar integrante del equipo", + "fr" : "Supprimer un membre d’équipe", + "zh_cn" : "删除团队成员" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.invite-member.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:112" ], "translations" : { "en" : "Invite to join the team", - "fr" : "Inviter à rejoindre l'équipe", - "es" : "Invitar a unirse al equipo" - } + "es" : "Invitar a unirse al equipo", + "fr" : "Inviter à rejoindre l’équipe", + "zh_cn" : "邀请加入团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.leave-and-reassign.hint1" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:204" ], "translations" : { "en" : "You are %s owner.", + "es" : "Eres %s dueño.", "fr" : "Vous êtes le propriétaire de %s.", - "es" : "Eres %s dueño." - } + "zh_cn" : "你是%s的所有者。" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-and-reassign.hint2" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:205" ], "translations" : { "en" : "Select other member to promote before leave", - "fr" : "Sélectionnez un autre membre à promouvoir avant de partir", - "es" : "Promociona otro miembro a dueño antes de abandonar el equipo" - } + "es" : "Promociona otro miembro a dueño antes de abandonar el equipo", + "fr" : "Sélectionnez un autre membre à promouvoir avant de quitter l’équipe", + "zh_cn" : "请在退出前,从其他成员中选择一位晋升。" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-and-reassign.promote-and-leave" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:222" ], "translations" : { "en" : "Promote and leave", + "es" : "Promocionar y abandonar", "fr" : "Promouvoir et quitter", - "es" : "Promocionar y abandonar" - } + "zh_cn" : "晋升并退出" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-and-reassign.select-memeber-to-promote" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:182" ], "translations" : { "en" : "Select a member to promote", + "es" : "Selecciona un miembro a promocionar", "fr" : "Sélectionnez un membre à promouvoir", - "es" : "Selecciona un miembro a promocionar" - } + "zh_cn" : "选择一位成员晋升" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-and-reassign.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:199" ], "translations" : { "en" : "Select a member to promote", + "es" : "Selecciona un miembro a promocionar", "fr" : "Sélectionnez un membre à promouvoir", - "es" : "Selecciona un miembro a promocionar" - } + "zh_cn" : "选择一位成员晋升" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:276" ], "translations" : { "en" : "Leave team", - "fr" : "Quitter l'équipe", - "es" : "Abandonar el equipo" - } + "es" : "Abandonar el equipo", + "fr" : "Quitter l’équipe", + "zh_cn" : "退出团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:275" ], "translations" : { "en" : "Are you sure you want to leave this team?", - "fr" : "Êtes-vous sûr de vouloir quitter cette équipe?", - "es" : "¿Seguro que quieres abandonar este equipo?" - } + "es" : "¿Seguro que quieres abandonar este equipo?", + "fr" : "Êtes‑vous sûr de vouloir quitter cette équipe ?", + "zh_cn" : "选择一位成员晋升" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.leave-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:274" ], "translations" : { "en" : "Leaving team", - "fr" : "Quitter l'équipe", - "es" : "Abandonando el equipo" - } + "es" : "Abandonando el equipo", + "fr" : "Quitter l’équipe", + "zh_cn" : "正在退出团队" + }, + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs" ] }, "modals.promote-owner-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:151" ], "translations" : { "en" : "Promote", + "es" : "Promocionar", "fr" : "Promouvoir", - "es" : "Promocionar" - } + "zh_cn" : "晋升" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.promote-owner-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:150" ], "translations" : { "en" : "Are you sure you want to promote this user to owner?", - "fr" : "Voulez-vous vraiment promouvoir cet utilisateur en propriétaire?", - "es" : "¿Seguro que quieres promocionar este usuario a dueño?" - } + "es" : "¿Seguro que quieres promocionar este usuario a dueño?", + "fr" : "Êtes‑vous sûr de vouloir promouvoir cette personne propriétaire ?", + "zh_cn" : "你确定想要晋升该用户为所有者?" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.promote-owner-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:149" ], "translations" : { "en" : "Promote to owner", - "fr" : "Promouvoir en propriétaire", - "es" : "Promocionar a dueño" - } + "es" : "Promocionar a dueño", + "fr" : "Promouvoir propriétaire", + "zh_cn" : "晋升为所有者" + }, + "used-in" : [ "src/app/main/ui/dashboard/team.cljs" ] }, "modals.remove-shared-confirm.accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:127", "src/app/main/ui/dashboard/grid.cljs:132" ], "translations" : { "en" : "Remove as Shared Library", + "es" : "Eliminar como Biblioteca Compartida", "fr" : "Supprimer en tant que Bibliothèque Partagée", "ru" : "", - "es" : "Eliminar como Biblioteca Compartida" - } + "zh_cn" : "不再作为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.remove-shared-confirm.hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:125", "src/app/main/ui/dashboard/grid.cljs:130" ], "translations" : { "en" : "Once removed as Shared Library, the File Library of this file will stop being available to be used among the rest of your files.", + "es" : "Una vez eliminado como Biblioteca Compartida, la Biblioteca de este archivo dejará de estar disponible para ser usada por el resto de tus archivos.", "fr" : "Une fois supprimée en tant que Bibliothèque Partagée, la Bibliothèque de ce fichier ne pourra plus être utilisée par le reste de vos fichiers.", "ru" : "", - "es" : "Una vez eliminado como Biblioteca Compartida, la Biblioteca de este archivo dejará de estar disponible para ser usada por el resto de tus archivos." - } + "zh_cn" : "一旦不再作为共享库,该文档库就不能继续用于你的其他文档中。" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.remove-shared-confirm.message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:124", "src/app/main/ui/dashboard/grid.cljs:129" ], "translations" : { "en" : "Remove “%s” as Shared Library", - "fr" : "Retirer “%s” en tant que Bibliothèque Partagée", + "es" : "Añadir “%s” como Biblioteca Compartida", + "fr" : "Retirer « %s » en tant que Bibliothèque Partagée", "ru" : "", - "es" : "Añadir “%s” como Biblioteca Compartida" - } + "zh_cn" : "不再将“%s”作为共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs", "src/app/main/ui/dashboard/grid.cljs" ] }, "modals.update-remote-component.accept" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:95", "src/app/main/ui/workspace/sidebar/options/component.cljs:72" ], "translations" : { "en" : "Update component", + "es" : "Actualizar componente", "fr" : "Actualiser le composant", "ru" : "", - "es" : "Actualizar componente" - } + "zh_cn" : "更新组件" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "modals.update-remote-component.cancel" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:94", "src/app/main/ui/workspace/sidebar/options/component.cljs:71" ], "translations" : { "en" : "Cancel", + "es" : "Cancelar", "fr" : "Annuler", "ru" : "", - "es" : "Cancelar" - } + "zh_cn" : "取消" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "modals.update-remote-component.hint" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:93", "src/app/main/ui/workspace/sidebar/options/component.cljs:70" ], "translations" : { "en" : "You are about to update a component in a shared library. This may affect other files that use it.", - "fr" : "Vous êtes sur le point de mettre à jour un composant dans une bibliothèque partagée. Cela peut affecter d'autres fichiers qui l'utilisent.", + "es" : "Vas a actualizar un componente en una librería compartida. Esto puede afectar a otros archivos que la usen.", + "fr" : "Vous êtes sur le point de mettre à jour le composant d’une Bibliothèque Partagée. Cela peut affecter d’autres fichiers qui l’utilisent.", "ru" : "", - "es" : "Vas a actualizar un componente en una librería compartida. Esto puede afectar a otros archivos que la usen." - } + "zh_cn" : "你即将更新共享库中的一个组件。这可能会对使用该组件的其他文档产生影响。" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "modals.update-remote-component.message" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:92", "src/app/main/ui/workspace/sidebar/options/component.cljs:69" ], "translations" : { "en" : "Update a component in a shared library", - "fr" : "Actualiser un composant dans une bibliothèque", + "es" : "Actualizar un componente en librería", + "fr" : "Actualiser le composant d’une bibliothèque", "ru" : "", - "es" : "Actualizar un componente en librería" - } + "zh_cn" : "更新共享库中的一个组件" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "notifications.profile-deletion-not-allowed" : { - "used-in" : [ "src/app/main/ui/settings/delete_account.cljs:28" ], "translations" : { - "en" : "You can't delete you profile. Reasign your teams before proceed.", + "en" : "You can't delete you profile. Reassign your teams before proceed.", + "es" : "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir.", "fr" : "Vous ne pouvez pas supprimer votre profil. Réassignez vos équipes avant de continuer.", "ru" : "Вы не можете удалить профиль. Сначала смените команду.", - "es" : "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." - } + "zh_cn" : "你无法删除你的个人资料。请先转让你的团队。" + }, + "used-in" : [ "src/app/main/ui/settings/delete_account.cljs" ] }, "notifications.profile-saved" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:36", "src/app/main/ui/settings/profile.cljs:38" ], "translations" : { "en" : "Profile saved successfully!", - "fr" : "Profil enregistré avec succès!", + "es" : "Perfil guardado correctamente!", + "fr" : "Profil enregistré avec succès !", "ru" : "Профиль успешно сохранен!", - "es" : "Perfil guardado correctamente!" - } + "zh_cn" : "个人资料保存成功!" + }, + "used-in" : [ "src/app/main/ui/settings/profile.cljs", "src/app/main/ui/settings/options.cljs" ] }, "notifications.validation-email-sent" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:56", "src/app/main/ui/auth/register.cljs:54" ], "translations" : { "en" : "Verification email sent to %s. Check your email!", - "fr" : "E-mail de vérification envoyé à %s. Vérifiez votre email!", - "es" : "Verificación de email enviada a %s. Comprueba tu correo." - } + "es" : "Verificación de email enviada a %s. Comprueba tu correo.", + "fr" : "E‑mail de vérification envoyé à %s. Vérifiez votre e‑mail !", + "zh_cn" : "验证邮件已发至%s。请检查邮箱。" + }, + "used-in" : [ "src/app/main/ui/settings/change_email.cljs" ] }, "profile.recovery.go-to-login" : { - "used-in" : [ "src/app/main/ui/auth/recovery.cljs:95" ], "translations" : { "en" : "Go to login", - "fr" : "Aller à la connexion", + "es" : null, + "fr" : "Aller à la page de connexion", "ru" : null, - "es" : null - } + "zh_cn" : "去登录" + }, + "used-in" : [ "src/app/main/ui/auth/recovery.cljs" ] }, "settings.multiple" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:213", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:161", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:170", "src/app/main/ui/workspace/sidebar/options/typography.cljs:99", "src/app/main/ui/workspace/sidebar/options/typography.cljs:149", "src/app/main/ui/workspace/sidebar/options/typography.cljs:162", "src/app/main/ui/workspace/sidebar/options/blur.cljs:79", "src/app/main/ui/workspace/sidebar/options/stroke.cljs:145" ], "translations" : { "en" : "Mixed", + "es" : "Varios", "fr" : "Divers", "ru" : "Смешаный", - "es" : "Varios" - } - }, - "settings.profile" : { - "translations" : { - "en" : "PROFILE", - "fr" : "PROFIL", - "ru" : "ПРОФИЛЬ", - "es" : "PERFIL" + "zh_cn" : "混合" }, - "unused" : true - }, - "settings.teams" : { - "translations" : { - "en" : "TEAMS", - "fr" : "EQUIPES", - "ru" : "КОМАНДЫ", - "es" : "EQUIPOS" - }, - "unused" : true + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs", "src/app/main/ui/workspace/sidebar/options/typography.cljs", "src/app/main/ui/workspace/sidebar/options/typography.cljs", "src/app/main/ui/workspace/sidebar/options/typography.cljs", "src/app/main/ui/workspace/sidebar/options/shadow.cljs", "src/app/main/ui/workspace/sidebar/options/blur.cljs" ] }, "viewer.empty-state" : { - "used-in" : [ "src/app/main/ui/handoff.cljs:56", "src/app/main/ui/viewer.cljs:192" ], "translations" : { "en" : "No frames found on the page.", + "es" : "No se ha encontrado ningún tablero.", "fr" : "Aucun cadre trouvé sur la page.", "ru" : "На странице не найдено ни одного кадра", - "es" : "No se ha encontrado ningún tablero." - } + "zh_cn" : "该页面上未找到任何画框。" + }, + "used-in" : [ "src/app/main/ui/handoff.cljs", "src/app/main/ui/viewer.cljs" ] }, "viewer.frame-not-found" : { - "used-in" : [ "src/app/main/ui/handoff.cljs:60", "src/app/main/ui/viewer.cljs:196" ], "translations" : { "en" : "Frame not found.", + "es" : "No se encuentra el tablero.", "fr" : "Cadre introuvable.", "ru" : "Кадры не найдены.", - "es" : "No se encuentra el tablero." - } + "zh_cn" : "画框未找到。" + }, + "used-in" : [ "src/app/main/ui/handoff.cljs", "src/app/main/ui/viewer.cljs" ] }, "viewer.header.dont-show-interactions" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:125" ], "translations" : { "en" : "Don't show interactions", + "es" : "No mostrar interacciones", "fr" : "Ne pas afficher les interactions", "ru" : "Не показывать взаимодействия", - "es" : "No mostrar interacciones" - } + "zh_cn" : "不显示交互" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.edit-page" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:266" ], "translations" : { "en" : "Edit page", - "fr" : "Editer la page", + "es" : "Editar página", + "fr" : "Modifier la page", "ru" : "Редактировать страницу", - "es" : "Editar página" - } + "zh_cn" : "编辑页面" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.fullscreen" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:277" ], "translations" : { "en" : "Full Screen", + "es" : "Pantalla completa", "fr" : "Plein écran", "ru" : "Полный экран", - "es" : "Pantalla completa" - } + "zh_cn" : "全屏" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.copy-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:93" ], "translations" : { "en" : "Copy link", - "fr" : "Copier lien", + "es" : "Copiar enlace", + "fr" : "Copier le lien", "ru" : "Копировать ссылку", - "es" : "Copiar enlace" - } + "zh_cn" : "复制链接" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.create-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:102" ], "translations" : { "en" : "Create link", - "fr" : "Créer lien", + "es" : "Crear enlace", + "fr" : "Créer le lien", "ru" : "Создать ссылку", - "es" : "Crear enlace" - } + "zh_cn" : "创建链接" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.placeholder" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:94" ], "translations" : { "en" : "Share link will appear here", + "es" : "El enlace para compartir aparecerá aquí", "fr" : "Le lien de partage apparaîtra ici", "ru" : "Здесь будет ссылка для обмена", - "es" : "El enlace para compartir aparecerá aquí" - } + "zh_cn" : "分享链接将会显示在这里" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.remove-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:100" ], "translations" : { "en" : "Remove link", + "es" : "Eliminar enlace", "fr" : "Supprimer le lien", "ru" : "Удалить ссылку", - "es" : "Eliminar enlace" - } + "zh_cn" : "移除链接" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.subtitle" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:96" ], "translations" : { "en" : "Anyone with the link will have access", + "es" : "Cualquiera con el enlace podrá acceder", "fr" : "Toute personne disposant du lien aura accès", "ru" : "Любой, у кого есть ссылка будет иметь доступ", - "es" : "Cualquiera con el enlace podrá acceder" - } + "zh_cn" : "任何人都可以通过本链接访问" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.share.title" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:79", "src/app/main/ui/viewer/header.cljs:81", "src/app/main/ui/viewer/header.cljs:87" ], "translations" : { "en" : "Share link", + "es" : "Enlace", "fr" : "Lien de partage", "ru" : "Поделиться ссылкой", - "es" : "Enlace" - } + "zh_cn" : "分享链接" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs", "src/app/main/ui/viewer/header.cljs", "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.show-interactions" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:130" ], "translations" : { "en" : "Show interactions", + "es" : "Mostrar interacciones", "fr" : "Afficher les interactions", "ru" : "Показывать взаимодействия", - "es" : "Mostrar interacciones" - } + "zh_cn" : "显示交互" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.show-interactions-on-click" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:135" ], "translations" : { "en" : "Show interactions on click", + "es" : "Mostrar interacciones al hacer click", "fr" : "Afficher les interactions au clic", "ru" : "Показывать взаимодействия по клику", - "es" : "Mostrar interacciones al hacer click" - } + "zh_cn" : "点击时显示交互" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "viewer.header.sitemap" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:224" ], "translations" : { "en" : "Sitemap", + "es" : "Mapa del sitio", "fr" : "Plan du site", "ru" : "План сайта", - "es" : "Mapa del sitio" - } + "zh_cn" : "站点地图" + }, + "used-in" : [ "src/app/main/ui/viewer/header.cljs" ] }, "workspace.align.hcenter" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:53" ], "translations" : { "en" : "Align horizontal center", - "fr" : "Aligner au centre", + "es" : "Alinear al centro", + "fr" : "Aligner horizontalement au centre", "ru" : "Выровнять по горизонтали", - "es" : "Alinear al centro" - } + "zh_cn" : "水平居中对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.hdistribute" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:65" ], "translations" : { "en" : "Distribute horizontal spacing", - "fr" : "Répartir l'espacement horizontal", + "es" : "Distribuir espacio horizontal", + "fr" : "Répartir l’espacement horizontal", "ru" : "Распределить горизонтальное пространство", - "es" : "Distribuir espacio horizontal" - } + "zh_cn" : "水平均匀分布" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.hleft" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:47" ], "translations" : { "en" : "Align left", + "es" : "Alinear a la izquierda", "fr" : "Aligner à gauche", "ru" : "Выровнять по левому краю", - "es" : "Alinear a la izquierda" - } + "zh_cn" : "靠左对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.hright" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:59" ], "translations" : { "en" : "Align right", + "es" : "Alinear a la derecha", "fr" : "Aligner à droite", "ru" : "Выровнять по правому краю", - "es" : "Alinear a la derecha" - } + "zh_cn" : "靠右对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.vbottom" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:84" ], "translations" : { "en" : "Align bottom", + "es" : "Alinear abajo", "fr" : "Aligner en bas", "ru" : "Выровнять по нижнему краю", - "es" : "Alinear abajo" - } + "zh_cn" : "底部对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.vcenter" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:78" ], "translations" : { "en" : "Align vertical center", - "fr" : "Aligner au centre", + "es" : "Alinear al centro", + "fr" : "Aligner verticalement au centre", "ru" : "Выровнять по вертикали", - "es" : "Alinear al centro" - } + "zh_cn" : "垂直居中对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.vdistribute" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:90" ], "translations" : { "en" : "Distribute vertical spacing", - "fr" : "Répartir l'espacement vertical", + "es" : "Distribuir espacio vertical", + "fr" : "Répartir l’espacement vertical", "ru" : "Распределить вертикальное пространство", - "es" : "Distribuir espacio vertical" - } + "zh_cn" : "垂直均匀分布" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.align.vtop" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs:72" ], "translations" : { "en" : "Align top", + "es" : "Alinear arriba", "fr" : "Aligner en haut", "ru" : "Выровнять по верхнему краю", - "es" : "Alinear arriba" - } + "zh_cn" : "顶部对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/align.cljs" ] }, "workspace.assets.assets" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:705" ], "translations" : { "en" : "Assets", + "es" : "Recursos", "fr" : "Ressources", "ru" : "", - "es" : "Recursos" - } + "zh_cn" : "素材" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.box-filter-all" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:725" ], "translations" : { "en" : "All assets", + "es" : "Todos", "fr" : "Toutes", "ru" : "", - "es" : "Todos" - } - }, - "workspace.assets.box-filter-colors" : { - "translations" : { - "en" : "Colors", - "fr" : "Couleurs", - "ru" : "", - "es" : "Colores" + "zh_cn" : "所有素材" }, - "unused" : true + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.box-filter-graphics" : { "translations" : { "en" : "Graphics", + "es" : "Gráficos", "fr" : "Graphiques", "ru" : "", - "es" : "Gráficos" + "zh_cn" : "图形" }, "unused" : true }, "workspace.assets.colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:399", "src/app/main/ui/workspace/sidebar/assets.cljs:728" ], "translations" : { "en" : "Colors", + "es" : "Colores", "fr" : "Couleurs", "ru" : "", - "es" : "Colores" - } + "zh_cn" : "颜色" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.components" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:109", "src/app/main/ui/workspace/sidebar/assets.cljs:726" ], "translations" : { "en" : "Components", + "es" : "Componentes", "fr" : "Composants", "ru" : "", - "es" : "Componentes" - } + "zh_cn" : "组件" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.delete" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:151", "src/app/main/ui/workspace/sidebar/assets.cljs:140", "src/app/main/ui/workspace/sidebar/assets.cljs:260", "src/app/main/ui/workspace/sidebar/assets.cljs:375", "src/app/main/ui/workspace/sidebar/assets.cljs:504" ], "translations" : { "en" : "Delete", + "es" : "Borrar", "fr" : "Supprimer", "ru" : "", - "es" : "Borrar" - } + "zh_cn" : "删除" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.duplicate" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:155", "src/app/main/ui/workspace/sidebar/assets.cljs:139" ], "translations" : { "en" : "Duplicate", + "es" : "Duplicar", "fr" : "Dupliquer", "ru" : "", - "es" : "Duplicar" - } + "zh_cn" : "创建副本" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.edit" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:374", "src/app/main/ui/workspace/sidebar/assets.cljs:503" ], "translations" : { "en" : "Edit", - "fr" : "Éditer", + "es" : "Editar", + "fr" : "Modifier", "ru" : "", - "es" : "Editar" - } + "zh_cn" : "编辑" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:602" ], "translations" : { "en" : "File library", + "es" : "Biblioteca del archivo", "fr" : "Bibliothèque du fichier", "ru" : "", - "es" : "Biblioteca del archivo" - } + "zh_cn" : "文档库" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:221", "src/app/main/ui/workspace/sidebar/assets.cljs:727" ], "translations" : { "en" : "Graphics", + "es" : "Gráficos", "fr" : "Graphiques", "ru" : "", - "es" : "Gráficos" - } + "zh_cn" : "图形" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:708" ], "translations" : { "en" : "Libraries", + "es" : "Bibliotecas", "fr" : "Bibliothèques", "ru" : "", - "es" : "Bibliotecas" - } + "zh_cn" : "库" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.not-found" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:666" ], "translations" : { "en" : "No assets found", + "es" : "No se encontraron recursos", "fr" : "Aucune ressource trouvée", "ru" : "", - "es" : "No se encontraron recursos" - } + "zh_cn" : "未找到素材" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.rename" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:154", "src/app/main/ui/workspace/sidebar/assets.cljs:138", "src/app/main/ui/workspace/sidebar/assets.cljs:259", "src/app/main/ui/workspace/sidebar/assets.cljs:373", "src/app/main/ui/workspace/sidebar/assets.cljs:502" ], "translations" : { "en" : "Rename", + "es" : "Renombrar", "fr" : "Renommer", "ru" : "", - "es" : "Renombrar" - } + "zh_cn" : "重命名" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.search" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:712" ], "translations" : { "en" : "Search assets", + "es" : "Buscar recursos", "fr" : "Chercher des ressources", "ru" : "", - "es" : "Buscar recursos" - } + "zh_cn" : "搜索素材" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.shared" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:604" ], "translations" : { "en" : "SHARED", + "es" : "COMPARTIDA", "fr" : "PARTAGÉ", "ru" : "", - "es" : "COMPARTIDA" - } + "zh_cn" : "共享的" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.typography" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:491", "src/app/main/ui/workspace/sidebar/assets.cljs:729" ], "translations" : { "en" : "Typographies", + "es" : "Tipografías", "fr" : "Typographies", - "es" : "Tipografías" - } + "zh_cn" : "排版" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs", "src/app/main/ui/workspace/sidebar/assets.cljs" ] }, "workspace.assets.typography.font-id" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:276" ], "translations" : { "en" : "Font", + "es" : "Fuente", "fr" : "Police", - "es" : "Fuente" - } + "zh_cn" : "字体" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.font-size" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:284" ], "translations" : { "en" : "Size", + "es" : "Tamaño", "fr" : "Taille", - "es" : "Tamaño" - } + "zh_cn" : "尺寸" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.font-variant-id" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:280" ], "translations" : { "en" : "Variant", + "es" : "Variante", "fr" : "Variante", - "es" : "Variante" - } + "zh_cn" : "变体" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.go-to-edit" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:301" ], "translations" : { "en" : "Go to style library file to edit", + "es" : "Ir al archivo de la biblioteca del estilo para editar", "fr" : "Accéder au fichier de bibliothèque de styles à modifier", - "es" : "Ir al archivo de la biblioteca del estilo para editar" - } + "zh_cn" : "前往样式库文件进行编辑" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.letter-spacing" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:292" ], "translations" : { "en" : "Letter Spacing", - "fr" : "Espacement des lettres", - "es" : "Interletrado" - } + "es" : "Interletrado", + "fr" : "Interlettrage", + "zh_cn" : "字距" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.line-height" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:288" ], "translations" : { "en" : "Line Height", - "fr" : "Hauteur de ligne", - "es" : "Interlineado" - } + "es" : "Interlineado", + "fr" : "Interlignage", + "zh_cn" : "行高" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.assets.typography.sample" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:254", "src/app/main/ui/handoff/attributes/text.cljs:96", "src/app/main/ui/handoff/attributes/text.cljs:105" ], "translations" : { "en" : "Ag", + "es" : "Ag", "fr" : "Ag", - "es" : "Ag" - } + "zh_cn" : "Ag" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs", "src/app/main/ui/handoff/attributes/text.cljs", "src/app/main/ui/handoff/attributes/text.cljs" ] }, "workspace.assets.typography.text-transform" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:296" ], "translations" : { "en" : "Text Transform", + "es" : "Transformar texto", "fr" : "Transformer le texte", - "es" : "Transformar texto" - } + "zh_cn" : "文本变换" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.gradients.linear" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:71", "src/app/main/ui/components/color_bullet.cljs:31" ], "translations" : { "en" : "Linear gradient", + "es" : "Degradado lineal", "fr" : "Dégradé linéaire", - "es" : "Degradado lineal" - } + "zh_cn" : "线性渐变" + }, + "used-in" : [ "src/app/main/data/workspace/libraries.cljs", "src/app/main/ui/components/color_bullet.cljs" ] }, "workspace.gradients.radial" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:72", "src/app/main/ui/components/color_bullet.cljs:32" ], "translations" : { "en" : "Radial gradient", + "es" : "Degradado radial", "fr" : "Dégradé radial", - "es" : "Degradado radial" - } + "zh_cn" : "放射渐变" + }, + "used-in" : [ "src/app/main/data/workspace/libraries.cljs", "src/app/main/ui/components/color_bullet.cljs" ] }, "workspace.header.menu.disable-dynamic-alignment" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:220" ], "translations" : { "en" : "Disable dynamic alignment", - "fr" : "Désactiver l'alignement dynamique", + "es" : "Desactivar alineamiento dinámico", + "fr" : "Désactiver l’alignement dynamique", "ru" : "Отключить активное выравнивание", - "es" : "Desactivar alineamiento dinámico" - } + "zh_cn" : "禁用动态对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.disable-snap-grid" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:188" ], "translations" : { "en" : "Disable snap to grid", - "fr" : "Désactiver l'alignement sur la grille", + "es" : "Desactivar alinear a la rejilla", + "fr" : "Désactiver l’alignement sur la grille", "ru" : "Отключить привязку к сетке", - "es" : "Desactivar alinear a la rejilla" - } + "zh_cn" : "禁用吸附到网格" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.enable-dynamic-alignment" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:221" ], "translations" : { "en" : "Enable dynamic aligment", - "fr" : "Activer l'alignement dynamique", + "es" : "Activar alineamiento dinámico", + "fr" : "Activer l’alignement dynamique", "ru" : "Включить активное выравнивание", - "es" : "Activar alineamiento dinámico" - } + "zh_cn" : "启用动态对齐" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.enable-snap-grid" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:189" ], "translations" : { "en" : "Snap to grid", + "es" : "Alinear a la rejilla", "fr" : "Aligner sur la grille", "ru" : "Привяка к сетке", - "es" : "Alinear a la rejilla" - } + "zh_cn" : "吸附到网格" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.hide-assets" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:209" ], "translations" : { "en" : "Hide assets", + "es" : "Ocultar recursos", "fr" : "Masquer les ressources", "ru" : "", - "es" : "Ocultar recursos" - } + "zh_cn" : "隐藏素材" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.hide-grid" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:181" ], "translations" : { "en" : "Hide grids", + "es" : "Ocultar rejillas", "fr" : "Masquer la grille", "ru" : "Спрятать сетку", - "es" : "Ocultar rejillas" - } + "zh_cn" : "隐藏网格" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.hide-layers" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:195" ], "translations" : { "en" : "Hide layers", - "fr" : "Masquer les couches", + "es" : "Ocultar capas", + "fr" : "Masquer les calques", "ru" : "Спрятать слои", - "es" : "Ocultar capas" - } + "zh_cn" : "隐藏图层" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.hide-palette" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:202" ], "translations" : { "en" : "Hide color palette", + "es" : "Ocultar paleta de colores", "fr" : "Masquer la palette de couleurs", "ru" : "Спрятать палитру цветов", - "es" : "Ocultar paleta de colores" - } + "zh_cn" : "隐藏调色盘" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.hide-rules" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:174" ], "translations" : { "en" : "Hide rules", + "es" : "Ocultar reglas", "fr" : "Masquer les règles", "ru" : "Спрятать линейки", - "es" : "Ocultar reglas" - } + "zh_cn" : "隐藏标尺" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.select-all" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:214" ], "translations" : { "en" : "Select all", - "fr" : "Sélectionner tout", + "es" : "Seleccionar todo", + "fr" : "Tout sélectionner", "ru" : "", - "es" : "Seleccionar todo" - } + "zh_cn" : "全选" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.show-assets" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:210" ], "translations" : { "en" : "Show assets", + "es" : "Mostrar recursos", "fr" : "Montrer les ressources", "ru" : "", - "es" : "Mostrar recursos" - } + "zh_cn" : "显示素材" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.show-grid" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:182" ], "translations" : { "en" : "Show grid", + "es" : "Mostrar rejilla", "fr" : "Montrer la grille", "ru" : "Показать сетку", - "es" : "Mostrar rejilla" - } + "zh_cn" : "显示网格" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.show-layers" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:196" ], "translations" : { "en" : "Show layers", - "fr" : "Montrer les couches", + "es" : "Mostrar capas", + "fr" : "Montrer les calques", "ru" : "Показать слои", - "es" : "Mostrar capas" - } + "zh_cn" : "显示图层" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.show-palette" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:203" ], "translations" : { "en" : "Show color palette", + "es" : "Mostrar paleta de colores", "fr" : "Montrer la palette de couleurs", "ru" : "Показать палитру цветов", - "es" : "Mostrar paleta de colores" - } + "zh_cn" : "显示调色盘" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.menu.show-rules" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:175" ], "translations" : { "en" : "Show rules", + "es" : "Mostrar reglas", "fr" : "Montrer les règles", "ru" : "Показать линейки", - "es" : "Mostrar reglas" - } + "zh_cn" : "显示标尺" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.save-error" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:58" ], "translations" : { "en" : "Error on saving", - "fr" : "Erreur d'enregistrement", - "es" : "Error al guardar" - } + "es" : "Error al guardar", + "fr" : "Erreur d’enregistrement", + "zh_cn" : "保存时发生错误" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.saved" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:53" ], "translations" : { "en" : "Saved", + "es" : "Guardado", "fr" : "Enregistré", - "es" : "Guardado" - } + "zh_cn" : "已保存" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.saving" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:48" ], "translations" : { "en" : "Saving", + "es" : "Guardando", "fr" : "Enregistrement", - "es" : "Guardando" - } + "zh_cn" : "正在保存" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.unsaved" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:43" ], "translations" : { "en" : "Unsaved changes", + "es" : "Cambios sin guardar", "fr" : "Modifications non sauvegardées", - "es" : "Cambios sin guardar" - } + "zh_cn" : "未保存的修改" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.header.viewer" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:279" ], "translations" : { "en" : "View mode (%s)", - "fr" : "Mode visualisation (%s)", + "es" : "Modo de visualización (%s)", + "fr" : "Mode spectateur (%s)", "ru" : "Режим просмотра (%s)", - "es" : "Modo de visualización (%s)" - } + "zh_cn" : "查看模式(%s)" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.libraries.add" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:116" ], "translations" : { "en" : "Add", + "es" : "Añadir", "fr" : "Ajouter", "ru" : "", - "es" : "Añadir" - } + "zh_cn" : "添加" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.colors" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:44" ], "translations" : { "en" : "%s colors", + "es" : "%s colors", "fr" : "%s couleurs", "ru" : "", - "es" : "%s colors" - } + "zh_cn" : "%s种颜色" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.colors.big-thumbnails" : { - "used-in" : [ "src/app/main/ui/workspace/colorpalette.cljs:171" ], "translations" : { "en" : "Big thumbnails", + "es" : "Miniaturas grandes", "fr" : "Grandes vignettes", - "es" : "Miniaturas grandes" - } + "zh_cn" : "大缩略图" + }, + "used-in" : [ "src/app/main/ui/workspace/colorpalette.cljs" ] }, "workspace.libraries.colors.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/colorpicker/libraries.cljs:89", "src/app/main/ui/workspace/colorpalette.cljs:149" ], "translations" : { "en" : "File library", + "es" : "Biblioteca del archivo", "fr" : "Bibliothèque du fichier", - "es" : "Biblioteca del archivo" - } + "zh_cn" : "文档库" + }, + "used-in" : [ "src/app/main/ui/workspace/colorpicker/libraries.cljs", "src/app/main/ui/workspace/colorpalette.cljs" ] }, "workspace.libraries.colors.recent-colors" : { - "used-in" : [ "src/app/main/ui/workspace/colorpicker/libraries.cljs:88", "src/app/main/ui/workspace/colorpalette.cljs:159" ], "translations" : { "en" : "Recent colors", + "es" : "Colores recientes", "fr" : "Couleurs récentes", - "es" : "Colores recientes" - } + "zh_cn" : "最近使用的颜色" + }, + "used-in" : [ "src/app/main/ui/workspace/colorpicker/libraries.cljs", "src/app/main/ui/workspace/colorpalette.cljs" ] }, "workspace.libraries.colors.save-color" : { - "used-in" : [ "src/app/main/ui/workspace/colorpicker.cljs:339" ], "translations" : { "en" : "Save color style", + "es" : "Guardar estilo de color", "fr" : "Enregistrer le style de couleur", - "es" : "Guardar estilo de color" - } + "zh_cn" : "保存颜色风格" + }, + "used-in" : [ "src/app/main/ui/workspace/colorpicker.cljs" ] }, "workspace.libraries.colors.small-thumbnails" : { - "used-in" : [ "src/app/main/ui/workspace/colorpalette.cljs:176" ], "translations" : { "en" : "Small thumbnails", + "es" : "Miniaturas pequeñas", "fr" : "Petites vignettes", - "es" : "Miniaturas pequeñas" - } + "zh_cn" : "小缩略图" + }, + "used-in" : [ "src/app/main/ui/workspace/colorpalette.cljs" ] }, "workspace.libraries.components" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:38" ], "translations" : { "en" : "%s components", + "es" : "%s componentes", "fr" : "%s composants", "ru" : "", - "es" : "%s componentes" - } + "zh_cn" : "%s个组件" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:85" ], "translations" : { "en" : "File library", + "es" : "Biblioteca de este archivo", "fr" : "Bibliothèque du fichier", "ru" : "", - "es" : "Biblioteca de este archivo" - } + "zh_cn" : "文档库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:41" ], "translations" : { "en" : "%s graphics", + "es" : "%s gráficos", "fr" : "%s graphiques", "ru" : "", - "es" : "%s gráficos" - } + "zh_cn" : "%s个图形" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.in-this-file" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:82" ], "translations" : { "en" : "LIBRARIES IN THIS FILE", + "es" : "BIBLIOTECAS EN ESTE ARCHIVO", "fr" : "BIBLIOTHÈQUES DANS CE FICHIER", "ru" : "", - "es" : "BIBLIOTECAS EN ESTE ARCHIVO" - } + "zh_cn" : "本文档中的库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:177" ], "translations" : { "en" : "LIBRARIES", + "es" : "BIBLIOTECAS", "fr" : "BIBLIOTHÈQUES", "ru" : "", - "es" : "BIBLIOTECAS" - } + "zh_cn" : "库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.library" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:136" ], "translations" : { "en" : "LIBRARY", + "es" : "BIBLIOTECA", "fr" : "BIBLIOTHÈQUE", "ru" : "", - "es" : "BIBLIOTECA" - } + "zh_cn" : "库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.no-libraries-need-sync" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:134" ], "translations" : { "en" : "There are no Shared Libraries that need update", - "fr" : "Aucune Bibliothèque Partagée n'a besoin d'être mise à jour", + "es" : "No hay bibliotecas que necesiten ser actualizadas", + "fr" : "Aucune Bibliothèque Partagée n’a besoin d’être mise à jour", "ru" : "", - "es" : "No hay bibliotecas que necesiten ser actualizadas" - } + "zh_cn" : "没有需要更新的共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.no-matches-for" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:122" ], "translations" : { "en" : "No matches found for “%s“", - "fr" : "Aucune correspondance pour “%s“", + "es" : "No se encuentra “%s“", + "fr" : "Aucune correspondance pour « %s »", "ru" : "Совпадений для “%s“ не найдено", - "es" : "No se encuentra “%s“" - } + "zh_cn" : "没有找到“%s”的匹配项" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.no-shared-libraries-available" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:121" ], "translations" : { "en" : "There are no Shared Libraries available", - "fr" : "Aucune bibliothèque partagée disponible", + "es" : "No hay bibliotecas compartidas disponibles", + "fr" : "Aucune Bibliothèque Partagée disponible", "ru" : "", - "es" : "No hay bibliotecas compartidas disponibles" - } + "zh_cn" : "没有可用的共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.search-shared-libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:99" ], "translations" : { "en" : "Search shared libraries", - "fr" : "Rechercher des bibliothèques partagées", + "es" : "Buscar bibliotecas compartidas", + "fr" : "Rechercher des Bibliothèques Partagées", "ru" : "", - "es" : "Buscar bibliotecas compartidas" - } + "zh_cn" : "搜索共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.shared-libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:96" ], "translations" : { "en" : "SHARED LIBRARIES", + "es" : "BIBLIOTECAS COMPARTIDAS", "fr" : "BIBLIOTHÈQUES PARTAGÉES", "ru" : "", - "es" : "BIBLIOTECAS COMPARTIDAS" - } + "zh_cn" : "共享库" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.text.multiple-typography" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:264" ], "translations" : { "en" : "Multiple typographies", + "es" : "Varias tipografías", "fr" : "Multiple typographies", - "es" : "Varias tipografías" - } + "zh_cn" : "复合排版" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.libraries.text.multiple-typography-tooltip" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:266" ], "translations" : { "en" : "Unlink all typographies", - "fr" : "Dissocier toutes les typographies", - "es" : "Desvincular todas las tipografías" - } + "es" : "Desvincular todas las tipografías", + "fr" : "Dissocier toutes les typographies" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.libraries.typography" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:47" ], "translations" : { "en" : "%s typographies", - "fr" : "%s typographies", - "es" : "%s tipografías" - } + "es" : "%s tipografías", + "fr" : "%s typographies" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.update" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:143" ], "translations" : { "en" : "Update", + "es" : "Actualizar", "fr" : "Actualiser", - "ru" : "", - "es" : "Actualizar" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.libraries.updates" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:181" ], "translations" : { "en" : "UPDATES", + "es" : "ACTUALIZACIONES", "fr" : "MISES À JOUR", - "ru" : "", - "es" : "ACTUALIZACIONES" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs" ] }, "workspace.library.all" : { "translations" : { "en" : "All libraries", + "es" : "Todas", "fr" : "Toutes les bibliothèques", - "ru" : "Все библиотеки", - "es" : "Todas" - }, - "unused" : true - }, - "workspace.library.icons" : { - "translations" : { - "en" : "Icons", - "fr" : "Icônes", - "ru" : "Иконки", - "es" : "Iconos" - }, - "unused" : true - }, - "workspace.library.images" : { - "translations" : { - "en" : "Images", - "fr" : "Images", - "ru" : "Изображения", - "es" : "Imágenes" + "ru" : "Все библиотеки" }, "unused" : true }, "workspace.library.libraries" : { "translations" : { "en" : "Libraries", + "es" : "Bibliotecas", "fr" : "Bibliothèques", - "ru" : "Библиотеки", - "es" : "Bibliotecas" + "ru" : "Библиотеки" }, "unused" : true }, "workspace.library.own" : { "translations" : { "en" : "My libraries", + "es" : "Mis bibliotecas", "fr" : "Mes bibliothèques", - "ru" : "Мои библиотеки", - "es" : "Mis bibliotecas" + "ru" : "Мои библиотеки" }, "unused" : true }, "workspace.library.store" : { "translations" : { "en" : "Store libraries", + "es" : "Predefinidas", "fr" : "Prédéfinies", - "ru" : "Сохраненные библиотеки", - "es" : "Predefinidas" + "ru" : "Сохраненные библиотеки" }, "unused" : true }, "workspace.options.blur-options.background-blur" : { "translations" : { "en" : "Background", - "fr" : "Fond", - "es" : "Fondo" + "es" : "Fondo", + "fr" : "Fond" }, "unused" : true }, "workspace.options.blur-options.layer-blur" : { "translations" : { "en" : "Layer", - "fr" : "Couche", - "es" : "Capa" + "es" : "Capa", + "fr" : "Calque" }, "unused" : true }, "workspace.options.blur-options.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs:62" ], "translations" : { "en" : "Blur", - "fr" : "Flou", - "es" : "Desenfoque" - } + "es" : "Desenfoque", + "fr" : "Flou" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs" ] }, "workspace.options.blur-options.title.group" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs:61" ], "translations" : { "en" : "Group blur", - "fr" : "Flou de groupe", - "es" : "Desenfoque del grupo" - } + "es" : "Desenfoque del grupo", + "fr" : "Flou de groupe" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs" ] }, "workspace.options.blur-options.title.multiple" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs:60" ], "translations" : { "en" : "Selection blur", - "fr" : "Flou de sélection", - "es" : "Desenfoque de la selección" - } + "es" : "Desenfoque de la selección", + "fr" : "Flou de sélection" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/blur.cljs" ] }, "workspace.options.canvas-background" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/page.cljs:45" ], "translations" : { "en" : "Canvas background", - "fr" : "Couleur de fond", - "ru" : "Фон холста", - "es" : "Color de fondo" - } + "es" : "Color de fondo", + "fr" : "Couleur de fond du canvas", + "ru" : "Фон холста" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/page.cljs" ] }, "workspace.options.component" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:81" ], "translations" : { "en" : "Component", - "fr" : "Composant", - "es" : "Componente" - } + "es" : "Componente", + "fr" : "Composant" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs" ] }, "workspace.options.design" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options.cljs:70" ], "translations" : { "en" : "Design", + "es" : "Diseño", "fr" : "Conception", - "ru" : "Дизайн", - "es" : "Diseño" - } + "ru" : "Дизайн" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options.cljs" ] }, "workspace.options.export" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:132", "src/app/main/ui/handoff/exports.cljs:96" ], "translations" : { "en" : "Export", + "es" : "Exportar", "fr" : "Export", - "ru" : "Экспорт", - "es" : "Exprotar" - } + "ru" : "Экспорт" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs", "src/app/main/ui/handoff/exports.cljs" ] }, "workspace.options.export-object" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:166", "src/app/main/ui/handoff/exports.cljs:131" ], "translations" : { "en" : "Export shape", + "es" : "Exportar forma", "fr" : "Exporter la forme", - "ru" : "Экспорт фигуры", - "es" : "Exportar forma" - } + "ru" : "Экспорт фигуры" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs", "src/app/main/ui/handoff/exports.cljs" ] }, "workspace.options.export.suffix" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:149" ], "translations" : { "en" : "Suffix", - "fr" : "Suffixe", - "es" : "Sufijo" - } + "es" : "Sufijo", + "fr" : "Suffixe" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs" ] }, "workspace.options.exporting-object" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:165", "src/app/main/ui/handoff/exports.cljs:130" ], "translations" : { - "en" : "Exporting...", - "fr" : "Export...", - "ru" : "Экспортирую...", - "es" : "Exportando" - } + "en" : "Exporting…", + "es" : "Exportando", + "fr" : "Export en cours…", + "ru" : "Экспортирую…" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs", "src/app/main/ui/handoff/exports.cljs" ] }, "workspace.options.fill" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs:41" ], "translations" : { "en" : "Fill", + "es" : "Relleno", "fr" : "Remplissage", - "ru" : "Заливка", - "es" : "Relleno" - } + "ru" : "Заливка" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs" ] }, "workspace.options.grid.auto" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:36" ], "translations" : { "en" : "Auto", + "es" : "Automático", "fr" : "Automatique", - "ru" : "Авто", - "es" : "Automático" - } + "ru" : "Авто" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.column" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:133" ], "translations" : { "en" : "Columns", + "es" : "Columnas", "fr" : "Colonnes", - "ru" : "Колонки", - "es" : "Columnas" - } + "ru" : "Колонки" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.columns" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:173" ], "translations" : { "en" : "Columns", + "es" : "Columnas", "fr" : "Colonnes", - "ru" : "Колонки", - "es" : "Columnas" - } + "ru" : "Колонки" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.gutter" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:206" ], "translations" : { "en" : "Gutter", + "es" : "Espaciado", "fr" : "Gouttière", - "ru" : "Желоб", - "es" : "Espaciado" - } + "ru" : "Желоб" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.height" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:197" ], "translations" : { "en" : "Height", + "es" : "Altura", "fr" : "Hauteur", - "ru" : "Высота", - "es" : "Altura" - } + "ru" : "Высота" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.margin" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:212" ], "translations" : { "en" : "Margin", + "es" : "Margen", "fr" : "Marge", - "ru" : "Поле", - "es" : "Margen" - } + "ru" : "Поле" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.rows" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:164" ], "translations" : { "en" : "Rows", + "es" : "Filas", "fr" : "Lignes", - "ru" : "Строки", - "es" : "Filas" - } + "ru" : "Строки" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.set-default" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:227" ], "translations" : { "en" : "Set as default", + "es" : "Establecer valor por defecto", "fr" : "Définir par défaut", - "ru" : "Установить по умолчанию", - "es" : "Establecer valor por defecto" - } + "ru" : "Установить по умолчанию" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.size" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:157" ], "translations" : { "en" : "Size", + "es" : "Tamaño", "fr" : "Taille", - "ru" : "Размер", - "es" : "Tamaño" - } + "ru" : "Размер" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:182" ], "translations" : { "en" : "Type", + "es" : "Tipo", "fr" : "Type", - "ru" : "Тип", - "es" : "Tipo" - } + "ru" : "Тип" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.bottom" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:190" ], "translations" : { "en" : "Bottom", + "es" : "Abajo", "fr" : "Bas", - "ru" : "Низ", - "es" : "Abajo" - } + "ru" : "Низ" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.center" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:188" ], "translations" : { "en" : "Center", + "es" : "Centro", "fr" : "Centre", - "ru" : "Центр", - "es" : "Centro" - } + "ru" : "Центр" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.left" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:187" ], "translations" : { "en" : "Left", + "es" : "Izquierda", "fr" : "Gauche", - "ru" : "Левый", - "es" : "Izquierda" - } + "ru" : "Левый" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.right" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:191" ], "translations" : { "en" : "Right", + "es" : "Derecha", "fr" : "Droite", - "ru" : "Правый", - "es" : "Derecha" - } + "ru" : "Правый" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.stretch" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:184" ], "translations" : { "en" : "Stretch", + "es" : "Estirar", "fr" : "Étirer", - "ru" : "Растягивать", - "es" : "Estirar" - } + "ru" : "Растягивать" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.type.top" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:186" ], "translations" : { "en" : "Top", + "es" : "Arriba", "fr" : "Haut", - "ru" : "Верх", - "es" : "Arriba" - } + "ru" : "Верх" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.use-default" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:225" ], "translations" : { "en" : "Use default", + "es" : "Usar valor por defecto", "fr" : "Utiliser la valeur par défaut", - "ru" : "Использовать значение по умолчанию", - "es" : "Usar valor por defecto" - } + "ru" : "Использовать значение по умолчанию" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.params.width" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:198" ], "translations" : { "en" : "Width", + "es" : "Ancho", "fr" : "Largeur", - "ru" : "Ширина", - "es" : "Ancho" - } + "ru" : "Ширина" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.row" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:134" ], "translations" : { "en" : "Rows", + "es" : "Filas", "fr" : "Lignes", - "ru" : "Строки", - "es" : "Filas" - } + "ru" : "Строки" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.square" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:132" ], "translations" : { "en" : "Square", + "es" : "Cuadros", "fr" : "Carré", - "ru" : "Квадрат", - "es" : "Cuadros" - } + "ru" : "Квадрат" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.grid.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs:239" ], "translations" : { "en" : "Grid & Layouts", - "fr" : "Grille & couches", - "ru" : "Сетка и Макеты", - "es" : "Rejilla & Estructuras" - } + "es" : "Rejilla & Estructuras", + "fr" : "Grille & Calques", + "ru" : "Сетка и Макеты" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame_grid.cljs" ] }, "workspace.options.group-fill" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs:40" ], "translations" : { "en" : "Group fill", + "es" : "Relleno de grupo", "fr" : "Remplissage de groupe", - "ru" : "Заливка для группы", - "es" : "Relleno de grupo" - } + "ru" : "Заливка для группы" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs" ] }, "workspace.options.group-stroke" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:54" ], "translations" : { "en" : "Group stroke", + "es" : "Borde de grupo", "fr" : "Contour de groupe", - "ru" : "Обводка для группы", - "es" : "Borde de grupo" - } + "ru" : "Обводка для группы" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.navigate-to" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs:59" ], "translations" : { "en" : "Navigate to", + "es" : "Navegar a", "fr" : "Naviguer vers", - "ru" : "Перейти к", - "es" : "Navegar a" - } + "ru" : "Перейти к" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs" ] }, "workspace.options.none" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs:72" ], "translations" : { "en" : "None", + "es" : "Ninguno", "fr" : "Aucun", - "ru" : "Не задано", - "es" : "Ninguno" - } + "ru" : "Не задано" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs" ] }, "workspace.options.position" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs:118", "src/app/main/ui/workspace/sidebar/options/measures.cljs:146" ], "translations" : { "en" : "Position", + "es" : "Posición", "fr" : "Position", - "ru" : "Позиция", - "es" : "Posición" - } + "ru" : "Позиция" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs", "src/app/main/ui/workspace/sidebar/options/frame.cljs" ] }, "workspace.options.prototype" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options.cljs:83" ], "translations" : { "en" : "Prototype", + "es" : "Prototipo", "fr" : "Prototype", - "ru" : "Прототип", - "es" : "Prototipo" - } + "ru" : "Прототип" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options.cljs" ] }, "workspace.options.radius" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs:186" ], "translations" : { "en" : "Radius", + "es" : "Radio", "fr" : "Rayon", - "ru" : "Радиус", - "es" : "Radio" - } + "ru" : "Радиус" + }, + "unused" : true + }, + "workspace.options.radius.all-corners" : { + "translations" : { + "en" : "All corners", + "es" : "Todas las esquinas" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs" ] + }, + "workspace.options.radius.single-corners" : { + "translations" : { + "en" : "Single corners", + "es" : "Esquinas individuales" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs" ] }, "workspace.options.rotation" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs:163" ], "translations" : { "en" : "Rotation", + "es" : "Rotación", "fr" : "Rotation", - "ru" : "Вращение", - "es" : "Rotación" - } + "ru" : "Вращение" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs" ] }, "workspace.options.select-a-shape" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs:53" ], "translations" : { "en" : "Select a shape, artboard or group to drag a connection to other artboard.", + "es" : "Selecciona una figura, tablero o grupo para arrastrar una conexión a otro tablero.", "fr" : "Sélectionnez une forme, un plan de travail ou un groupe pour faire glisser une connexion vers un autre plan de travail.", - "ru" : "Выберите фигуру, рабочую область или группу чтобы перенести связь на другую рабочую область.", - "es" : "Selecciona una figura, tablero o grupo para arrastrar una conexión a otro tablero." - } + "ru" : "Выберите фигуру, рабочую область или группу чтобы перенести связь на другую рабочую область." + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs" ] }, "workspace.options.select-artboard" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs:65" ], "translations" : { "en" : "Select artboard", + "es" : "Selecciona un tablero", "fr" : "Sélectionner un plan de travail", - "ru" : "Выберите рабочую область", - "es" : "Selecciona un tablero" - } + "ru" : "Выберите рабочую область" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs" ] }, "workspace.options.selection-fill" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs:39" ], "translations" : { "en" : "Selection fill", + "es" : "Relleno de selección", "fr" : "Remplissage de sélection", - "ru" : "Заливка выбранного", - "es" : "Relleno de selección" - } + "ru" : "Заливка выбранного" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/fill.cljs" ] }, "workspace.options.selection-stroke" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:53" ], "translations" : { "en" : "Selection stroke", + "es" : "Borde de selección", "fr" : "Contour de sélection", - "ru" : "Обводка выбранного", - "es" : "Borde de selección" - } + "ru" : "Обводка выбранного" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.shadow-options.blur" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:166" ], "translations" : { "en" : "Blur", - "fr" : "Flou", - "es" : "Desenfoque" - } + "es" : "Desenfoque", + "fr" : "Flou" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.drop-shadow" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:135" ], "translations" : { "en" : "Drop shadow", - "fr" : "Ombre portée", - "es" : "Sombra arrojada" - } + "es" : "Sombra arrojada", + "fr" : "Ombre portée" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.inner-shadow" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:136" ], "translations" : { "en" : "Inner shadow", - "fr" : "Ombre intérieure", - "es" : "Sombra interior" - } + "es" : "Sombra interior", + "fr" : "Ombre intérieure" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.offsetx" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:146" ], "translations" : { "en" : "X", - "fr" : "X", - "es" : "X" - } + "es" : "X", + "fr" : "X" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.offsety" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:155" ], "translations" : { "en" : "Y", - "fr" : "Y", - "es" : "Y" - } + "es" : "Y", + "fr" : "Y" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.spread" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:176" ], "translations" : { "en" : "Spread", - "fr" : "Diffusion", - "es" : "Difusión" - } + "es" : "Difusión", + "fr" : "Diffusion" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:204" ], "translations" : { "en" : "Shadow", - "fr" : "Ombre", - "es" : "Sombra" - } + "es" : "Sombra", + "fr" : "Ombre" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.title.group" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:203" ], "translations" : { "en" : "Group shadow", - "fr" : "Ombre de groupe", - "es" : "Sombra del grupo" - } + "es" : "Sombra del grupo", + "fr" : "Ombre de groupe" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.shadow-options.title.multiple" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs:202" ], "translations" : { "en" : "Selection shadows", - "fr" : "Ombres de la sélection", - "es" : "Sombras de la seleccíón" - } + "es" : "Sombras de la seleccíón", + "fr" : "Ombres de la sélection" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/shadow.cljs" ] }, "workspace.options.size" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs:93", "src/app/main/ui/workspace/sidebar/options/measures.cljs:118" ], "translations" : { "en" : "Size", + "es" : "Tamaño", "fr" : "Taille", - "ru" : "Размер", - "es" : "Tamaño" - } + "ru" : "Размер" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs", "src/app/main/ui/workspace/sidebar/options/frame.cljs" ] }, "workspace.options.size-presets" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs:75" ], "translations" : { "en" : "Size presets", + "es" : "Tamaños predefinidos", "fr" : "Tailles prédéfinies", - "ru" : "Предустановки для размеров", - "es" : "Tamaños predefinidos" - } + "ru" : "Предустановки для размеров" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs" ] }, "workspace.options.stroke" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:55" ], "translations" : { "en" : "Stroke", + "es" : "Borde", "fr" : "Bordure", - "ru" : "Обводка", - "es" : "Borde" - } + "ru" : "Обводка" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.center" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:152" ], "translations" : { "en" : "Center", + "es" : "Centro", "fr" : "Centre", - "ru" : "Центр", - "es" : "Centro" - } + "ru" : "Центр" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.dashed" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:162" ], "translations" : { "en" : "Dashed", - "fr" : "Tiré", - "ru" : "Пунктирный", - "es" : "Rayado" - } + "es" : "Rayado", + "fr" : "Tirets", + "ru" : "Пунктирный" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.dotted" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:161" ], "translations" : { "en" : "Dotted", + "es" : "Punteado", "fr" : "Pointillé", - "ru" : "Точечный", - "es" : "Punteado" - } + "ru" : "Точечный" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.inner" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:153" ], "translations" : { "en" : "Inside", + "es" : "Interior", "fr" : "Intérieur", - "ru" : "Внутрь", - "es" : "Interior" - } + "ru" : "Внутрь" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.mixed" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:163" ], "translations" : { "en" : "Mixed", + "es" : "Mezclado", "fr" : "Mixte", - "ru" : "Смешаный", - "es" : "Mezclado" - } + "ru" : "Смешаный" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.outer" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:154" ], "translations" : { "en" : "Outside", + "es" : "Exterior", "fr" : "Extérieur", - "ru" : "Наружу", - "es" : "Exterior" - } + "ru" : "Наружу" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.stroke.solid" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:160" ], "translations" : { "en" : "Solid", + "es" : "Sólido", "fr" : "Solide", - "ru" : "Сплошной", - "es" : "Sólido" - } + "ru" : "Сплошной" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs" ] }, "workspace.options.text-options.align-bottom" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:112" ], "translations" : { "en" : "Align bottom", + "es" : "Alinear abajo", "fr" : "Aligner en bas", - "ru" : "Выровнять низ", - "es" : "Alinear abajo" - } + "ru" : "Выровнять низ" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-center" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:76" ], "translations" : { "en" : "Align center", + "es" : "Aliniear al centro", "fr" : "Aligner au centre", - "ru" : "Выравнивание по центру", - "es" : "Aliniear al centro" - } + "ru" : "Выравнивание по центру" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-justify" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:86" ], "translations" : { "en" : "Justify", + "es" : "Justificar", "fr" : "Justifier", - "ru" : "Выравнивание по ширине", - "es" : "Justificar" - } + "ru" : "Выравнивание по ширине" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-left" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:71" ], "translations" : { "en" : "Align left", + "es" : "Alinear a la izquierda", "fr" : "Aligner à gauche", - "ru" : "Выравнивание по левому краю", - "es" : "Alinear a la izquierda" - } + "ru" : "Выравнивание по левому краю" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-middle" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:107" ], "translations" : { "en" : "Align middle", - "fr" : "Aligner au milieu", - "ru" : "Выравнивание по центру", - "es" : "Alinear al centro" - } + "es" : "Alinear al centro", + "fr" : "Aligner verticalement au milieu", + "ru" : "Выравнивание по центру" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-right" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:81" ], "translations" : { "en" : "Align right", + "es" : "Alinear a la derecha", "fr" : "Aligner à droite", - "ru" : "Выравнивание по правому краю", - "es" : "Alinear a la derecha" - } + "ru" : "Выравнивание по правому краю" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.align-top" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:102" ], "translations" : { "en" : "Align top", + "es" : "Alinear arriba", "fr" : "Aligner en haut", - "ru" : "Выравнивание по верхнему краю", - "es" : "Alinear arriba" - } + "ru" : "Выравнивание по верхнему краю" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.decoration" : { "translations" : { "en" : "Decoration", + "es" : "Decoración", "fr" : "Décoration", - "ru" : "Оформление", - "es" : "Decoración" + "ru" : "Оформление" }, "unused" : true }, + "workspace.options.text-options.google" : { + "translations" : { + "en" : "Google", + "es" : "Google" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] + }, "workspace.options.text-options.grow-auto-height" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:137" ], "translations" : { "en" : "Auto height", - "fr" : "Hauteur automatique", - "es" : "Alto automático" - } + "es" : "Alto automático", + "fr" : "Hauteur automatique" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.grow-auto-width" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:132" ], "translations" : { "en" : "Auto width", - "fr" : "Largeur automatique", - "es" : "Ancho automático" - } + "es" : "Ancho automático", + "fr" : "Largeur automatique" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.grow-fixed" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:127" ], "translations" : { "en" : "Fixed", - "fr" : "Fixe", - "es" : "Fijo" - } + "es" : "Fijo", + "fr" : "Fixe" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.letter-spacing" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:154" ], "translations" : { "en" : "Letter Spacing", - "fr" : "Espacement des lettres", - "ru" : "Межсимвольный интервал", - "es" : "Espaciado entre letras" - } + "es" : "Espaciado entre letras", + "fr" : "Interlettrage", + "ru" : "Межсимвольный интервал" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.line-height" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:141" ], "translations" : { "en" : "Line height", - "fr" : "Hauteur de ligne", - "ru" : "Высота строки", - "es" : "Altura de línea" - } + "es" : "Altura de línea", + "fr" : "Interlignage", + "ru" : "Высота строки" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.lowercase" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:186" ], "translations" : { "en" : "Lowercase", + "es" : "Minúsculas", "fr" : "Minuscule", - "ru" : "Нижний регистр", - "es" : "Minúsculas" - } + "ru" : "Нижний регистр" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.none" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:176", "src/app/main/ui/workspace/sidebar/options/text.cljs:153" ], "translations" : { "en" : "None", + "es" : "Nada", "fr" : "Aucune", - "ru" : "Не задано", - "es" : "Nada" - } + "ru" : "Не задано" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs", "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] + }, + "workspace.options.text-options.preset" : { + "translations" : { + "en" : "Preset", + "es" : "Predefinidos" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.strikethrough" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:165" ], "translations" : { "en" : "Strikethrough", + "es" : "Tachado", "fr" : "Barré", - "ru" : "Перечеркнутый", - "es" : "Tachado" - } + "ru" : "Перечеркнутый" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.text-case" : { "translations" : { "en" : "Case", + "es" : "Mayús/minús", "fr" : "Casse", - "ru" : "Регистр", - "es" : "Mayús/minús" + "ru" : "Регистр" }, "unused" : true }, "workspace.options.text-options.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:186" ], "translations" : { "en" : "Text", + "es" : "Texto", "fr" : "Texte", - "ru" : "Текст", - "es" : "Texto" - } + "ru" : "Текст" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.title-group" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:185" ], "translations" : { "en" : "Group text", + "es" : "Texto de grupo", "fr" : "Texte de groupe", - "ru" : "Текст группы", - "es" : "Texto de grupo" - } + "ru" : "Текст группы" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.title-selection" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:184" ], "translations" : { "en" : "Selection text", + "es" : "Texto de selección", "fr" : "Texte de la sélection", - "ru" : "Выбранный текст", - "es" : "Texto de selección" - } + "ru" : "Выбранный текст" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.titlecase" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:191" ], "translations" : { - "en" : "Titlecase", - "fr" : "Titre", - "ru" : "Каждое слово с заглавной буквы", - "es" : "Título" - } + "en" : "Title Case", + "es" : "Título", + "fr" : "Premières Lettres en Capitales", + "ru" : "Каждое слово с заглавной буквы" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.underline" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:159" ], "translations" : { "en" : "Underline", - "fr" : "Souligner", - "ru" : "Подчеркнутый", - "es" : "Subrayado" - } + "es" : "Subrayado", + "fr" : "Soulignage", + "ru" : "Подчеркнутый" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs" ] }, "workspace.options.text-options.uppercase" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:181" ], "translations" : { "en" : "Uppercase", + "es" : "Mayúsculas", "fr" : "Majuscule", - "ru" : "Верхний регистр", - "es" : "Mayúsculas" - } + "ru" : "Верхний регистр" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs" ] }, "workspace.options.text-options.vertical-align" : { "translations" : { "en" : "Vertical align", + "es" : "Alineación vertical", "fr" : "Alignement vertical", - "ru" : "Вертикальное выравнивание", - "es" : "Alineación vertical" + "ru" : "Вертикальное выравнивание" }, "unused" : true }, "workspace.options.use-play-button" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs:55" ], "translations" : { "en" : "Use the play button at the header to run the prototype view.", - "fr" : "Utilisez le bouton de lecture dans l'en-tête pour exécuter la vue du prototype.", - "ru" : "Используй кнопку запуск в заголовке чтобы перейти на экран прототипа.", - "es" : "Usa el botón de play de la cabecera para arrancar la vista de prototipo." - } + "es" : "Usa el botón de play de la cabecera para arrancar la vista de prototipo.", + "fr" : "Utilisez le bouton de lecture dans l’en‑tête pour exécuter la vue du prototype.", + "ru" : "Используй кнопку запуск в заголовке чтобы перейти на экран прототипа." + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/interactions.cljs" ] }, "workspace.shape.menu.back" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:124" ], "translations" : { "en" : "Send to back", - "fr" : "Mettre en arrière plan", - "es" : "Enviar al fondo" - } + "es" : "Enviar al fondo", + "fr" : "Envoyer au fond" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.backward" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:121" ], "translations" : { "en" : "Send backward", - "fr" : "Reculer", - "es" : "Enviar atrás" - } + "es" : "Enviar atrás", + "fr" : "Éloigner" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.copy" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:102" ], "translations" : { "en" : "Copy", - "fr" : "Copier", - "es" : "Copiar" - } + "es" : "Copiar", + "fr" : "Copier" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.create-component" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:168" ], "translations" : { "en" : "Create component", - "fr" : "Créer un composant", - "es" : "Crear componente" - } + "es" : "Crear componente", + "fr" : "Créer un composant" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.cut" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:105" ], "translations" : { "en" : "Cut", - "fr" : "Couper", - "es" : "Cortar" - } + "es" : "Cortar", + "fr" : "Couper" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.delete" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:200" ], "translations" : { "en" : "Delete", - "fr" : "Supprimer", - "es" : "Eliminar" - } + "es" : "Eliminar", + "fr" : "Supprimer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.detach-instance" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:180", "src/app/main/ui/workspace/context_menu.cljs:190", "src/app/main/ui/workspace/sidebar/options/component.cljs:95", "src/app/main/ui/workspace/sidebar/options/component.cljs:100" ], "translations" : { "en" : "Detach instance", - "fr" : "Détacher l'instance", - "es" : "Desacoplar instancia" - } + "es" : "Desacoplar instancia", + "fr" : "Détacher l’instance" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.duplicate" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:111" ], "translations" : { "en" : "Duplicate", - "fr" : "Dupliquer", - "es" : "Duplicar" - } + "es" : "Duplicar", + "fr" : "Dupliquer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] + }, + "workspace.shape.menu.edit" : { + "translations" : { + "en" : "Edit", + "es" : "Editar" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] + }, + "workspace.shape.menu.flip-horizontal" : { + "translations" : { + "en" : "Flip horizontal", + "es" : "Voltear horizontal" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] + }, + "workspace.shape.menu.flip-vertical" : { + "translations" : { + "en" : "Flip vertical", + "es" : "Voltear vertical" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.forward" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:115" ], "translations" : { "en" : "Bring forward", - "fr" : "Avancer", - "es" : "Mover hacia delante" - } + "es" : "Mover hacia delante", + "fr" : "Avancer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.front" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:118" ], "translations" : { "en" : "Bring to front", - "fr" : "Mettre au premier plan", - "es" : "Mover al frente" - } + "es" : "Mover al frente", + "fr" : "Amener au premier plan" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.go-master" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:194", "src/app/main/ui/workspace/sidebar/options/component.cljs:102" ], "translations" : { "en" : "Go to master component file", - "fr" : "Aller au fichier du composant principal", - "es" : "Ir al archivo del componente maestro" - } + "es" : "Ir al archivo del componente maestro", + "fr" : "Aller au fichier du composant principal" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.group" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:131" ], "translations" : { "en" : "Group", - "fr" : "Groupe", - "es" : "Grupo" - } + "es" : "Grupo", + "fr" : "Groupe" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.hide" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:154" ], "translations" : { "en" : "Hide", - "fr" : "Masquer", - "es" : "Ocultar" - } + "es" : "Ocultar", + "fr" : "Masquer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.lock" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:160" ], "translations" : { "en" : "Lock", - "fr" : "Bloquer", - "es" : "Bloquear" - } + "es" : "Bloquear", + "fr" : "Bloquer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.mask" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:134", "src/app/main/ui/workspace/context_menu.cljs:147" ], "translations" : { "en" : "Mask", - "fr" : "Masque", - "es" : "Máscara" - } + "es" : "Máscara", + "fr" : "Masque" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.paste" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:108", "src/app/main/ui/workspace/context_menu.cljs:209" ], "translations" : { "en" : "Paste", - "fr" : "Coller", - "es" : "Pegar" - } + "es" : "Pegar", + "fr" : "Coller" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.reset-overrides" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:182", "src/app/main/ui/workspace/context_menu.cljs:192", "src/app/main/ui/workspace/sidebar/options/component.cljs:96", "src/app/main/ui/workspace/sidebar/options/component.cljs:101" ], "translations" : { "en" : "Reset overrides", - "fr" : "Annuler les modifications", - "es" : "Deshacer modificaciones" - } + "es" : "Deshacer modificaciones", + "fr" : "Annuler les modifications" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.show" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:152" ], "translations" : { "en" : "Show", - "fr" : "Montrer", - "es" : "Mostrar" - } + "es" : "Mostrar", + "fr" : "Montrer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.show-master" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:186", "src/app/main/ui/workspace/sidebar/options/component.cljs:98" ], "translations" : { "en" : "Show master component", - "fr" : "Afficher le composant principal", - "es" : "Ver componente maestro" - } + "es" : "Ver componente maestro", + "fr" : "Afficher le composant principal" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.ungroup" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:140" ], "translations" : { "en" : "Ungroup", - "fr" : "Dégrouper", - "es" : "Desagrupar" - } + "es" : "Desagrupar", + "fr" : "Dégrouper" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.unlock" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:158" ], "translations" : { "en" : "Unlock", - "fr" : "Débloquer", - "es" : "Desbloquear" - } + "es" : "Desbloquear", + "fr" : "Débloquer" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.unmask" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:144" ], "translations" : { "en" : "Unmask", - "fr" : "Supprimer le masque", - "es" : "Quitar máscara" - } + "es" : "Quitar máscara", + "fr" : "Supprimer le masque" + }, + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.shape.menu.update-master" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:184", "src/app/main/ui/workspace/context_menu.cljs:196", "src/app/main/ui/workspace/sidebar/options/component.cljs:97", "src/app/main/ui/workspace/sidebar/options/component.cljs:103" ], "translations" : { "en" : "Update master component", - "fr" : "Actualiser le composant principal", - "es" : "Actualizar componente maestro" - } + "es" : "Actualizar componente maestro", + "fr" : "Actualiser le composant principal" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/sidebar/options/component.cljs", "src/app/main/ui/workspace/context_menu.cljs", "src/app/main/ui/workspace/context_menu.cljs" ] }, "workspace.sidebar.history" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:122" ], "translations" : { "en" : "History (%s)", - "fr" : "Historique (%s)", - "es" : "Historial (%s)" - } - }, - "workspace.sidebar.icons" : { - "translations" : { - "en" : "Icons", - "fr" : "Icône", - "ru" : "Иконки", - "es" : "Iconos" + "es" : "Historial (%s)", + "fr" : "Historique (%s)" }, - "unused" : true + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.sidebar.layers" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:112" ], "translations" : { "en" : "Layers (%s)", - "fr" : "Couches (%s)", - "es" : "Capas (%s)" - } + "es" : "Capas (%s)", + "fr" : "Calques (%s)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.sidebar.sitemap" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs:207" ], "translations" : { "en" : "Pages", + "es" : "Páginas", "fr" : "Pages", - "ru" : "Страницы", - "es" : "Páginas" - } + "ru" : "Страницы" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/sitemap.cljs" ] }, "workspace.sitemap" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:149" ], "translations" : { "en" : "Sitemap", + "es" : "Mapa del sitio", "fr" : "Plan du site", - "ru" : "Карта сайта", - "es" : "Mapa del sitio" - } + "ru" : "Карта сайта" + }, + "used-in" : [ "src/app/main/ui/workspace/header.cljs" ] }, "workspace.toolbar.assets" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:117" ], "translations" : { "en" : "Assets (%s)", + "es" : "Recursos (%s)", "fr" : "Ressources (%s)", - "ru" : "", - "es" : "Recursos (%s)" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.color-palette" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:127" ], "translations" : { "en" : "Color Palette (%s)", + "es" : "Paleta de colores (%s)", "fr" : "Palette de couleurs (%s)", - "ru" : "Палитра цветов (%s)", - "es" : "Paleta de colores (%s)" - } + "ru" : "Палитра цветов (%s)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.comments" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:105" ], "translations" : { "en" : "Comments (%s)", - "fr" : "Commentaires (%s)", - "es" : "Comentarios (%s)" - } + "es" : "Comentarios (%s)", + "fr" : "Commentaires (%s)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.curve" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:94" ], "translations" : { "en" : "Curve (%s)", + "es" : "Curva (%s)", "fr" : "Courbe (%s)", - "ru" : "Кривая (%s)", - "es" : "Curva (%s)" - } + "ru" : "Кривая (%s)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.ellipse" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:81" ], "translations" : { "en" : "Ellipse (E)", + "es" : "Elipse (E)", "fr" : "Ellipse (E)", - "ru" : "", - "es" : "Elipse (E)" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.frame" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:71" ], "translations" : { "en" : "Artboard (A)", + "es" : "Tablero (A)", "fr" : "Plan de travail (A)", - "ru" : "Рабочая область (A)", - "es" : "Tablero (A)" - } + "ru" : "Рабочая область (A)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.image" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:43" ], "translations" : { "en" : "Image (K)", + "es" : "Imagen (K)", "fr" : "Image (K)", - "ru" : "Изображение (K)", - "es" : "Imagen (K)" - } + "ru" : "Изображение (K)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.move" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:65" ], "translations" : { "en" : "Move", + "es" : "Mover", "fr" : "Déplacer", - "ru" : "Вытеснить", - "es" : "Mover" - } + "ru" : "Вытеснить" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.path" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:99" ], "translations" : { "en" : "Path (P)", + "es" : "Ruta (P)", "fr" : "Chemin (P)", - "ru" : "Линия (P)", - "es" : "Ruta (P)" - } + "ru" : "Линия (P)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.rect" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:76" ], "translations" : { "en" : "Rectangle (R)", + "es" : "Rectángulo (R)", "fr" : "Rectangle (R)", - "ru" : "Прямоугольник (R)", - "es" : "Rectángulo (R)" - } + "ru" : "Прямоугольник (R)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.toolbar.text" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:86" ], "translations" : { "en" : "Text (T)", + "es" : "Texto (T)", "fr" : "Texte (T)", - "ru" : "Текст (T)", - "es" : "Texto (T)" - } + "ru" : "Текст (T)" + }, + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs" ] }, "workspace.undo.empty" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:293" ], "translations" : { "en" : "There are no history changes so far", - "fr" : "Il n'y a aucun changement dans l'historique", - "es" : "Todavía no hay cambios en el histórico" - } + "es" : "Todavía no hay cambios en el histórico", + "fr" : "Il n’y a aucun changement dans l’historique pour l’instant" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.entry.delete" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:121" ], "translations" : { "en" : "Deleted %s", - "fr" : "Supprimé %s", - "es" : "%s eliminado" - } + "es" : "%s eliminado", + "fr" : "Supprimé %s" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.entry.modify" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:120" ], "translations" : { "en" : "Modified %s", - "fr" : "Modifié %s", - "es" : "%s modificado" - } + "es" : "%s modificado", + "fr" : "Modifié %s" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.entry.move" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:122" ], "translations" : { "en" : "Moved objects", - "fr" : "Objets déplacés", - "es" : "Objetos movidos" - } + "es" : "Objetos movidos", + "fr" : "Objets déplacés" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.entry.multiple.circle" : { "translations" : { "en" : "circles", - "fr" : "cercles", - "es" : "círculos" + "es" : "círculos", + "fr" : "cercles" }, "unused" : true }, "workspace.undo.entry.multiple.color" : { "translations" : { "en" : "color assets", - "fr" : "couleurs", - "es" : "colores" + "es" : "colores", + "fr" : "couleurs" }, "unused" : true }, "workspace.undo.entry.multiple.component" : { "translations" : { "en" : "components", - "fr" : "composants", - "es" : "componentes" + "es" : "componentes", + "fr" : "composants" }, "unused" : true }, "workspace.undo.entry.multiple.curve" : { "translations" : { "en" : "curves", - "fr" : "courbes", - "es" : "curvas" + "es" : "curvas", + "fr" : "courbes" }, "unused" : true }, "workspace.undo.entry.multiple.frame" : { "translations" : { "en" : "artboard", - "fr" : "plan de travail", - "es" : "mesa de trabajo" + "es" : "mesa de trabajo", + "fr" : "plan de travail" }, "unused" : true }, "workspace.undo.entry.multiple.group" : { "translations" : { "en" : "groups", - "fr" : "grouprs", - "es" : "grupos" - }, - "unused" : true - }, - "workspace.undo.entry.multiple.image" : { - "translations" : { - "en" : "images", - "fr" : "images", - "es" : "imágenes" + "es" : "grupos", + "fr" : "groupes" }, "unused" : true }, "workspace.undo.entry.multiple.media" : { "translations" : { "en" : "graphic assets", - "fr" : "graphiques", - "es" : "gráficos" + "es" : "gráficos", + "fr" : "graphiques" }, "unused" : true }, "workspace.undo.entry.multiple.multiple" : { "translations" : { "en" : "objects", - "fr" : "objets", - "es" : "objetos" + "es" : "objetos", + "fr" : "objets" }, "unused" : true }, "workspace.undo.entry.multiple.page" : { "translations" : { "en" : "pages", - "fr" : "pages", - "es" : "páginas" + "es" : "páginas", + "fr" : "pages" }, "unused" : true }, "workspace.undo.entry.multiple.path" : { "translations" : { "en" : "paths", - "fr" : "chemins", - "es" : "trazos" + "es" : "trazos", + "fr" : "chemins" }, "unused" : true }, "workspace.undo.entry.multiple.rect" : { "translations" : { "en" : "rectangles", - "fr" : "rectangles", - "es" : "rectángulos" + "es" : "rectángulos", + "fr" : "rectangles" }, "unused" : true }, "workspace.undo.entry.multiple.shape" : { "translations" : { "en" : "shapes", - "fr" : "formes", - "es" : "formas" + "es" : "formas", + "fr" : "formes" }, "unused" : true }, "workspace.undo.entry.multiple.text" : { "translations" : { "en" : "texts", - "fr" : "textes", - "es" : "textos" + "es" : "textos", + "fr" : "textes" }, "unused" : true }, "workspace.undo.entry.multiple.typography" : { "translations" : { "en" : "typography assets", - "fr" : "typographie", - "es" : "tipografías" + "es" : "tipografías", + "fr" : "typographie" }, "unused" : true }, "workspace.undo.entry.new" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:119" ], "translations" : { "en" : "New %s", - "fr" : "Nouveau %s", - "es" : "Nuevo %s" - } + "es" : "Nuevo %s", + "fr" : "Nouveau %s" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.entry.single.circle" : { "translations" : { "en" : "circle", - "fr" : "cercle", - "es" : "círculo" + "es" : "círculo", + "fr" : "cercle" }, "unused" : true }, "workspace.undo.entry.single.color" : { "translations" : { "en" : "color asset", - "fr" : "culeur", - "es" : "color" + "es" : "color", + "fr" : "couleur" }, "unused" : true }, "workspace.undo.entry.single.component" : { "translations" : { "en" : "component", - "fr" : "composant", - "es" : "componente" + "es" : "componente", + "fr" : "composant" }, "unused" : true }, "workspace.undo.entry.single.curve" : { "translations" : { "en" : "curve", - "fr" : "courbe", - "es" : "curva" + "es" : "curva", + "fr" : "courbe" }, "unused" : true }, "workspace.undo.entry.single.frame" : { "translations" : { "en" : "artboard", - "fr" : "plan de travail", - "es" : "mesa de trabajo" + "es" : "mesa de trabajo", + "fr" : "plan de travail" }, "unused" : true }, "workspace.undo.entry.single.group" : { "translations" : { "en" : "group", - "fr" : "groupe", - "es" : "grupo" + "es" : "grupo", + "fr" : "groupe" }, "unused" : true }, "workspace.undo.entry.single.image" : { "translations" : { "en" : "image", - "fr" : "image", - "es" : "imagen" + "es" : "imagen", + "fr" : "image" }, "unused" : true }, "workspace.undo.entry.single.media" : { "translations" : { "en" : "graphic asset", - "fr" : "graphique", - "es" : "gráfico" + "es" : "gráfico", + "fr" : "graphique" }, "unused" : true }, "workspace.undo.entry.single.multiple" : { "translations" : { "en" : "object", - "fr" : "objet", - "es" : "objeto" + "es" : "objeto", + "fr" : "objet" }, "unused" : true }, "workspace.undo.entry.single.page" : { "translations" : { "en" : "page", - "fr" : "page", - "es" : "página" + "es" : "página", + "fr" : "page" }, "unused" : true }, "workspace.undo.entry.single.path" : { "translations" : { "en" : "path", - "fr" : "chemin", - "es" : "trazo" + "es" : "trazo", + "fr" : "chemin" }, "unused" : true }, "workspace.undo.entry.single.rect" : { "translations" : { "en" : "rectangle", - "fr" : "Rectangle", - "es" : "rectángulo" + "es" : "rectángulo", + "fr" : "rectangle" }, "unused" : true }, "workspace.undo.entry.single.shape" : { "translations" : { "en" : "shape", - "fr" : "forme", - "es" : "forma" + "es" : "forma", + "fr" : "forme" }, "unused" : true }, "workspace.undo.entry.single.text" : { "translations" : { "en" : "text", - "fr" : "texte", - "es" : "texto" + "es" : "texto", + "fr" : "texte" }, "unused" : true }, "workspace.undo.entry.single.typography" : { "translations" : { "en" : "typography asset", - "fr" : "typographie", - "es" : "tipografía" + "es" : "tipografía", + "fr" : "typographie" }, "unused" : true }, "workspace.undo.entry.unknown" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:123" ], "translations" : { "en" : "Operation over %s", - "fr" : "Opération sur %s", - "es" : "Operación sobre %s" - } + "es" : "Operación sobre %s", + "fr" : "Opération sur %s" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.undo.title" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs:289" ], "translations" : { "en" : "History", - "fr" : "Historique", - "es" : "Histórico" - } + "es" : "Histórico", + "fr" : "Historique" + }, + "used-in" : [ "src/app/main/ui/workspace/sidebar/history.cljs" ] }, "workspace.updates.dismiss" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:690" ], "translations" : { "en" : "Dismiss", + "es" : "Ignorar", "fr" : "Ignorer", - "ru" : "", - "es" : "Ignorar" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/data/workspace/libraries.cljs" ] }, "workspace.updates.there-are-updates" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:686" ], "translations" : { "en" : "There are updates in shared libraries", + "es" : "Hay actualizaciones en librerías compartidas", "fr" : "Il y a des mises à jour dans les Bibliothèques Partagées", - "ru" : "", - "es" : "Hay actualizaciones en librerías compartidas" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/data/workspace/libraries.cljs" ] }, "workspace.updates.update" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:688" ], "translations" : { "en" : "Update", + "es" : "Actualizar", "fr" : "Actualiser", - "ru" : "", - "es" : "Actualizar" - } + "ru" : "" + }, + "used-in" : [ "src/app/main/data/workspace/libraries.cljs" ] }, "workspace.viewport.click-to-close-path" : { "translations" : { "en" : "Click to close the path", + "es" : "Pulsar para cerrar la ruta", "fr" : "Cliquez pour fermer le chemin", - "ru" : "Кликни чтобы закончить фигуру", - "es" : "Pulsar para cerrar la ruta" + "ru" : "Кликни чтобы закончить фигуру" }, "unused" : true - }, - "workspace.shape.menu.flip-horizontal" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:146" ], - "translations" : { - "en" : "Flip horizontal", - "es" : "Voltear horizontal" - } - }, - "workspace.shape.menu.flip-vertical" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:143" ], - "translations" : { - "en" : "Flip vertical", - "es" : "Voltear vertical" - } } } diff --git a/frontend/resources/styles/common/framework.scss b/frontend/resources/styles/common/framework.scss index 55089a604d..33116b33d8 100644 --- a/frontend/resources/styles/common/framework.scss +++ b/frontend/resources/styles/common/framework.scss @@ -385,6 +385,10 @@ ul.slider-dots { right: 6px; } + &.mini { + width: 43px; + } + // Input amounts &.pixels { @@ -1160,7 +1164,10 @@ input[type=range]:focus::-ms-fill-upper { .icon { padding: $small; - width: 40px; + width: 48px; + height: 48px; + justify-content: center; + align-items: center; } .content { @@ -1169,6 +1176,7 @@ input[type=range]:focus::-ms-fill-upper { font-size: $fs14; padding: $small; width: 100%; + align-items: center; } } @@ -1227,7 +1235,6 @@ input[type=range]:focus::-ms-fill-upper { &.inline { width: 100%; - margin-bottom: $big; } } diff --git a/frontend/resources/styles/main/layouts/login.scss b/frontend/resources/styles/main/layouts/login.scss index ba80b79f3b..e4b890d37b 100644 --- a/frontend/resources/styles/main/layouts/login.scss +++ b/frontend/resources/styles/main/layouts/login.scss @@ -45,12 +45,13 @@ } .auth-content { - grid-column: 2 / span 1; - height: 100vh; - display: flex; - justify-content: center; align-items: center; background-color: $color-white; + display: flex; + grid-column: 2 / span 1; + height: 100vh; + justify-content: center; + position: relative; .form-container { width: 412px; @@ -91,3 +92,13 @@ } } } + +.terms-login { + bottom: $big; + font-size: $fs14; + position: absolute; + + span { + margin: 0 $small; + } +} diff --git a/frontend/resources/styles/main/partials/dashboard-sidebar.scss b/frontend/resources/styles/main/partials/dashboard-sidebar.scss index eca4e70e26..801e91d5b7 100644 --- a/frontend/resources/styles/main/partials/dashboard-sidebar.scss +++ b/frontend/resources/styles/main/partials/dashboard-sidebar.scss @@ -354,7 +354,6 @@ } input[type=submit] { - width: 120px; margin-bottom: 0px; } } diff --git a/frontend/resources/styles/main/partials/handoff.scss b/frontend/resources/styles/main/partials/handoff.scss index ea94a565a5..18267d300d 100644 --- a/frontend/resources/styles/main/partials/handoff.scss +++ b/frontend/resources/styles/main/partials/handoff.scss @@ -90,7 +90,7 @@ position: relative; display: flex; flex-direction: row; - padding: 1rem 0.5rem; + padding: 1rem 1.6rem 1rem 0.5rem; .attributes-label, .attributes-value { diff --git a/frontend/resources/styles/main/partials/left-toolbar.scss b/frontend/resources/styles/main/partials/left-toolbar.scss index 1fab2b9b31..1385bfc929 100644 --- a/frontend/resources/styles/main/partials/left-toolbar.scss +++ b/frontend/resources/styles/main/partials/left-toolbar.scss @@ -42,14 +42,13 @@ $width-left-toolbar: 48px; flex-shrink: 0; height: 48px; justify-content: center; - margin: $x-small 0; position: relative; width: 48px; svg { fill: $color-gray-20; - height: 18px; - width: 18px; + height: 16px; + width: 16px; } &:hover { diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss index 866d61eb15..dd6fb38086 100644 --- a/frontend/resources/styles/main/partials/sidebar-element-options.scss +++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss @@ -595,6 +595,35 @@ } +.radius-options { + align-items: center; + border: 1px solid $color-gray-60; + border-radius: 4px; + display: flex; + justify-content: space-between; + padding: 8px; + width: 64px; + + .radius-icon { + display: flex; + align-items: center; + + svg { + cursor: pointer; + height: 16px; + fill: $color-gray-30; + width: 16px; + } + + &:hover, + &.selected { + svg { + fill: $color-primary; + } + } + } +} + .orientation-icon { margin-left: $small; display: flex; diff --git a/frontend/resources/styles/main/partials/workspace-header.scss b/frontend/resources/styles/main/partials/workspace-header.scss index b1def9cf06..6fd1cd7e35 100644 --- a/frontend/resources/styles/main/partials/workspace-header.scss +++ b/frontend/resources/styles/main/partials/workspace-header.scss @@ -21,6 +21,7 @@ .main-icon { align-items: center; background-color: $color-gray-60; + border-bottom: 1px solid $color-gray-50; cursor: pointer; display: flex; height: 100%; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 70e808686c..be85ce6d2f 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -66,23 +66,24 @@ (def default-theme "default") (def default-language "en") -(def demo-warning (obj/get global "penpotDemoWarning" false)) -(def feedback-enabled (obj/get global "penpotFeedbackEnabled" false)) -(def allow-demo-users (obj/get global "penpotAllowDemoUsers" true)) -(def google-client-id (obj/get global "penpotGoogleClientID" nil)) -(def gitlab-client-id (obj/get global "penpotGitlabClientID" nil)) -(def github-client-id (obj/get global "penpotGithubClientID" nil)) -(def login-with-ldap (obj/get global "penpotLoginWithLDAP" false)) -(def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js")) -(def translations (obj/get global "penpotTranslations")) -(def themes (obj/get global "penpotThemes")) +(def demo-warning (obj/get global "penpotDemoWarning" false)) +(def feedback-enabled (obj/get global "penpotFeedbackEnabled" false)) +(def allow-demo-users (obj/get global "penpotAllowDemoUsers" true)) +(def google-client-id (obj/get global "penpotGoogleClientID" nil)) +(def gitlab-client-id (obj/get global "penpotGitlabClientID" nil)) +(def github-client-id (obj/get global "penpotGithubClientID" nil)) +(def login-with-ldap (obj/get global "penpotLoginWithLDAP" false)) +(def registration-enabled (obj/get global "penpotRegistrationEnabled" true)) +(def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js")) +(def translations (obj/get global "penpotTranslations")) +(def themes (obj/get global "penpotThemes")) -(def public-uri (or (obj/get global "penpotPublicURI") (.-origin ^js location))) +(def public-uri (or (obj/get global "penpotPublicURI") (.-origin ^js location))) -(def version (delay (parse-version global))) -(def target (delay (parse-target global))) -(def browser (delay (parse-browser))) -(def platform (delay (parse-platform))) +(def version (delay (parse-version global))) +(def target (delay (parse-target global))) +(def browser (delay (parse-browser))) +(def platform (delay (parse-platform))) (when (= :browser @target) (js/console.log diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 0e678384f5..23ae18fb4a 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -82,7 +82,7 @@ (st/emit! (rt/initialize-router ui/routes) (rt/initialize-history on-navigate)) - (st/emit! udu/fetch-profile) + (st/emit! (udu/fetch-profile)) (mf/mount (mf/element ui/app) (dom/get-element "app")) (mf/mount (mf/element modal) (dom/get-element "modal"))) @@ -99,6 +99,10 @@ (mf/unmount (dom/get-element "modal")) (init-ui)) +(add-watch i18n/locale "locale" (fn [_ _ o v] + (when (not= o v) + (reinit)))) + (defn ^:dev/after-load after-load [] (reinit)) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index 5ef0ab07ba..b040a8d2c0 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -79,30 +79,6 @@ (watch [this state s] (rx/of (logged-in profile))))) -(defn login-with-ldap - [{:keys [email password] :as data}] - (us/verify ::login-params data) - (ptk/reify ::login-with-ldap - ptk/UpdateEvent - (update [_ state] - (merge state (dissoc initial-state :route :router))) - - ptk/WatchEvent - (watch [this state s] - (let [{:keys [on-error on-success] - :or {on-error identity - on-success identity}} (meta data) - params {:email email - :password password - :scope "webapp"}] - (->> (rx/timer 100) - (rx/mapcat #(rp/mutation :login-with-ldap params)) - (rx/tap on-success) - (rx/catch (fn [err] - (on-error err) - (rx/empty))) - (rx/map logged-in)))))) - ;; --- Logout (def clear-user-data @@ -114,12 +90,13 @@ ptk/WatchEvent (watch [_ state s] (->> (rp/mutation :logout) + (rx/catch (constantly (rx/empty))) (rx/ignore))) ptk/EffectEvent (effect [_ state s] (reset! storage {}) - (i18n/set-default-locale!)))) + (i18n/reset-locale)))) (defn logout [] @@ -131,10 +108,11 @@ ;; --- Register +(s/def ::invitation-token ::us/not-empty-string) + (s/def ::register - (s/keys :req-un [::fullname - ::password - ::email])) + (s/keys :req-un [::fullname ::password ::email] + :opt-un [::invitation-token])) (defn register "Create a register event instance." diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 48afb5ae50..03800e9927 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -24,6 +24,7 @@ (def mac-shift "\u21E7") (def mac-control "\u2303") (def mac-esc "\u238B") +(def mac-enter "\u23CE") (def left-arrow "\u2190") (def up-arrow "\u2191") @@ -73,3 +74,7 @@ mac-esc "Escape")) +(defn enter [] + (if (cfg/check-platform? :macos) + mac-enter + "Enter")) diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 4aec70645f..8490095c21 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -32,7 +32,7 @@ (s/def ::fullname ::us/string) (s/def ::email ::us/email) (s/def ::password ::us/string) -(s/def ::lang ::us/string) +(s/def ::lang (s/nilable ::us/string)) (s/def ::theme ::us/string) (s/def ::created-at ::us/inst) (s/def ::password-1 ::us/string) @@ -57,9 +57,6 @@ (update [_ state] (assoc state :profile (cond-> data - (nil? (:lang data)) - (assoc :lang cfg/default-language) - (nil? (:theme data)) (assoc :theme cfg/default-theme)))) @@ -67,12 +64,13 @@ (effect [_ state stream] (let [profile (:profile state)] (swap! storage assoc :profile profile) - (i18n/set-current-locale! (:lang profile)) + (i18n/set-locale! (:lang profile)) (theme/set-current-theme! (:theme profile)))))) ;; --- Fetch Profile -(def fetch-profile +(defn fetch-profile + [] (reify ptk/WatchEvent (watch [_ state s] @@ -90,16 +88,19 @@ (us/assert ::profile data) (ptk/reify ::update-profile ptk/WatchEvent - (watch [_ state s] - (let [mdata (meta data) + (watch [_ state stream] + (let [mdata (meta data) on-success (:on-success mdata identity) - on-error (:on-error mdata identity) - handle-error #(do (on-error (:payload %)) - (rx/empty))] - (->> (rp/mutation :update-profile data) - (rx/do on-success) - (rx/map (constantly fetch-profile)) - (rx/catch rp/client-error? handle-error)))))) + on-error (:on-error mdata identity)] + (rx/merge + (->> (rp/mutation :update-profile data) + (rx/map fetch-profile) + (rx/catch on-error)) + (->> stream + (rx/filter (ptk/type? ::profile-fetched)) + (rx/take 1) + (rx/tap on-success) + (rx/ignore))))))) ;; --- Request Email Change @@ -123,7 +124,7 @@ ptk/WatchEvent (watch [_ state stream] (->> (rp/mutation :cancel-email-change {}) - (rx/map (constantly fetch-profile)))))) + (rx/map (constantly (fetch-profile))))))) ;; --- Update Password (Form) @@ -158,7 +159,7 @@ (watch [_ state stream] (let [{:keys [id] :as profile} (:profile state)] (->> (rp/mutation :update-profile-props {:props {:onboarding-viewed true}}) - (rx/map (constantly fetch-profile))))))) + (rx/map (constantly (fetch-profile)))))))) ;; --- Update Photo @@ -184,7 +185,7 @@ (rx/map prepare) (rx/mapcat #(rp/mutation :update-profile-photo %)) (rx/do on-success) - (rx/map (constantly fetch-profile)) + (rx/map (constantly (fetch-profile))) (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 79d370c33c..3c4243ee0e 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -181,8 +181,10 @@ ;; Mark file initialized when indexes are ready (->> stream (rx/filter #(= ::dwc/index-initialized %)) - (rx/map (constantly - (file-initialized project-id file-id)))) + (rx/first) + (rx/map (fn [] + (file-initialized project-id file-id)))) + )))) (defn- file-initialized @@ -1003,7 +1005,7 @@ result (let [group (get objects current-id)] - (if (and (not= uuid/zero current-id) + (if (and (not= :frame (:type group)) (not= current-id parent-id) (empty? (remove removed-id? (:shapes group)))) @@ -1089,6 +1091,31 @@ (rx/of (relocate-shapes selected parent-id to-index)))))) +(defn start-editing-selected + [] + (ptk/reify ::start-editing-selected + ptk/WatchEvent + (watch [_ state stream] + (let [selected (get-in state [:workspace-local :selected])] + (if-not (= 1 (count selected)) + (rx/empty) + + (let [objects (dwc/lookup-page-objects state) + {:keys [id type shapes]} (get objects (first selected))] + + (case type + :text + (rx/of (dwc/start-edition-mode id)) + + :group + (rx/of (dwc/select-shapes (into (d/ordered-set) [(last shapes)]))) + + :path + (rx/of (dwc/start-edition-mode id) + (dwdp/start-path-edit id)) + :else (rx/empty)))))))) + + ;; --- Change Page Order (D&D Ordering) (defn relocate-page diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index b4b8a128fb..a83b4c68f5 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -63,6 +63,8 @@ ;; --- Changes Handling +(defonce page-change? #{:add-page :mod-page :del-page :mov-page}) + (defn commit-changes ([changes undo-changes] (commit-changes changes undo-changes {})) @@ -105,10 +107,24 @@ ptk/WatchEvent (watch [_ state stream] (when-not @error - (let [page-id (:current-page-id state)] + (let [;; adds page-id to page changes (that have the `id` field instead) + add-page-id + (fn [{:keys [id type page] :as change}] + (cond-> change + (page-change? type) + (assoc :page-id (or id (:id page))))) + + changes-by-pages + (->> changes + (map add-page-id) + (remove #(nil? (:page-id %))) + (group-by :page-id)) + + process-page-changes + (fn [[page-id changes]] + (update-indices page-id changes))] (rx/concat - (when (some :page-id changes) - (rx/of (update-indices page-id changes))) + (rx/from (map process-page-changes changes-by-pages)) (when (and save-undo? (seq undo-changes)) (let [entry {:undo-changes undo-changes diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 2eda58376e..cefc68be76 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -112,7 +112,7 @@ :command "shift+v" :fn #(st/emit! (dw/flip-vertical-selected))} - :flip-horizontal {:tooltip (ds/shift "V") + :flip-horizontal {:tooltip (ds/shift "H") :command "shift+h" :fn #(st/emit! (dw/flip-horizontal-selected))} @@ -255,7 +255,12 @@ :escape {:tooltip (ds/esc) :command "escape" - :fn #(st/emit! (esc-pressed))}}) + :fn #(st/emit! (esc-pressed))} + + :start-editing {:tooltip (ds/enter) + :command "enter" + :fn #(st/emit! (dw/start-editing-selected))} + }) (defn get-tooltip [shortcut] (assert (contains? shortcuts shortcut) (str shortcut)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index b102be822b..57be6ef0b3 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -373,7 +373,6 @@ (rx/of (set-modifiers selected) (apply-modifiers selected) - (calculate-frame-for-move selected) (fn [state] (-> state (update :workspace-local dissoc :modifiers) (update :workspace-local dissoc :current-move-selected))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 61e7045377..209350233a 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -41,36 +41,6 @@ {:id "bold" :name "bold" :weight "bold" :style "normal"} {:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"} {:id "black" :name "black" :weight "900" :style "normal"} - {:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]} - {:id "roboto" - :family "roboto" - :name "Roboto" - :variants [{:id "100" :name "100" :weight "100" :style "normal"} - {:id "100italic" :name "100 (italic)" :weight "100" :style "italic"} - {:id "200" :name "200" :weight "200" :style "normal"} - {:id "200italic" :name "200 (italic)" :weight "200" :style "italic"} - {:id "regular" :name "regular" :weight "400" :style "normal"} - {:id "italic" :name "italic" :weight "400" :style "italic"} - {:id "500" :name "500" :weight "500" :style "normal"} - {:id "500italic" :name "500 (italic)" :weight "500" :style "italic"} - {:id "bold" :name "bold" :weight "bold" :style "normal"} - {:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"} - {:id "black" :name "black" :weight "900" :style "normal"} - {:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]} - {:id "robotocondensed" - :family "robotocondensed" - :name "Roboto Condensed" - :variants [{:id "100" :name "100" :weight "100" :style "normal"} - {:id "100italic" :name "100 (italic)" :weight "100" :style "italic"} - {:id "200" :name "200" :weight "200" :style "normal"} - {:id "200italic" :name "200 (italic)" :weight "200" :style "italic"} - {:id "regular" :name "regular" :weight "400" :style "normal"} - {:id "italic" :name "italic" :weight "400" :style "italic"} - {:id "500" :name "500" :weight "500" :style "normal"} - {:id "500italic" :name "500 (italic)" :weight "500" :style "italic"} - {:id "bold" :name "bold" :weight "bold" :style "normal"} - {:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"} - {:id "black" :name "black" :weight "900" :style "normal"} {:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]}]) (defonce fontsdb (l/atom {})) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index d6effae0e9..2ec5b3e26e 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -81,19 +81,19 @@ (defmethod mutation :login-with-google [id params] (let [uri (str cfg/public-uri "/api/oauth/google")] - (->> (http/send! {:method :post :uri uri}) + (->> (http/send! {:method :post :uri uri :query params}) (rx/mapcat handle-response)))) (defmethod mutation :login-with-gitlab [id params] (let [uri (str cfg/public-uri "/api/oauth/gitlab")] - (->> (http/send! {:method :post :uri uri}) + (->> (http/send! {:method :post :uri uri :query params}) (rx/mapcat handle-response)))) (defmethod mutation :login-with-github [id params] (let [uri (str cfg/public-uri "/api/oauth/github")] - (->> (http/send! {:method :post :uri uri}) + (->> (http/send! {:method :post :uri uri :query params}) (rx/mapcat handle-response)))) (defmethod mutation :upload-file-media-object @@ -106,6 +106,12 @@ (seq params)) (send-mutation! id form))) +(defmethod mutation :send-feedback + [id params] + (let [uri (str cfg/public-uri "/api/feedback")] + (->> (http/send! {:method :post :uri uri :body params}) + (rx/mapcat handle-response)))) + (defmethod mutation :update-profile-photo [id params] (let [form (js/FormData.)] @@ -122,23 +128,5 @@ (seq params)) (send-mutation! id form))) -(defmethod mutation :login - [id params] - (let [uri (str cfg/public-uri "/api/login")] - (->> (http/send! {:method :post :uri uri :body params}) - (rx/mapcat handle-response)))) - -(defmethod mutation :logout - [id params] - (let [uri (str cfg/public-uri "/api/logout")] - (->> (http/send! {:method :post :uri uri :body params}) - (rx/mapcat handle-response)))) - -(defmethod mutation :login-with-ldap - [id params] - (let [uri (str cfg/public-uri "/api/login-ldap")] - (->> (http/send! {:method :post :uri uri :body params}) - (rx/mapcat handle-response)))) - (def client-error? http/client-error?) (def server-error? http/server-error?) diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index b8a8e952a9..969bae465f 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -11,11 +11,12 @@ [app.main.store :as st] [app.main.refs :as refs] [app.common.geom.point :as gpt] - [app.util.globals :as globals])) + [app.util.globals :as globals]) + (:import goog.events.KeyCodes)) ;; --- User Events -(defrecord KeyboardEvent [type key shift ctrl alt]) +(defrecord KeyboardEvent [type key shift ctrl alt meta]) (defn keyboard-event? [v] @@ -112,7 +113,28 @@ ob (->> (rx/merge (->> st/stream (rx/filter keyboard-event?) - (rx/map :alt)) + (rx/filter #(let [key (:key %)] + (= key KeyCodes.ALT))) + (rx/map #(= :down (:type %)))) + ;; Fix a situation caused by using `ctrl+alt` kind of shortcuts, + ;; that makes keyboard-alt stream registring the key pressed but + ;; on bluring the window (unfocus) the key down is never arrived. + (->> window-blur + (rx/map (constantly false)))) + (rx/dedupe))] + (rx/subscribe-with ob sub) + sub)) + +(defonce keyboard-ctrl + (let [sub (rx/behavior-subject nil) + ob (->> (rx/merge + (->> st/stream + (rx/filter keyboard-event?) + (rx/filter #(let [key (:key %)] + (or + (= key KeyCodes.CTRL) + (= key KeyCodes.META)))) + (rx/map #(= :down (:type %)))) ;; Fix a situation caused by using `ctrl+alt` kind of shortcuts, ;; that makes keyboard-alt stream registring the key pressed but ;; on bluring the window (unfocus) the key down is never arrived. diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 81ac15d54e..fb5861ec0d 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -60,8 +60,10 @@ (def routes [["/auth" ["/login" :auth-login] - ["/register" :auth-register] - ["/register/success" :auth-register-success] + (when cfg/registration-enabled + ["/register" :auth-register]) + (when cfg/registration-enabled + ["/register/success" :auth-register-success]) ["/recovery/request" :auth-recovery-request] ["/recovery" :auth-recovery] ["/verify-token" :auth-verify-token]] diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index 19065079b4..29dd7af455 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -37,7 +37,7 @@ [:div.auth [:section.auth-sidebar - [:a.logo {:href "/#/"} i/logo] + [:a.logo {:href "https://penpot.app"} i/logo] [:span.tagline (t locale "auth.sidebar-tagline")]] [:section.auth-content @@ -49,11 +49,15 @@ [:& register-success-page {:params params}] :auth-login - [:& login-page {:locale locale :params params}] + [:& login-page {:params params}] :auth-recovery-request [:& recovery-request-page {:locale locale}] :auth-recovery [:& recovery-page {:locale locale - :params (:query-params route)}])]])) + :params (:query-params route)}]) + [:div.terms-login + [:a {:href "https://penpot.app/terms.html" :target "_blank"} "Terms of service"] + [:span "and"] + [:a {:href "https://penpot.app/privacy.html" :target "_blank"} "Privacy policy"]]]])) diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index bbdc805e72..db3b56cf76 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.auth.login (:require @@ -33,44 +33,62 @@ (s/keys :req-un [::email ::password])) (defn- login-with-google - [event] + [event params] (dom/prevent-default event) - (->> (rp/mutation! :login-with-google {}) + (->> (rp/mutation! :login-with-google params) (rx/subs (fn [{:keys [redirect-uri] :as rsp}] (.replace js/location redirect-uri)) (fn [{:keys [type] :as error}] (st/emit! (dm/error (tr "errors.google-auth-not-enabled"))))))) (defn- login-with-gitlab - [event] + [event params] (dom/prevent-default event) - (->> (rp/mutation! :login-with-gitlab {}) + (->> (rp/mutation! :login-with-gitlab params) (rx/subs (fn [{:keys [redirect-uri] :as rsp}] (.replace js/location redirect-uri))))) (defn- login-with-github - [event] + [event params] (dom/prevent-default event) - (->> (rp/mutation! :login-with-github {}) + (->> (rp/mutation! :login-with-github params) (rx/subs (fn [{:keys [redirect-uri] :as rsp}] (.replace js/location redirect-uri))))) +(defn- login-with-ldap + [event params] + (dom/prevent-default event) + (dom/stop-propagation event) + (let [{:keys [on-error]} (meta params)] + (->> (rp/mutation! :login-with-ldap params) + (rx/subs (fn [profile] + (if-let [token (:invitation-token profile)] + (st/emit! (rt/nav :auth-verify-token {} {:token token})) + (st/emit! (da/logged-in profile)))) + (fn [{:keys [type code] :as error}] + (cond + (and (= type :restriction) + (= code :ldap-disabled)) + (st/emit! (dm/error (tr "errors.ldap-disabled"))) + + (fn? on-error) + (on-error error))))))) + (mf/defc login-form - [] - (let [error? (mf/use-state false) - form (fm/use-form :spec ::login-form - :inital {}) + [{:keys [params] :as props}] + (let [error (mf/use-state false) + form (fm/use-form :spec ::login-form + :inital {}) on-error - (fn [form event] - (js/console.log error?) - (reset! error? true)) + (fn [_] + (reset! error (tr "errors.wrong-credentials"))) on-submit (mf/use-callback (mf/deps form) (fn [event] - (reset! error? false) + (reset! error nil) (let [params (with-meta (:clean-data @form) {:on-error on-error})] (st/emit! (da/login params))))) @@ -79,17 +97,15 @@ (mf/use-callback (mf/deps form) (fn [event] - (reset! error? false) - (let [params (with-meta (:clean-data @form) - {:on-error on-error})] - (st/emit! (da/login-with-ldap params)))))] + (let [params (merge (:clean-data @form) params)] + (login-with-ldap event (with-meta params {:on-error on-error})))))] [:* - (when @error? + (when-let [message @error] [:& msgs/inline-banner {:type :warning - :content (tr "errors.auth.unauthorized") - :on-close #(reset! error? false)}]) + :content message + :on-close #(reset! error nil)}]) [:& fm/form {:on-submit on-submit :form form} [:div.fields-row @@ -107,8 +123,7 @@ :help-icon i/eye :label (tr "auth.password")}]] [:& fm/submit-button - {:label (tr "auth.login-submit") - :on-click on-submit}] + {:label (tr "auth.login-submit")}] (when cfg/login-with-ldap [:& fm/submit-button @@ -116,13 +131,13 @@ :on-click on-submit-ldap}])]])) (mf/defc login-page - [] + [{:keys [params] :as props}] [:div.generic-form.login-form [:div.form-container [:h1 (tr "auth.login-title")] [:div.subtitle (tr "auth.login-subtitle")] - [:& login-form {}] + [:& login-form {:params params}] [:div.links [:div.link-entry @@ -130,27 +145,28 @@ :tab-index "5"} (tr "auth.forgot-password")]] - [:div.link-entry - [:span (tr "auth.register") " "] - [:a {:on-click #(st/emit! (rt/nav :auth-register)) - :tab-index "6"} - (tr "auth.register-submit")]]] + (when cfg/registration-enabled + [:div.link-entry + [:span (tr "auth.register") " "] + [:a {:on-click #(st/emit! (rt/nav :auth-register {} params)) + :tab-index "6"} + (tr "auth.register-submit")]])] (when cfg/google-client-id [:a.btn-ocean.btn-large.btn-google-auth - {:on-click login-with-google} + {:on-click #(login-with-google % params)} "Login with Google"]) (when cfg/gitlab-client-id [:a.btn-ocean.btn-large.btn-gitlab-auth - {:on-click login-with-gitlab} + {:on-click #(login-with-gitlab % params)} [:img.logo {:src "/images/icons/brand-gitlab.svg"}] (tr "auth.login-with-gitlab-submit")]) (when cfg/github-client-id [:a.btn-ocean.btn-large.btn-github-auth - {:on-click login-with-github} + {:on-click #(login-with-github % params)} [:img.logo {:src "/images/icons/brand-github.svg"}] (tr "auth.login-with-github-submit")]) diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 19f4daaddc..9a0e0db0bc 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -18,9 +18,9 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr t]] [app.util.router :as rt] + [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] - [beicon.core :as rx] [rumext.alpha :as mf])) (s/def ::email ::us/email) @@ -28,37 +28,41 @@ (mf/defc recovery-form [] - (let [form (fm/use-form :spec ::recovery-request-form - :initial {}) - + (let [form (fm/use-form :spec ::recovery-request-form :initial {}) submitted (mf/use-state false) - on-error - (mf/use-callback - (fn [{:keys [code] :as error}] - (reset! submitted false) - (if (= code :profile-not-verified) - (rx/of (dm/error (tr "auth.notifications.profile-not-verified") - {:timeout nil})) - - (rx/throw error)))) - on-success (mf/use-callback - (fn [] + (fn [data] (reset! submitted false) (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")) (rt/nav :auth-login)))) + on-error + (mf/use-callback + (fn [data {:keys [code] :as error}] + (reset! submitted false) + (case code + :profile-not-verified + (rx/of (dm/error (tr "auth.notifications.profile-not-verified") {:timeout nil})) + + :profile-is-muted + (rx/of (dm/error (tr "errors.profile-is-muted"))) + + :email-has-permanent-bounces + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" (:email data)))) + + (rx/throw error)))) + on-submit (mf/use-callback (fn [] (reset! submitted true) - (->> (with-meta (:clean-data @form) - {:on-success on-success - :on-error on-error}) - (uda/request-profile-recovery) - (st/emit!))))] + (let [cdata (:clean-data @form) + params (with-meta cdata + {:on-success #(on-success cdata %) + :on-error #(on-error cdata %)})] + (st/emit! (uda/request-profile-recovery params)))))] [:& fm/form {:on-submit on-submit :form form} diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 9d76bd2b90..63b59b1b97 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -23,6 +23,7 @@ [app.util.i18n :refer [tr t]] [app.util.router :as rt] [app.util.timers :as tm] + [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -42,13 +43,11 @@ (s/def ::fullname ::us/not-empty-string) (s/def ::password ::us/not-empty-string) (s/def ::email ::us/email) -(s/def ::token ::us/not-empty-string) +(s/def ::invitation-token ::us/not-empty-string) (s/def ::register-form - (s/keys :req-un [::password - ::fullname - ::email] - :opt-un [::token])) + (s/keys :req-un [::password ::fullname ::email] + :opt-un [::invitation-token])) (mf/defc register-form [{:keys [params] :as props}] @@ -64,23 +63,24 @@ (reset! submitted? false) (case (:code error) :registration-disabled - (st/emit! (dm/error (tr "errors.registration-disabled"))) + (rx/of (dm/error (tr "errors.registration-disabled"))) + + :email-has-permanent-bounces + (let [email (get @form [:data :email])] + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" email)))) :email-already-exists (swap! form assoc-in [:errors :email] {:message "errors.email-already-exists"}) - (st/emit! (dm/error (tr "errors.unexpected-error")))))) + (rx/throw error)))) on-success (mf/use-callback (fn [form data] (reset! submitted? false) - (if (and (:is-active data) (:claims data)) - (let [message (tr "auth.notifications.team-invitation-accepted")] - (st/emit! (rt/nav :dashboard-projects {:team-id (get-in data [:claims :team-id])}) - du/fetch-profile - (dm/success message))) + (if-let [token (:invitation-token data)] + (st/emit! (rt/nav :auth-verify-token {} {:token token})) (st/emit! (rt/nav :auth-register-success {} {:email (:email data)}))))) on-submit @@ -143,7 +143,7 @@ [:div.links [:div.link-entry [:span (tr "auth.already-have-account") " "] - [:a {:on-click #(st/emit! (rt/nav :auth-login)) + [:a {:on-click #(st/emit! (rt/nav :auth-login {} params)) :tab-index "4"} (tr "auth.login-here")]] @@ -156,19 +156,19 @@ (when cfg/google-client-id [:a.btn-ocean.btn-large.btn-google-auth - {:on-click login/login-with-google} + {:on-click #(login/login-with-google % params)} "Login with Google"]) (when cfg/gitlab-client-id [:a.btn-ocean.btn-large.btn-gitlab-auth - {:on-click login/login-with-gitlab} + {:on-click #(login/login-with-gitlab % params)} [:img.logo {:src "/images/icons/brand-gitlab.svg"}] (tr "auth.login-with-gitlab-submit")]) (when cfg/github-client-id [:a.btn-ocean.btn-large.btn-github-auth - {:on-click login/login-with-github} + {:on-click #(login/login-with-github % params)} [:img.logo {:src "/images/icons/brand-github.svg"}] (tr "auth.login-with-github-submit")])]) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 0f01368372..403fd25bd7 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -42,7 +42,7 @@ (let [msg (tr "dashboard.notifications.email-changed-successfully")] (ts/schedule 100 #(st/emit! (dm/success msg))) (st/emit! (rt/nav :settings-profile) - du/fetch-profile))) + (du/fetch-profile)))) (defmethod handle-token :auth [tdata] @@ -52,18 +52,19 @@ [tdata] (case (:state tdata) :created - (let [message (tr "auth.notifications.team-invitation-accepted")] - (st/emit! du/fetch-profile - (rt/nav :dashboard-projects {:team-id (:team-id tdata)}) - (dm/success message))) + (st/emit! (dm/success (tr "auth.notifications.team-invitation-accepted")) + (du/fetch-profile) + (rt/nav :dashboard-projects {:team-id (:team-id tdata)})) :pending - (st/emit! (rt/nav :auth-register {} {:token (:token tdata)})))) + (let [token (:invitation-token tdata)] + (st/emit! (rt/nav :auth-register {} {:invitation-token token}))))) (defmethod handle-token :default [tdata] - (js/console.log "Unhandled token:" (pr-str tdata)) - (st/emit! (rt/nav :auth-login))) + (st/emit! + (rt/nav :auth-login) + (dm/warn (tr "errors.unexpected-token")))) (mf/defc verify-token [{:keys [route] :as props}] diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 89555b2342..3343403cbe 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -11,18 +11,18 @@ (:require [app.config :as cfg] [app.main.data.comments :as dcm] + [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] [app.main.streams :as ms] - [app.main.ui.context :as ctx] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.data.modal :as modal] + [app.main.ui.context :as ctx] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] - [app.util.time :as dt] [app.util.dom :as dom] - [app.util.object :as obj] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [app.util.time :as dt] [cuerdas.core :as str] [okulary.core :as l] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/components/editable_label.cljs b/frontend/src/app/main/ui/components/editable_label.cljs index 6fac66f569..d8ea4c657f 100644 --- a/frontend/src/app/main/ui/components/editable_label.cljs +++ b/frontend/src/app/main/ui/components/editable_label.cljs @@ -9,12 +9,12 @@ (ns app.main.ui.components.editable-label (:require - [rumext.alpha :as mf] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] + [app.util.data :refer [classnames]] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [app.util.timers :as timers] - [app.util.data :refer [classnames]])) + [rumext.alpha :as mf])) (mf/defc editable-label [{:keys [value on-change on-cancel editing? disable-dbl-click? class-name]}] diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 6ee38c6d15..a8161d7d9b 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -9,12 +9,12 @@ (ns app.main.ui.components.numeric-input (:require - [rumext.alpha :as mf] - [app.main.ui.keyboard :as kbd] [app.common.data :as d] + [app.common.math :as math] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [app.util.object :as obj] - [app.common.math :as math])) + [rumext.alpha :as mf])) (mf/defc numeric-input {::mf/wrap-props false diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index 73bf41bcc0..dd1b303af6 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -8,17 +8,17 @@ ;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.ui.confirm - (:import goog.events.EventType) (:require - [rumext.alpha :as mf] - [goog.events :as events] [app.main.data.modal :as modal] [app.main.store :as st] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as k] + [app.util.data :refer [classnames]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr t]] - [app.util.data :refer [classnames]])) + [app.util.keyboard :as k] + [goog.events :as events] + [rumext.alpha :as mf]) + (:import goog.events.EventType)) (mf/defc confirm-dialog {::mf/register modal/components diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index d87aa390ae..81501e109d 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -16,9 +16,9 @@ [app.main.ui.dashboard.grid :refer [grid]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [okulary.core :as l] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 50c67d1871..91e7c89d86 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -9,20 +9,20 @@ (ns app.main.ui.dashboard.grid (:require - [app.common.uuid :as uuid] [app.common.math :as mth] + [app.common.uuid :as uuid] [app.config :as cfg] [app.main.data.dashboard :as dd] + [app.main.data.modal :as modal] [app.main.fonts :as fonts] [app.main.store :as st] [app.main.ui.components.context-menu :refer [context-menu]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] - [app.main.data.modal :as modal] [app.main.worker :as wrk] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [app.util.time :as dt] [app.util.timers :as ts] diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 6439414a8b..652fa10f59 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -10,8 +10,8 @@ (ns app.main.ui.dashboard.inline-edition (:require [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [rumext.alpha :as mf])) (mf/defc inline-edition diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index fde6e0cf27..4aa913ea2a 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -16,9 +16,9 @@ [app.main.store :as st] [app.main.ui.dashboard.grid :refer [line-grid]] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [app.util.time :as dt] [okulary.core :as l] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 2d4fb98a3d..f5d98b0311 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -13,10 +13,10 @@ [app.common.spec :as us] [app.config :as cfg] [app.main.data.auth :as da] + [app.main.data.comments :as dcm] [app.main.data.dashboard :as dd] [app.main.data.messages :as dm] [app.main.data.modal :as modal] - [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.repo :as rp] [app.main.store :as st] @@ -26,13 +26,13 @@ [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.team-form] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] + [app.util.avatars :as avatars] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [app.util.object :as obj] [app.util.router :as rt] [app.util.time :as dt] - [app.util.avatars :as avatars] [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 85ea34d9df..2ab438bdd4 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -97,13 +97,34 @@ (st/emitf (dm/success "Invitation sent successfully") (modal/hide))) + on-error + (mf/use-callback + (mf/deps team) + (fn [form {:keys [type code] :as error}] + (let [email (get @form [:data :email])] + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (dm/error (tr "errors.profile-is-muted")) + + (and (= :validation type) + (= :member-is-muted code)) + (dm/error (tr "errors.member-is-muted")) + + (and (= :validation type) + (= :email-has-permanent-bounces)) + (dm/error (tr "errors.email-has-permanent-bounces" email)) + + :else + (dm/error (tr "errors.generic")))))) on-submit (mf/use-callback (mf/deps team) (fn [form] (let [params (:clean-data @form) - mdata {:on-success (partial on-success form)}] + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)}] (st/emit! (dd/invite-team-member (with-meta params mdata))))))] [:div.modal.dashboard-invite-modal.form-container diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 0e263e5516..d743f76dca 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -18,12 +18,11 @@ [app.main.data.modal :as modal] [app.main.repo :as rp] [app.main.store :as st] - [app.main.ui.components.forms :refer [input submit-button form]] + [app.main.ui.components.forms :as fm] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] - [app.util.forms :as fm] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [app.util.object :as obj] [app.util.router :as rt] [app.util.time :as dt] @@ -90,28 +89,28 @@ [:div.modal-overlay [:div.modal-container.team-form-modal - [:div.modal-header - [:div.modal-header-title - (if team - [:h2 "Rename team"] - [:h2 "Create new team"])] - [:div.modal-close-button - {:on-click (st/emitf (modal/hide))} i/close]] + [:& fm/form {:form form + :on-submit on-submit} - [:div.modal-content.generic-form - [:form - [:& input {:type "text" - :form form - :name :name - :label "Enter new team name:"}]]] + [:div.modal-header + [:div.modal-header-title + (if team + [:h2 (tr "labels.rename-team")] + [:h2 (tr "labels.create-team")])] + [:div.modal-close-button + {:on-click (st/emitf (modal/hide))} i/close]] - [:div.modal-footer - [:div.action-buttons - [:& submit-button - {:form form - :on-click on-submit - :label (if team - "Update team" - "Create team")}]]]]])) + [:div.modal-content.generic-form + [:& fm/input {:type "text" + :form form + :name :name + :label "Enter new team name:"}]] + + [:div.modal-footer + [:div.action-buttons + [:& fm/submit-button + {:label (if team + (tr "labels.update-team") + (tr "labels.create-team"))}]]]]]])) diff --git a/frontend/src/app/main/ui/handoff.cljs b/frontend/src/app/main/ui/handoff.cljs index 95c0434e40..b9c41b9f30 100644 --- a/frontend/src/app/main/ui/handoff.cljs +++ b/frontend/src/app/main/ui/handoff.cljs @@ -20,11 +20,11 @@ [app.main.ui.handoff.right-sidebar :refer [right-sidebar]] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.viewer.header :refer [header]] [app.main.ui.viewer.thumbnails :refer [thumbnails-panel]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [beicon.core :as rx] [goog.events :as events] [okulary.core :as l] diff --git a/frontend/src/app/main/ui/handoff/attributes/layout.cljs b/frontend/src/app/main/ui/handoff/attributes/layout.cljs index 02175ece73..c161fffa95 100644 --- a/frontend/src/app/main/ui/handoff/attributes/layout.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/layout.cljs @@ -17,13 +17,17 @@ [app.util.code-gen :as cg] [app.main.ui.components.copy-button :refer [copy-button]])) -(def properties [:width :height :x :y :radius :rx]) +(def properties [:width :height :x :y :radius :rx :r1]) + (def params {:to-prop {:x "left" :y "top" :rotation "transform" - :rx "border-radius"} - :format {:rotation #(str/fmt "rotate(%sdeg)" %)}}) + :rx "border-radius" + :r1 "border-radius"} + :format {:rotation #(str/fmt "rotate(%sdeg)" %) + :r1 #(apply str/fmt "%spx, %spx, %spx, %spx" %)} + :multi {:r1 [:r1 :r2 :r3 :r4]}}) (defn copy-data ([shape] @@ -62,6 +66,19 @@ [:div.attributes-value (mth/precision (:rx shape) 2) "px"] [:& copy-button {:data (copy-data shape :rx)}]]) + (when (and (:r1 shape) + (or (not= (:r1 shape) 0) + (not= (:r2 shape) 0) + (not= (:r3 shape) 0) + (not= (:r4 shape) 0))) + [:div.attributes-unit-row + [:div.attributes-label (t locale "handoff.attributes.layout.radius")] + [:div.attributes-value (mth/precision (:r1 shape) 2) ", " + (mth/precision (:r2 shape) 2) ", " + (mth/precision (:r3 shape) 2) ", " + (mth/precision (:r4 shape) 2) "px"] + [:& copy-button {:data (copy-data shape :r1)}]]) + (when (not= (:rotation shape 0) 0) [:div.attributes-unit-row [:div.attributes-label (t locale "handoff.attributes.layout.rotation")] diff --git a/frontend/src/app/main/ui/handoff/left_sidebar.cljs b/frontend/src/app/main/ui/handoff/left_sidebar.cljs index 70d1c701d9..b9107018d6 100644 --- a/frontend/src/app/main/ui/handoff/left_sidebar.cljs +++ b/frontend/src/app/main/ui/handoff/left_sidebar.cljs @@ -14,10 +14,9 @@ [app.main.data.viewer :as dv] [app.main.store :as st] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] - [app.main.ui.keyboard :as kbd] [app.main.ui.workspace.sidebar.layers :refer [element-icon layer-name frame-wrapper]] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [okulary.core :as l] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 3d54dfb6b4..1bf0233769 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -87,6 +87,8 @@ (def play (icon-xref :play)) (def plus (icon-xref :plus)) (def radius (icon-xref :radius)) +(def radius-1 (icon-xref :radius-1)) +(def radius-4 (icon-xref :radius-4)) (def recent (icon-xref :recent)) (def redo (icon-xref :redo)) (def rotate (icon-xref :rotate)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index a3de8bf923..396aa867bd 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -9,14 +9,14 @@ (ns app.main.ui.modal (:require - [okulary.core :as l] - [goog.events :as events] - [rumext.alpha :as mf] - [app.main.store :as st] - [app.main.ui.keyboard :as k] [app.main.data.modal :as dm] + [app.main.refs :as refs] + [app.main.store :as st] [app.util.dom :as dom] - [app.main.refs :as refs]) + [app.util.keyboard :as k] + [goog.events :as events] + [okulary.core :as l] + [rumext.alpha :as mf]) (:import goog.events.EventType)) (defn- on-esc-clicked diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index cdad1a33e1..63a1ea84a3 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -40,22 +40,31 @@ (s/keys :req-un [::email-1 ::email-2])) (defn- on-error - [form error] - (cond - (= (:code error) :email-already-exists) + [form {:keys [code] :as error}] + (case code + :email-already-exists (swap! form (fn [data] (let [error {:message (tr "errors.email-already-exists")}] (assoc-in data [:errors :email-1] error)))) - :else + :profile-is-muted + (rx/of (dm/error (tr "errors.profile-is-muted"))) + + :email-has-permanent-bounces + (let [email (get @form [:data :email-1])] + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" email)))) + (rx/throw error))) (defn- on-success [form data] - (let [email (get-in @form [:clean-data :email-1]) - message (tr "notifications.validation-email-sent" email)] - (st/emit! (dm/info message) - (modal/hide)))) + (if (:changed data) + (st/emit! (du/fetch-profile) + (modal/hide)) + (let [email (get-in @form [:clean-data :email-1]) + message (tr "notifications.validation-email-sent" email)] + (st/emit! (dm/info message) + (modal/hide))))) (defn- on-submit [form event] diff --git a/frontend/src/app/main/ui/settings/feedback.cljs b/frontend/src/app/main/ui/settings/feedback.cljs index 125303a23f..1f8e885193 100644 --- a/frontend/src/app/main/ui/settings/feedback.cljs +++ b/frontend/src/app/main/ui/settings/feedback.cljs @@ -30,16 +30,7 @@ (s/def ::feedback-form (s/keys :req-un [::subject ::content])) -(defn- on-error - [form error] - (st/emit! (dm/error (tr "errors.generic")))) - -(defn- on-success - [form] - (st/emit! (dm/success (tr "notifications.profile-saved")))) - - -(mf/defc options-form +(mf/defc feedback-form [] (let [profile (mf/deref refs/profile) form (fm/use-form :spec ::feedback-form) @@ -50,6 +41,7 @@ (mf/use-callback (mf/deps profile) (fn [event] + (reset! loading false) (st/emit! (dm/success (tr "labels.feedback-sent"))) (swap! form assoc :data {} :touched {} :errors {}))) @@ -58,7 +50,7 @@ (mf/deps profile) (fn [{:keys [code] :as error}] (reset! loading false) - (if (= code :feedbck-disabled) + (if (= code :feedback-disabled) (st/emit! (dm/error (tr "labels.feedback-disabled"))) (st/emit! (dm/error (tr "errors.generic")))))) @@ -68,9 +60,8 @@ (fn [form event] (reset! loading true) (let [data (:clean-data @form)] - (prn "on-submit" data) - (->> (rp/mutation! :send-profile-feedback data) - (rx/subs on-succes on-error #(reset! loading false))))))] + (->> (rp/mutation! :send-feedback data) + (rx/subs on-succes on-error)))))] [:& fm/form {:class "feedback-form" :on-submit on-submit @@ -117,4 +108,4 @@ [] [:div.dashboard-settings [:div.form-container - [:& options-form]]]) + [:& feedback-form]]]) diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 0ae1a3d3c1..1ad0d6d2c0 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -10,6 +10,7 @@ (ns app.main.ui.settings.options (:require [app.common.spec :as us] + [app.common.data :as d] [app.main.data.messages :as dm] [app.main.data.users :as du] [app.main.refs :as refs] @@ -21,7 +22,7 @@ [cljs.spec.alpha :as s] [rumext.alpha :as mf])) -(s/def ::lang (s/nilable ::us/not-empty-string)) +(s/def ::lang (s/nilable ::us/string)) (s/def ::theme (s/nilable ::us/not-empty-string)) (s/def ::options-form @@ -38,6 +39,9 @@ (defn- on-submit [form event] (let [data (:clean-data @form) + data (cond-> data + (empty? (:lang data)) + (assoc :lang nil)) mdata {:on-success (partial on-success form) :on-error (partial on-error form)}] (st/emit! (du/update-profile (with-meta data mdata))))) @@ -54,12 +58,10 @@ [:h2 (t locale "labels.language")] [:div.fields-row - [:& fm/select {:options [{:label "English" :value "en"} - {:label "Français" :value "fr"} - {:label "Español" :value "es"} - {:label "Русский" :value "ru"}] + [:& fm/select {:options (d/concat [{:label "Auto (browser)" :value ""}] + i18n/supported-locales) :label (t locale "dashboard.select-ui-language") - :default "en" + :default "" :name :lang}]] [:h2 (t locale "dashboard.theme-change")] diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index a548f179ce..66c1ac4a73 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -22,11 +22,56 @@ :dashed "10,10" nil)) +(defn- truncate-side + [shape ra-attr rb-attr dimension-attr] + (let [ra (ra-attr shape) + rb (rb-attr shape) + dimension (dimension-attr shape)] + (if (<= (+ ra rb) dimension) + [ra rb] + [(/ (* ra dimension) (+ ra rb)) + (/ (* rb dimension) (+ ra rb))]))) + +(defn- truncate-radius + [shape] + (let [[r-top-left r-top-right] + (truncate-side shape :r1 :r2 :width) + + [r-right-top r-right-bottom] + (truncate-side shape :r2 :r3 :height) + + [r-bottom-right r-bottom-left] + (truncate-side shape :r3 :r4 :width) + + [r-left-bottom r-left-top] + (truncate-side shape :r4 :r1 :height)] + + [(min r-top-left r-left-top) + (min r-top-right r-right-top) + (min r-right-bottom r-bottom-right) + (min r-bottom-left r-left-bottom)])) + (defn add-border-radius [attrs shape] - (if (or (:rx shape) (:ry shape)) - (obj/merge! attrs #js {:rx (:rx shape) - :ry (:ry shape)}) - attrs)) + (if (or (:r1 shape) (:r2 shape) (:r3 shape) (:r4 shape)) + (let [[r1 r2 r3 r4] (truncate-radius shape) + top (- (:width shape) r1 r2) + right (- (:height shape) r2 r3) + bottom (- (:width shape) r3 r4) + left (- (:height shape) r4 r1)] + (obj/merge! attrs #js {:d (str "M" (+ (:x shape) r1) "," (:y shape) " " + "h" top " " + "a" r2 "," r2 " 0 0 1 " r2 "," r2 " " + "v" right " " + "a" r3 "," r3 " 0 0 1 " (- r3) "," r3 " " + "h" (- bottom) " " + "a" r4 "," r4 " 0 0 1 " (- r4) "," (- r4) " " + "v" (- left) " " + "a" r1 "," r1 " 0 0 1 " r1 "," (- r1) " " + "z")})) + (if (or (:rx shape) (:ry shape)) + (obj/merge! attrs #js {:rx (:rx shape) + :ry (:ry shape)}) + attrs))) (defn add-fill [attrs shape render-id] (let [fill-color-gradient-id (str "fill-color-gradient_" render-id)] diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 72badd7ad3..f530dc1692 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -124,33 +124,36 @@ (defn get-filters-bounds [shape filters blur-value] - (if (and (= :svg-raw (:type shape)) - (not= :svg (get-in shape [:content :tag]))) + (let [svg-root? (and (= :svg-raw (:type shape)) (not= :svg (get-in shape [:content :tag]))) + frame? (= :frame (:type shape)) + {:keys [x y width height]} (:selrect shape)] + (if svg-root? + ;; When is a raw-svg but not the root we use the whole svg as bound for the filter. Is the maximum + ;; we're allowed to display + {:x 0 :y 0 :width width :height height} - ;; When is a raw-svg but not the root we use the whole svg as bound for the filter. Is the maximum - ;; we're allowed to display - {:x 0 :y 0 :width (get-in shape [:selrect :width]) :height (get-in shape [:selrect :height])} + ;; Otherwise we calculate the bound + (let [filter-bounds (->> filters + (filter #(= :drop-shadow (:type %))) + (map (partial filter-bounds shape) )) + ;; We add the selrect so the minimum size will be the selrect + filter-bounds (conj filter-bounds (:selrect shape)) + x1 (apply min (map :x1 filter-bounds)) + y1 (apply min (map :y1 filter-bounds)) + x2 (apply max (map :x2 filter-bounds)) + y2 (apply max (map :y2 filter-bounds)) - ;; Otherwise we calculate the bound - (let [filter-bounds (->> filters - (filter #(= :drop-shadow (:type %))) - (map (partial filter-bounds shape) )) - ;; We add the selrect so the minimum size will be the selrect - filter-bounds (conj filter-bounds (:selrect shape)) - x1 (apply min (map :x1 filter-bounds)) - y1 (apply min (map :y1 filter-bounds)) - x2 (apply max (map :x2 filter-bounds)) - y2 (apply max (map :y2 filter-bounds)) + x1 (- x1 (* blur-value 2)) + x2 (+ x2 (* blur-value 2)) + y1 (- y1 (* blur-value 2)) + y2 (+ y2 (* blur-value 2))] - x1 (- x1 (* blur-value 2)) - x2 (+ x2 (* blur-value 2)) - y1 (- y1 (* blur-value 2)) - y2 (+ y2 (* blur-value 2))] - - {:x x1 - :y y1 - :width (- x2 x1) - :height (- y2 y1)}))) + ;; We should move the frame filter coordinates because they should be + ;; relative with the frame. By default they come as absolute + {:x (if frame? (- x1 x) x1) + :y (if frame? (- y1 y) y1) + :width (- x2 x1) + :height (- y2 y1)})))) (defn blur-filters [type value] (->> [value] diff --git a/frontend/src/app/main/ui/shapes/frame.cljs b/frontend/src/app/main/ui/shapes/frame.cljs index 55b39bb1b9..cf5c0d1389 100644 --- a/frontend/src/app/main/ui/shapes/frame.cljs +++ b/frontend/src/app/main/ui/shapes/frame.cljs @@ -32,11 +32,10 @@ #js {:x 0 :y 0 :width width - :height height}))] - [:svg {:x x :y y :width width :height height - :xmlnsXlink "http://www.w3.org/1999/xlink" - :xmlns "http://www.w3.org/2000/svg"} - [:> "rect" props] + :height height + :className "frame-background"}))] + [:* + [:> :rect props] (for [[i item] (d/enumerate childs)] [:& shape-wrapper {:frame shape :shape item diff --git a/frontend/src/app/main/ui/shapes/mask.cljs b/frontend/src/app/main/ui/shapes/mask.cljs index 3f59e17409..fab611fd30 100644 --- a/frontend/src/app/main/ui/shapes/mask.cljs +++ b/frontend/src/app/main/ui/shapes/mask.cljs @@ -32,5 +32,6 @@ :result "comp"}]] [:mask {:id (str (:id mask) "-mask")} [:g {:filter (str/fmt "url(#%s)" (str (:id mask) "-filter"))} - [:& shape-wrapper {:frame frame :shape mask}]]]]))) + [:& shape-wrapper {:frame frame :shape (-> mask + (dissoc :shadow :blur))}]]]]))) diff --git a/frontend/src/app/main/ui/shapes/rect.cljs b/frontend/src/app/main/ui/shapes/rect.cljs index 555bafa5af..ad35561802 100644 --- a/frontend/src/app/main/ui/shapes/rect.cljs +++ b/frontend/src/app/main/ui/shapes/rect.cljs @@ -37,4 +37,7 @@ [:& shape-custom-stroke {:shape shape :base-props props - :elem-name "rect"}])) + :elem-name + (if (.-d props) + "path" + "rect")}])) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index 62760374e6..08953de394 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -27,14 +27,26 @@ filter-id (str "filter_" render-id) styles (cond-> (obj/new) (:blocked shape) (obj/set! "pointerEvents" "none")) + + {:keys [x y width height type]} shape + frame? (= :frame type) group-props (-> (obj/clone props) (obj/without ["shape" "children"]) (obj/set! "id" (str "shape-" (:id shape))) - (obj/set! "className" (str "shape " (:type shape))) (obj/set! "filter" (filters/filter-str filter-id shape)) - (obj/set! "style" styles))] + (obj/set! "style" styles) + + (cond-> frame? + (-> (obj/set! "x" x) + (obj/set! "y" y) + (obj/set! "width" width) + (obj/set! "height" height) + (obj/set! "xmlnsXlink" "http://www.w3.org/1999/xlink") + (obj/set! "xmlns" "http://www.w3.org/2000/svg")))) + + wrapper-tag (if frame? "svg" "g")] [:& (mf/provider muc/render-ctx) {:value render-id} - [:> :g group-props + [:> wrapper-tag group-props [:defs [:& filters/filters {:shape shape :filter-id filter-id}] [:& grad/gradient {:shape shape :attr :fill-color-gradient}] diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 7fb7f80a31..01411bcc3d 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -15,21 +15,21 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as geom] [app.common.pages :as cp] + [app.main.data.comments :as dcm] [app.main.data.viewer :as dv] [app.main.data.viewer.shortcuts :as sc] - [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.comments :as cmt] [app.main.ui.components.fullscreen :as fs] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.viewer.header :refer [header]] [app.main.ui.viewer.shapes :as shapes :refer [frame-svg]] [app.main.ui.viewer.thumbnails :refer [thumbnails-panel]] - [app.main.ui.comments :as cmt] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] + [app.util.keyboard :as kbd] [goog.events :as events] [okulary.core :as l] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index c97025f13f..bb87dce0c2 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -11,6 +11,7 @@ (:require [app.common.math :as mth] [app.common.uuid :as uuid] + [app.config :as cfg] [app.main.data.comments :as dcm] [app.main.data.messages :as dm] [app.main.data.viewer :as dv] @@ -64,9 +65,13 @@ create (st/emitf (dv/create-share-link)) delete (st/emitf (dv/delete-share-link)) - href (.-href js/location) - href (subs href 0 (str/index-of href "?")) - link (str href "?token=" token "&index=0") + router (mf/deref refs/router) + route (mf/deref refs/route) + link (rt/resolve router + :viewer + (:path-params route) + {:token token :index "0"}) + link (str cfg/public-uri "/#" link) copy-link (fn [event] diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index a8eb93cde1..acc321f3bb 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -21,7 +21,6 @@ [app.main.ui.context :as ctx] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.workspace.colorpalette :refer [colorpalette]] [app.main.ui.workspace.colorpicker] [app.main.ui.workspace.context-menu :refer [context-menu]] @@ -32,6 +31,7 @@ [app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]] [app.main.ui.workspace.viewport :refer [viewport viewport-actions coordinates]] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [app.util.object :as obj] [beicon.core :as rx] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/workspace/colorpalette.cljs b/frontend/src/app/main/ui/workspace/colorpalette.cljs index eb26530038..01e3e20129 100644 --- a/frontend/src/app/main/ui/workspace/colorpalette.cljs +++ b/frontend/src/app/main/ui/workspace/colorpalette.cljs @@ -18,10 +18,10 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.color :as uc] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t]] + [app.util.keyboard :as kbd] [app.util.object :as obj] [beicon.core :as rx] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs index fcd72eb284..b622434656 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs @@ -9,16 +9,7 @@ (ns app.main.ui.workspace.colorpicker.pixel-overlay (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [okulary.core :as l] - [promesa.core :as p] - [beicon.core :as rx] - [goog.events :as events] [app.common.uuid :as uuid] - [app.util.timers :as timers] - [app.util.dom :as dom] - [app.util.object :as obj] [app.main.data.colors :as dwc] [app.main.data.fetch :as mdf] [app.main.data.modal :as modal] @@ -26,8 +17,17 @@ [app.main.store :as st] [app.main.ui.context :as muc] [app.main.ui.cursors :as cur] - [app.main.ui.keyboard :as kbd] - [app.main.ui.workspace.shapes :refer [shape-wrapper frame-wrapper]]) + [app.main.ui.workspace.shapes :refer [shape-wrapper frame-wrapper]] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [app.util.timers :as timers] + [beicon.core :as rx] + [cuerdas.core :as str] + [goog.events :as events] + [okulary.core :as l] + [promesa.core :as p] + [rumext.alpha :as mf]) (:import goog.events.EventType)) (defn format-viewbox [vbox] diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 01c1151d0c..7d6c99610b 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -24,6 +24,7 @@ [app.main.ui.icons :as i] [app.util.dom :as dom] [app.util.i18n :refer [t] :as i18n] + [app.util.timers :as timers] [beicon.core :as rx] [okulary.core :as l] [potok.core :as ptk] @@ -53,6 +54,10 @@ {:keys [id] :as shape} (:shape mdata) selected (:selected mdata) + single? (= (count selected) 1) + multiple? (> (count selected) 1) + editable-shape? (#{:group :text :path} (:type shape)) + current-file-id (mf/use-ctx ctx/current-file-id) do-duplicate (st/emitf dw/duplicate-selected) @@ -77,6 +82,9 @@ do-add-component (st/emitf dwl/add-component) do-detach-component (st/emitf (dwl/detach-component id)) do-reset-component (st/emitf (dwl/reset-component id)) + do-start-editing (fn [] + ;; We defer the execution so the mouse event won't close the editor + (timers/schedule #(st/emit! (dw/start-editing-selected)))) do-update-component (st/emitf (dwc/start-undo-transaction) (dwl/update-component id) @@ -99,7 +107,7 @@ :on-accept confirm-update-remote-component})) do-show-component (st/emitf (dw/go-to-layout :assets)) do-navigate-component-file (st/emitf (dwl/nav-to-component-file - (:component-file shape)))] + (:component-file shape)))] [:* [:& menu-entry {:title (t locale "workspace.shape.menu.copy") :shortcut (sc/get-tooltip :copy) @@ -128,7 +136,7 @@ :on-click do-send-to-back}] [:& menu-separator] - (when (> (count selected) 1) + (when multiple? [:* [:& menu-entry {:title (t locale "workspace.shape.menu.group") :shortcut (sc/get-tooltip :group) @@ -138,7 +146,7 @@ :on-click do-mask-group}] [:& menu-separator]]) - (when (>= (count selected) 1) + (when (or single? multiple?) [:* [:& menu-entry {:title (t locale "workspace.shape.menu.flip-vertical") :shortcut (sc/get-tooltip :flip-vertical) @@ -148,7 +156,7 @@ :on-click do-flip-horizontal}] [:& menu-separator]]) - (when (and (= (count selected) 1) (= (:type shape) :group)) + (when (and single? (= (:type shape) :group)) [:* [:& menu-entry {:title (t locale "workspace.shape.menu.ungroup") :shortcut (sc/get-tooltip :ungroup) @@ -161,6 +169,11 @@ :shortcut (sc/get-tooltip :group) :on-click do-mask-group}])]) + (when (and single? editable-shape?) + [:& menu-entry {:title (t locale "workspace.shape.menu.edit") + :shortcut (sc/get-tooltip :start-editing) + :on-click do-start-editing}]) + (if (:hidden shape) [:& menu-entry {:title (t locale "workspace.shape.menu.show") :on-click do-show-shape}] diff --git a/frontend/src/app/main/ui/workspace/effects.cljs b/frontend/src/app/main/ui/workspace/effects.cljs index a0ae1fd3c9..dee7ee88f9 100644 --- a/frontend/src/app/main/ui/workspace/effects.cljs +++ b/frontend/src/app/main/ui/workspace/effects.cljs @@ -9,13 +9,13 @@ (ns app.main.ui.workspace.effects (:require - [rumext.alpha :as mf] - [app.util.dom :as dom] - [app.main.data.workspace.selection :as dws] - [app.main.store :as st] [app.main.data.workspace :as dw] + [app.main.data.workspace.selection :as dws] [app.main.refs :as refs] - [app.main.ui.keyboard :as kbd])) + [app.main.store :as st] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [rumext.alpha :as mf])) (defn use-pointer-enter [{:keys [id]}] @@ -52,11 +52,9 @@ drawing? @refs/selected-drawing-tool button (.-which (.-nativeEvent event)) shift? (kbd/shift? event) - ctrl? (or (kbd/ctrl? event) (kbd/meta? event)) allow-click? (and (not blocked) (not drawing?) - (not ctrl?) (not edition))] (when (and (= button 1) allow-click?) diff --git a/frontend/src/app/main/ui/workspace/header.cljs b/frontend/src/app/main/ui/workspace/header.cljs index e4507a5ecc..53948281fc 100644 --- a/frontend/src/app/main/ui/workspace/header.cljs +++ b/frontend/src/app/main/ui/workspace/header.cljs @@ -19,10 +19,10 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.workspace.presence :refer [active-sessions]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [okulary.core :as l] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/workspace/selection.cljs b/frontend/src/app/main/ui/workspace/selection.cljs index ddfb7b0b10..7ee4ece580 100644 --- a/frontend/src/app/main/ui/workspace/selection.cljs +++ b/frontend/src/app/main/ui/workspace/selection.cljs @@ -10,29 +10,30 @@ (ns app.main.ui.workspace.selection "Selection handlers component." (:require - [beicon.core :as rx] - [cuerdas.core :as str] - [potok.core :as ptk] - [rumext.alpha :as mf] - [rumext.util :refer [map->obj]] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as geom] + [app.common.math :as mth] [app.common.uuid :as uuid] - [app.util.data :as d] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.refs :as refs] [app.main.store :as st] [app.main.streams :as ms] [app.main.ui.cursors :as cur] - [app.common.math :as mth] + [app.main.ui.hooks :as hooks] + [app.main.ui.measurements :as msr] + [app.main.ui.workspace.shapes.outline :refer [outline]] + [app.main.ui.workspace.shapes.path.editor :refer [path-editor]] + [app.util.data :as d] + [app.util.debug :refer [debug?]] [app.util.dom :as dom] [app.util.object :as obj] - [app.common.geom.shapes :as geom] - [app.common.geom.point :as gpt] - [app.common.geom.matrix :as gmt] - [app.util.debug :refer [debug?]] - [app.main.ui.workspace.shapes.outline :refer [outline]] - [app.main.ui.measurements :as msr] - [app.main.ui.workspace.shapes.path.editor :refer [path-editor]])) + [beicon.core :as rx] + [cuerdas.core :as str] + [potok.core :as ptk] + [rumext.alpha :as mf] + [rumext.util :refer [map->obj]])) (def rotation-handler-size 20) (def resize-point-radius 4) @@ -235,19 +236,24 @@ (mf/defc controls {::mf/wrap-props false} [props] - (let [{:keys [overflow-text] :as shape} (obj/get props "shape") + (let [{:keys [overflow-text type] :as shape} (obj/get props "shape") zoom (obj/get props "zoom") color (obj/get props "color") on-resize (obj/get props "on-resize") on-rotate (obj/get props "on-rotate") + disable-handlers (obj/get props "disable-handlers") current-transform (mf/deref refs/current-transform) + hide? (mf/use-state false) selrect (-> (:selrect shape) minimum-selrect) transform (geom/transform-matrix shape {:no-flip true})] + (hooks/use-stream ms/keyboard-ctrl #(when (= type :group) (reset! hide? %))) + (when (not (#{:move :rotate} current-transform)) - [:g.controls + [:g.controls {:style {:display (when @hide? "none")} + :pointer-events (when disable-handlers "none")} ;; Selection rect [:& selection-rect {:rect selrect @@ -290,7 +296,7 @@ :fill "transparent"}}]])) (mf/defc multiple-selection-handlers - [{:keys [shapes selected zoom color show-distances] :as props}] + [{:keys [shapes selected zoom color show-distances disable-handlers] :as props}] (let [shape (geom/setup {:type :rect} (geom/selection-rect (->> shapes (map geom/transform-shape)))) shape-center (geom/center-shape shape) @@ -311,6 +317,7 @@ [:& controls {:shape shape :zoom zoom :color color + :disable-handlers disable-handlers :on-resize on-resize :on-rotate on-rotate}] @@ -324,7 +331,7 @@ [:circle {:cx (:x shape-center) :cy (:y shape-center) :r 5 :fill "yellow"}])])) (mf/defc single-selection-handlers - [{:keys [shape zoom color show-distances] :as props}] + [{:keys [shape zoom color show-distances disable-handlers] :as props}] (let [shape-id (:id shape) shape (geom/transform-shape shape) @@ -349,7 +356,8 @@ :zoom zoom :color color :on-rotate on-rotate - :on-resize on-resize}] + :on-resize on-resize + :disable-handlers disable-handlers}] (when show-distances [:& msr/measurement {:bounds vbox @@ -360,7 +368,7 @@ (mf/defc selection-handlers {::mf/wrap [mf/memo]} - [{:keys [selected edition zoom show-distances] :as props}] + [{:keys [selected edition zoom show-distances disable-handlers] :as props}] (let [;; We need remove posible nil values because on shape ;; deletion many shape will reamin selected and deleted ;; in the same time for small instant of time @@ -381,7 +389,8 @@ :selected selected :zoom zoom :color color - :show-distances show-distances}] + :show-distances show-distances + :disable-handlers disable-handlers}] (and (= type :text) (= edition (:id shape))) @@ -398,4 +407,5 @@ [:& single-selection-handlers {:shape shape :zoom zoom :color color - :show-distances show-distances}]))) + :show-distances show-distances + :disable-handlers disable-handlers}]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index ff0e4ff0bf..679f9af15f 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -14,16 +14,16 @@ [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.keyboard :as kbd] + [app.main.ui.context :as muc] [app.main.ui.shapes.frame :as frame] [app.main.ui.shapes.shape :refer [shape-container]] [app.main.ui.workspace.effects :as we] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [app.util.timers :as ts] [beicon.core :as rx] [okulary.core :as l] - [rumext.alpha :as mf] - [app.main.ui.context :as muc])) + [rumext.alpha :as mf])) (defn use-select-shape [{:keys [id]} edition] (mf/use-callback diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index f3f4cc3c56..bb8ac5c04e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -9,17 +9,18 @@ (ns app.main.ui.workspace.shapes.group (:require + [app.common.geom.shapes :as gsh] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] [app.main.streams :as ms] + [app.main.ui.hooks :as hooks] [app.main.ui.shapes.group :as group] [app.main.ui.shapes.shape :refer [shape-container]] [app.main.ui.workspace.effects :as we] + [app.util.debug :refer [debug?]] [app.util.dom :as dom] - [rumext.alpha :as mf] - [app.common.geom.shapes :as gsh] - [app.util.debug :refer [debug?]])) + [rumext.alpha :as mf])) (defn use-double-click [{:keys [id]}] (mf/use-callback @@ -40,8 +41,10 @@ frame (unchecked-get props "frame") {:keys [id x y width height]} shape - transform (gsh/transform-matrix shape) + transform (gsh/transform-matrix shape) + + ctrl? (mf/use-state false) childs-ref (mf/use-memo (mf/deps shape) #(refs/objects-by-id (:shapes shape))) childs (mf/deref childs-ref) @@ -59,33 +62,38 @@ is-mask-selected? (mf/deref is-mask-selected-ref) + expand-mask? is-child-selected? + group-interactions? (not (or @ctrl? is-child-selected?)) + handle-mouse-down (we/use-mouse-down shape) handle-context-menu (we/use-context-menu shape) handle-pointer-enter (we/use-pointer-enter shape) handle-pointer-leave (we/use-pointer-leave shape) handle-double-click (use-double-click shape)] + (hooks/use-stream ms/keyboard-ctrl #(reset! ctrl? %)) + [:> shape-container {:shape shape} [:g.group-shape [:& group-shape {:frame frame :shape shape :childs childs - :expand-mask is-mask-selected? - :pointer-events (when (not is-child-selected?) "none")}] + :expand-mask expand-mask? + :pointer-events (when group-interactions? "none")}] - (when-not is-child-selected? - [:rect.group-actions - {:x x - :y y - :fill (if (debug? :group) "red" "transparent") - :opacity 0.5 - :transform transform - :width width - :height height - :on-mouse-down handle-mouse-down - :on-context-menu handle-context-menu - :on-pointer-over handle-pointer-enter - :on-pointer-out handle-pointer-leave - :on-double-click handle-double-click}])]])))) + [:rect.group-actions + {:x x + :y y + :width width + :height height + :transform transform + :style {:pointer-events (when-not group-interactions? "none") + :fill (if (debug? :group) "red" "transparent") + :opacity 0.5} + :on-mouse-down handle-mouse-down + :on-context-menu handle-context-menu + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave + :on-double-click handle-double-click}]]])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/interactions.cljs b/frontend/src/app/main/ui/workspace/shapes/interactions.cljs index d4a8684fea..469a636fc2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/interactions.cljs @@ -10,17 +10,17 @@ (ns app.main.ui.workspace.shapes.interactions "Visually show shape interactions in workspace" (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.util.data :as dt] - [app.util.dom :as dom] [app.common.geom.point :as gpt] [app.common.geom.shapes :as geom] - [app.main.store :as st] - [app.main.refs :as refs] [app.main.data.workspace :as dw] - [app.main.ui.keyboard :as kbd] - [app.main.ui.workspace.shapes.outline :refer [outline]])) + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.workspace.shapes.outline :refer [outline]] + [app.util.data :as dt] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] + [rumext.alpha :as mf])) (defn- get-click-interaction [shape] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 961d800e2d..1e40979c94 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -182,11 +182,9 @@ (and self (.contains self target)) (and cpicker (.contains cpicker target)) (and palette (.contains palette target))) - (do - - (if selecting? - (mf/set-ref-val! selecting-ref false) - (on-close)))))) + (if selecting? + (mf/set-ref-val! selecting-ref false) + (on-close))))) on-mouse-down (fn [event] diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 52e519ce6b..ed043bf1cd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -26,17 +26,17 @@ [app.main.store :as st] [app.main.ui.components.color-bullet :as bc] [app.main.ui.components.context-menu :refer [context-menu]] + [app.main.ui.components.editable-label :refer [editable-label]] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.tab-container :refer [tab-container tab-element]] - [app.main.ui.components.editable-label :refer [editable-label]] [app.main.ui.context :as ctx] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.workspace.sidebar.options.typography :refer [typography-entry]] [app.util.data :refer [matches-search]] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.i18n :as i18n :refer [tr t]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [app.util.text :as ut] [app.util.timers :as timers] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 83e776c7c4..fc16303963 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -18,10 +18,9 @@ [app.main.store :as st] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t]] + [app.util.keyboard :as kbd] [app.util.object :as obj] [app.util.perf :as perf] [app.util.timers :as ts] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs index 1288fe3e3b..5c7440920b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs @@ -133,13 +133,25 @@ (def +size-presets+ [{:name "APPLE"} - {:name "iPhone X" + {:name "iPhone 12/12 Pro" + :width 390 + :height 844} + {:name "iPhone 12 Mini" + :width 360 + :height 780} + {:name "iPhone 12 Pro Max" + :width 428 + :height 926} + {:name "iPhone X/XS/11 Pro" :width 375 :height 812} + {:name "iPhone XS Max/XR/11/11 Pro Max" + :width 414 + :height 896} {:name "iPhone 6/7/8 Plus" :width 414 :height 736} - {:name "iPhone 6/7/8" + {:name "iPhone 6/7/8/SE2" :width 375 :height 667} {:name "iPhone 5/SE" @@ -154,26 +166,41 @@ {:name "iPad Pro 12.9in" :width 1024 :height 1366} + {:name "Watch 44mm" + :width 368 + :height 448} {:name "Watch 42mm" :width 312 :height 390} + {:name "Watch 40mm" + :width 324 + :height 394} {:name "Watch 38mm" :width 272 :height 340} - {:name "GOOGLE"} - {:name "Android mobile" + {:name "ANDROID"} + {:name "Mobile" :width 360 :height 640} - {:name "Android tablet" + {:name "Tablet" :width 768 :height 1024} + {:name "Google Pixel 4a/5" + :width 393 + :height 851} + {:name "Samsung Galaxy S20+/S21 Ultra" + :width 384 + :height 854} + {:name "Samsung Galaxy A71/A51" + :width 412 + :height 914} {:name "MICROSOFT"} {:name "Surface Pro 3" :width 1440 :height 960} - {:name "Surface Pro 4" + {:name "Surface Pro 4/5/6/7" :width 1368 :height 912} @@ -184,9 +211,88 @@ {:name "Web 1366" :width 1366 :height 768} + {:name "Web 1024" + :width 1024 + :height 768} {:name "Web 1920" :width 1920 :height 1080} + + {:name "PRINT (72dpi)"} + {:name "A0" + :width 2384 + :height 3370} + {:name "A1" + :width 1684 + :height 2384} + {:name "A2" + :width 1191 + :height 1684} + {:name "A3" + :width 842 + :height 1191} + {:name "A4" + :width 595 + :height 842} + {:name "A5" + :width 420 + :height 595} + {:name "A6" + :width 297 + :height 420} + {:name "Letter" + :width 612 + :height 792} + {:name "DIN Lang" + :width 595 + :height 281} + + {:name "SOCIAL MEDIA"} + {:name "Instagram profile" + :width 320 + :height 320} + {:name "Instagram post" + :width 1080 + :height 1080} + {:name "Instagram story" + :width 1080 + :height 1920} + {:name "Facebook profile" + :width 720 + :height 720} + {:name "Facebook cover" + :width 820 + :height 312} + {:name "Facebook post" + :width 1200 + :height 630} + {:name "LinkedIn profile" + :width 400 + :height 400} + {:name "LinkedIn cover" + :width 1584 + :height 396} + {:name "LinkedIn post" + :width 1200 + :height 627} + {:name "Twitter profile" + :width 400 + :height 400} + {:name "Twitter header" + :width 1500 + :height 500} + {:name "Twitter post" + :width 1024 + :height 512} + {:name "Youtube profile" + :width 800 + :height 800} + {:name "Youtube banner" + :width 2560 + :height 1440} + {:name "Youtube thumb" + :width 1280 + :height 720} ]) (mf/defc options diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs index 2361965064..ec870bd6d8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs @@ -24,7 +24,13 @@ [app.common.math :as math] [app.util.i18n :refer [t] :as i18n])) -(def measure-attrs [:proportion-lock :width :height :x :y :rotation :rx :ry :selrect]) +(def measure-attrs [:proportion-lock + :width :height + :x :y + :rotation + :rx :ry + :r1 :r2 :r3 :r4 + :selrect]) (defn- attr->string [attr values] (let [value (attr values)] @@ -93,20 +99,70 @@ (fn [value] (st/emit! (udw/increase-rotation ids value)))) - on-radius-change + on-switch-to-radius-1 (mf/use-callback (mf/deps ids) (fn [value] (let [radius-update (fn [shape] (cond-> shape - (:rx shape) (assoc :rx value :ry value)))] + (:r1 shape) + (-> (assoc :rx 0 :ry 0) + (dissoc :r1 :r2 :r3 :r4))))] + (st/emit! (dwc/update-shapes ids-with-children radius-update))))) + + on-switch-to-radius-4 + (mf/use-callback + (mf/deps ids) + (fn [value] + (let [radius-update + (fn [shape] + (cond-> shape + (:rx shape) + (-> (assoc :r1 0 :r2 0 :r3 0 :r4 0) + (dissoc :rx :ry))))] + (st/emit! (dwc/update-shapes ids-with-children radius-update))))) + + on-radius-1-change + (mf/use-callback + (mf/deps ids) + (fn [value] + (let [radius-update + (fn [shape] + (cond-> shape + (:r1 shape) + (-> (dissoc :r1 :r2 :r3 :r4) + (assoc :rx 0 :ry 0)) + + (or (:rx shape) (:r1 shape)) + (assoc :rx value :ry value)))] + + (st/emit! (dwc/update-shapes ids-with-children radius-update))))) + + on-radius-4-change + (mf/use-callback + (mf/deps ids) + (fn [value attr] + (let [radius-update + (fn [shape] + (cond-> shape + (:rx shape) + (-> (dissoc :rx :rx) + (assoc :r1 0 :r2 0 :r3 0 :r4 0)) + + (attr shape) + (assoc attr value)))] + (st/emit! (dwc/update-shapes ids-with-children radius-update))))) on-width-change #(on-size-change % :width) on-height-change #(on-size-change % :height) on-pos-x-change #(on-position-change % :x) on-pos-y-change #(on-position-change % :y) + on-radius-r1-change #(on-radius-4-change % :r1) + on-radius-r2-change #(on-radius-4-change % :r2) + on-radius-r3-change #(on-radius-4-change % :r3) + on-radius-r4-change #(on-radius-4-change % :r4) select-all #(-> % (dom/get-target) (.select))] [:div.element-set @@ -181,14 +237,61 @@ :value (attr->string :rotation values)}]]) ;; RADIUS - (when (and (options :radius) (not (nil? (:rx values)))) - [:div.row-flex - [:span.element-set-subtitle (t locale "workspace.options.radius")] - [:div.input-element.pixels - [:> numeric-input - {:placeholder "--" - :min 0 - :on-click select-all - :on-change on-radius-change - :value (attr->string :rx values)}]] - [:div.input-element]])]])) + (let [radius-1? (some? (:rx values)) + radius-4? (some? (:r1 values))] + (when (and (options :radius) (or radius-1? radius-4?)) + [:div.row-flex + [:div.radius-options + [:div.radius-icon.tooltip.tooltip-bottom + {:class (classnames + :selected + (and radius-1? (not radius-4?))) + :alt (t locale "workspace.options.radius.all-corners") + :on-click on-switch-to-radius-1} + i/radius-1] + [:div.radius-icon.tooltip.tooltip-bottom + {:class (classnames + :selected + (and radius-4? (not radius-1?))) + :alt (t locale "workspace.options.radius.single-corners") + :on-click on-switch-to-radius-4} + i/radius-4]] + (if radius-1? + [:div.input-element.mini + [:> numeric-input + {:placeholder "--" + :min 0 + :on-click select-all + :on-change on-radius-1-change + :value (attr->string :rx values)}]] + + [:* + [:div.input-element.mini + [:> numeric-input + {:placeholder "--" + :min 0 + :on-click select-all + :on-change on-radius-r1-change + :value (attr->string :r1 values)}]] + [:div.input-element.mini + [:> numeric-input + {:placeholder "--" + :min 0 + :on-click select-all + :on-change on-radius-r2-change + :value (attr->string :r2 values)}]] + [:div.input-element.mini + [:> numeric-input + {:placeholder "--" + :min 0 + :on-click select-all + :on-change on-radius-r3-change + :value (attr->string :r3 values)}]] + [:div.input-element.mini + [:> numeric-input + {:placeholder "--" + :min 0 + :on-click select-all + :on-change on-radius-r4-change + :value (attr->string :r4 values)}]]]) + ]))]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs index a9ec6087ff..6f4632aac3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs @@ -32,14 +32,14 @@ (mf/defc font-select-optgroups {::mf/wrap [mf/memo]} - [] + [{:keys [locale] :as props}] [:* - [:optgroup {:label "Local"} + [:optgroup {:label (t locale "workspace.options.text-options.preset")} (for [font fonts/local-fonts] [:option {:value (:id font) :key (:id font)} (:name font)])] - [:optgroup {:label "Google"} + [:optgroup {:label (t locale "workspace.options.text-options.google")} (for [font (fonts/resolve-fonts :google)] [:option {:value (:id font) :key (:id font)} @@ -97,7 +97,7 @@ :on-change on-font-family-change} (when (= font-id :multiple) [:option {:value ""} (t locale "settings.multiple")]) - [:& font-select-optgroups]]] + [:& font-select-optgroups {:locale locale}]]] [:div.row-flex (let [size-options [8 9 10 11 12 14 18 24 36 48 72] diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index bb28e672bf..5b2d8eb52e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -9,18 +9,18 @@ (ns app.main.ui.workspace.sidebar.sitemap (:require - [app.main.ui.components.context-menu :refer [context-menu]] [app.common.data :as d] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.context-menu :refer [context-menu]] [app.main.ui.context :as ctx] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] [app.util.router :as rt] [cuerdas.core :as str] [okulary.core :as l] diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 0d7f2d6afd..a8c27ee338 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -29,7 +29,6 @@ [app.main.ui.cursors :as cur] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] [app.main.ui.workspace.colorpicker.pixel-overlay :refer [pixel-overlay]] [app.main.ui.workspace.comments :refer [comments-layer]] [app.main.ui.workspace.drawarea :refer [draw-area]] @@ -46,6 +45,7 @@ [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.http :as http] + [app.util.keyboard :as kbd] [app.util.object :as obj] [app.util.perf :as perf] [app.util.timers :as timers] @@ -57,7 +57,8 @@ [promesa.core :as p] [rumext.alpha :as mf]) (:import goog.events.EventType - goog.events.WheelEvent)) + goog.events.WheelEvent + goog.events.KeyCodes)) (defonce css-mouse? (cfg/check-browser? :firefox)) @@ -219,11 +220,17 @@ (not (:blocked shape)) (not= edition (:id shape)) (outline? (:id shape)))) - shapes (->> (vals objects) (filter show-outline?)) + + remove-groups? (mf/use-state false) + + shapes (cond->> (vals objects) + show-outline? (filter show-outline?) + @remove-groups? (remove #(= :group (:type %)))) transform (mf/deref refs/current-transform) color (if (or (> (count shapes) 1) (nil? (:shape-ref (first shapes)))) "#31EFB8" "#00E0FF")] + (hooks/use-stream ms/keyboard-ctrl #(reset! remove-groups? %)) (when (nil? transform) [:g.outlines (for [shape shapes] @@ -424,16 +431,16 @@ (mf/use-callback (fn [event] (let [target (dom/get-target event)] - ; Capture mouse pointer to detect the movements even if cursor - ; leaves the viewport or the browser itself - ; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + ; Capture mouse pointer to detect the movements even if cursor + ; leaves the viewport or the browser itself + ; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture (.setPointerCapture target (.-pointerId event))))) on-pointer-up (mf/use-callback (fn [event] (let [target (dom/get-target event)] - ; Release pointer on mouse up + ; Release pointer on mouse up (.releasePointerCapture target (.-pointerId event))))) on-click @@ -442,9 +449,7 @@ (let [ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event)] - (if ctrl? - (st/emit! (dw/select-last-layer @ms/mouse-position)) - (st/emit! (ms/->MouseEvent :click ctrl? shift? alt?)))))) + (st/emit! (ms/->MouseEvent :click ctrl? shift? alt?))))) on-double-click (mf/use-callback @@ -460,14 +465,16 @@ (mf/use-callback (fn [event] (let [bevent (.getBrowserEvent ^js event) - key (.-keyCode ^js event) - ctrl? (kbd/ctrl? event) + key (.-keyCode ^js event) + key (.normalizeKeyCode KeyCodes key) + ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) - alt? (kbd/alt? event) + alt? (kbd/alt? event) + meta? (kbd/meta? event) target (dom/get-target event)] (when-not (.-repeat bevent) - (st/emit! (ms/->KeyboardEvent :down key ctrl? shift? alt?)) + (st/emit! (ms/->KeyboardEvent :down key shift? ctrl? alt? meta?)) (when (and (kbd/space? event) (not= "rich-text" (obj/get target "className")) (not= "INPUT" (obj/get target "tagName")) @@ -477,13 +484,15 @@ on-key-up (mf/use-callback (fn [event] - (let [key (.-keyCode event) - ctrl? (kbd/ctrl? event) + (let [key (.-keyCode event) + key (.normalizeKeyCode KeyCodes key) + ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) - alt? (kbd/alt? event)] + alt? (kbd/alt? event) + meta? (kbd/meta? event)] (when (kbd/space? event) (st/emit! dw/finish-pan ::finish-positioning)) - (st/emit! (ms/->KeyboardEvent :up key ctrl? shift? alt?))))) + (st/emit! (ms/->KeyboardEvent :up key shift? ctrl? alt? meta?))))) translate-point-to-viewport (mf/use-callback @@ -785,7 +794,8 @@ [:& selection-handlers {:selected selected :zoom zoom :edition edition - :show-distances (and (not transform) @alt?)}]) + :show-distances (and (not transform) @alt?) + :disable-handlers (or drawing-tool edition)}]) (when (= (count selected) 1) [:& gradient-handlers {:id (first selected) diff --git a/frontend/src/app/util/code_gen.cljs b/frontend/src/app/util/code_gen.cljs index 54dc6ed78a..86e0bd8f5e 100644 --- a/frontend/src/app/util/code_gen.cljs +++ b/frontend/src/app/util/code_gen.cljs @@ -39,9 +39,15 @@ (str/format "%spx %s %s" width style (uc/color->background color))))) (def styles-data - {:layout {:props [:width :height :x :y :radius :rx] - :to-prop {:x "left" :y "top" :rotation "transform" :rx "border-radius"} - :format {:rotation #(str/fmt "rotate(%sdeg)" %)}} + {:layout {:props [:width :height :x :y :radius :rx :r1] + :to-prop {:x "left" + :y "top" + :rotation "transform" + :rx "border-radius" + :r1 "border-radius"} + :format {:rotation #(str/fmt "rotate(%sdeg)" %) + :r1 #(apply str/fmt "%spx, %spx, %spx, %spx" %)} + :multi {:r1 [:r1 :r2 :r3 :r4]}} :fill {:props [:fill-color :fill-color-gradient] :to-prop {:fill-color "background" :fill-color-gradient "background"} :format {:fill-color format-fill-color :fill-color-gradient format-fill-color}} @@ -74,13 +80,14 @@ :text-transform name :fill-color format-fill-color}}) - (defn generate-css-props ([values properties] (generate-css-props values properties nil)) ([values properties params] - (let [{:keys [to-prop format tab-size] :or {to-prop {} tab-size 0}} params + (let [{:keys [to-prop format tab-size multi] + :or {to-prop {} tab-size 0 multi {}}} params + ;; We allow the :format and :to-prop to be a map for different properties ;; or just a value for a single property. This code transform a single ;; property to a uniform one @@ -94,19 +101,28 @@ (into {} (map #(vector % to-prop) properties)) to-prop) + get-value (fn [prop] + (if-let [props (prop multi)] + (map #(get values %) props) + (get values prop))) + + null? (fn [value] + (if (coll? value) + (every? #(or (nil? %) (= % 0)) value) + (or (nil? value) (= value 0)))) + default-format (fn [value] (str (mth/precision value 2) "px")) format-property (fn [prop] (let [css-prop (or (prop to-prop) (name prop)) format-fn (or (prop format) default-format) - css-val (format-fn (prop values) values)] + css-val (format-fn (get-value prop) values)] (when css-val (str (str/repeat " " tab-size) (str/fmt "%s: %s;" css-prop css-val)))))] (->> properties - (remove #(let [value (get values %)] - (or (nil? value) (= value 0)))) + (remove #(null? (get-value %))) (map format-property) (filter (comp not nil?)) (str/join "\n"))))) @@ -114,9 +130,11 @@ (defn shape->properties [shape] (let [props (->> styles-data vals (mapcat :props)) to-prop (->> styles-data vals (map :to-prop) (reduce merge)) - format (->> styles-data vals (map :format) (reduce merge))] + format (->> styles-data vals (map :format) (reduce merge)) + multi (->> styles-data vals (map :multi) (reduce merge))] (generate-css-props shape props {:to-prop to-prop :format format + :multi multi :tab-size 2}))) (defn text->properties [shape] (let [text-shape-style (select-keys styles-data [:layout :shadow :blur]) @@ -149,7 +167,7 @@ properties (if (= :text (:type shape)) (text->properties shape) (shape->properties shape)) - + selector (str/css-selector name) selector (if (str/starts-with? selector "-") (subs selector 1) selector)] (str/join "\n" [(str/fmt "/* %s */" name) diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 989132af6a..cd8774a82d 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -2,8 +2,10 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2015-2016 Juan de la Cruz -;; Copyright (c) 2015-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.i18n "A i18n foundation." @@ -17,9 +19,40 @@ [app.util.storage :refer [storage]] [app.util.transit :as t])) -(defonce locale (l/atom (or (get storage ::locale) - cfg/default-language))) +(def supported-locales + [{:label "English" :value "en"} + {:label "Español" :value "es"} + {:label "Français (community)" :value "fr"} + {:label "Русский (community)" :value "ru"} + {:label "简体中文 (community)" :value "zh_cn"}]) + +(defn- parse-locale + [locale] + (let [locale (-> (.-language js/navigator) + (str/lower) + (str/replace "-" "_"))] + (cond-> [locale] + (str/includes? locale "_") + (conj (subs locale 0 2))))) + +(def ^:private browser-locales + (delay + (-> (.-language js/navigator) + (parse-locale)))) + +(defn- autodetect + [] + (let [supported (into #{} (map :value supported-locales))] + (loop [locales (seq @browser-locales)] + (if-let [locale (first locales)] + (if (contains? supported locale) + locale + (recur (rest locales))) + cfg/default-language)))) + (defonce translations #js {}) +(defonce locale (l/atom (or (get storage ::locale) + (autodetect)))) ;; The traslations `data` is a javascript object and should be treated ;; with `goog.object` namespace functions instead of a standart @@ -31,14 +64,21 @@ [data] (set! translations data)) -(defn set-current-locale! - [v] - (swap! storage assoc ::locale v) - (reset! locale v)) +(defn set-locale! + [lang] + (if lang + (do + (swap! storage assoc ::locale lang) + (reset! locale lang)) + (do + (reset! locale (autodetect))))) -(defn set-default-locale! +(defn reset-locale + "Set the current locale to the browser detected one if it is + supported or default locale if not." [] - (set-current-locale! cfg/default-language)) + (swap! storage dissoc ::locale) + (reset! locale (autodetect))) (deftype C [val] IDeref diff --git a/frontend/src/app/main/ui/keyboard.cljs b/frontend/src/app/util/keyboard.cljs similarity index 52% rename from frontend/src/app/main/ui/keyboard.cljs rename to frontend/src/app/util/keyboard.cljs index 93e49a6d58..3aa7681d9b 100644 --- a/frontend/src/app/main/ui/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -1,4 +1,13 @@ -(ns app.main.ui.keyboard) +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.util.keyboard) (defn is-keycode? [keycode]