refactor(lang-lint): leverage logseq-i18n-lint for superior accuracy and performance

This commit is contained in:
Mega Yu
2026-04-15 16:00:39 +08:00
parent 69ee9e2f83
commit 0ceda82d33
11 changed files with 464 additions and 892 deletions

View File

@@ -89,7 +89,7 @@
language English. Logseq needs to work out of the box with its default
language. This catches mistakes where another language has accidentally typoed
keys or added ones without updating :en"
[{:keys [fix?]}]
[fix?]
(let [dicts (get-dicts)
;; For now defined as :en but clj-kondo analysis could be more thorough
valid-keys (set (keys (dicts :en)))
@@ -111,173 +111,41 @@
(println "These invalid translation keys have been removed from non-default dictionaries."))
(System/exit 1)))))
(def ^:private direct-translation-call-source-paths
["src/main" "src/electron"])
(def ^:private i18n-lint-launcher-path
(fs/absolutize "bin/logseq-i18n-lint"))
(def ^:private translated-code-source-paths
["deps" "src/main" "src/electron"])
(def ^:private i18n-lint-config-path
(fs/absolutize ".i18n-lint.toml"))
(def ^:private shortcut-config-path
"src/main/frontend/modules/shortcut/config.cljs")
;; Matches literal `(t :ns/key)` and `(tt :ns/key)` calls, including alias-qualified
;; forms like `(i18n/t :ns/key)`.
(def ^:private direct-translation-call-rg-pattern
"[(](?:[[:alnum:]._-]+/)?tt?[[:space:]]+:[^ )]+")
;; Matches literal `:i18n-key :ns/key` entries, including values wrapped onto the
;; next line when `rg` runs in multiline mode.
(def ^:private i18n-key-rg-pattern
":i18n-key[[:space:]]+:[^ }\n]+")
;; Matches files containing dynamic translation-call `if`/`or` forms, `i18n-key`
;; `if` forms, literal `:prompt-key`/`:title-key` options, `built-in-colors`,
;; `navs` vectors, date NLP labels, built-in property/class definitions, or
;; `:shortcut.category/*` keys, or supported dynamic `keyword` i18n patterns for
;; later exact extraction.
(def ^:private derived-ui-key-candidate-rg-pattern
"(?:[(](?:[[:alnum:]._-]+/)?tt?[[:space:]]+[(](?:if|or)\\b|:?i18n-key[[:space:]]+[(]if\\b|:prompt-key[[:space:]]+:|:title-key[[:space:]]+:|[(]def[[:space:]]+built-in-colors\\b|\\bnavs[[:space:]]+\\[|[(]def[[:space:]]+nlp-pages\\b|[(]def[[:space:]]+\\^:large-vars/data-var[[:space:]]+built-in-(?:properties|classes)\\b|:shortcut\\.category/|[(]keyword[[:space:]]+\"flashcard\\.rating\")")
(def ^:private built-in-db-ident-candidate-rg-pattern
"[(]def[[:space:]]+\\^:large-vars/data-var[[:space:]]+built-in-(?:properties|classes)\\b")
(defn- extract-keyword-match
[value]
(some-> (last (re-seq #":[^ )}\s]+" value))
(subs 1)
keyword))
(defn- rg-output-lines
[paths pattern & {:keys [multiline?]}]
(let [args (concat ["rg"]
(when multiline? ["-U"])
["--no-filename" "-o" "-N" pattern]
paths)]
(->> (apply shell {:out :string :continue true} args)
:out
string/split-lines
(remove string/blank?))))
(defn- rg-matching-files
[paths pattern]
(let [args (concat ["rg" "-l"
"--glob" "*.clj"
"--glob" "*.cljs"
"--glob" "*.cljc"
pattern]
paths)]
(->> (apply shell {:out :string :continue true} args)
:out
string/split-lines
(remove string/blank?))))
(defn- grep-direct-translation-keys
"Grep source paths for literal `(t :ns/key)` and `(tt :ns/key)` calls."
(defn- ensure-i18n-lint-ready!
[]
(->> (rg-output-lines direct-translation-call-source-paths direct-translation-call-rg-pattern)
(keep extract-keyword-match)
set))
(when-not (fs/exists? i18n-lint-launcher-path)
(println "logseq-i18n-lint launcher not found at" (str i18n-lint-launcher-path))
(System/exit 1))
(when-not (fs/exists? i18n-lint-config-path)
(println "i18n lint config not found at" (str i18n-lint-config-path))
(System/exit 1)))
(defn- grep-i18n-payload-keys
"Grep translated code paths for `:i18n-key` payload entries, including cases
where the translation key is wrapped onto the next line."
[]
(->> (rg-output-lines translated-code-source-paths i18n-key-rg-pattern :multiline? true)
(keep extract-keyword-match)
set))
(defn- run-i18n-lint-command!
[subcommand cli-args]
(ensure-i18n-lint-ready!)
(let [cmd (into ["bash"
(str i18n-lint-launcher-path)
"-c"
(str i18n-lint-config-path)
subcommand]
cli-args)
result (apply shell {:continue true
:out :inherit
:err :inherit}
cmd)]
(when (pos? (:exit result))
(System/exit (:exit result)))))
(defn- grep-derived-translation-keys
"Scan candidate source files for translation keys derived from supported
dynamic patterns such as `if`/`or` translation calls, option keys, built-in
colors, left-sidebar derived nav labels, and shortcut category labels."
[]
(->> (rg-matching-files translated-code-source-paths derived-ui-key-candidate-rg-pattern)
(mapcat #(lang-lint/derived-translation-keys (slurp %)))
set))
(defn- grep-built-in-db-ident-translation-keys
"Derive built-in property/class translation keys from built-in db-ident
definition forms."
[]
(->> (rg-matching-files translated-code-source-paths built-in-db-ident-candidate-rg-pattern)
(mapcat #(lang-lint/built-in-db-ident-translation-keys (slurp %)))
set))
(defn- grep-shortcut-command-keys
"Derive `:command.*` translation keys from shortcut ids declared in the
built-in shortcut config."
[]
(lang-lint/shortcut-command-keys (slurp shortcut-config-path)))
(def ^:private config-deprecation-detailed-keys
[:editor/command-trigger
:arweave/gateway
:preferred-format
:property-pages/enabled?
:block-hidden-properties
:feature/enable-block-timestamps?
:favorites
:default-templates])
(defn- config-key->deprecation-i18n-key
[config-key]
(let [ns-str (namespace config-key)
clean-name (string/replace (name config-key) #"\?$" "")
leaf (if ns-str
(str ns-str "-" clean-name)
clean-name)]
(keyword "graph.validation" (str "config-" leaf "-warning"))))
(defn- grep-config-deprecation-translation-keys
"Derive `:graph.validation/*` deprecation keys from deprecated config keys."
[]
(conj (->> config-deprecation-detailed-keys
(map config-key->deprecation-i18n-key)
set)
:graph.validation/config-unused-in-db-graphs-warning))
(defn- delete-not-used-key-from-dict-file
[invalid-keys]
(let [paths (fs/list-dir "src/resources/dicts")]
(doseq [path paths]
(let [result (rewrite/parse-string (String. (fs/read-all-bytes path)))
new-content (str (reduce
(fn [result k]
(rewrite/dissoc result k))
result invalid-keys))]
(spit (fs/file path) new-content)))))
(defn- validate-ui-translations-are-used
"This validation checks that translation keys referenced from frontend, Electron,
shortcut config, and translated validation payloads all exist in the default
:en dictionary, and that unused keys in :en can be detected."
[{:keys [fix?]}]
(let [defined-translation-keys (set (keys (:en (get-dicts))))
built-in-defined-translation-keys (->> (grep-built-in-db-ident-translation-keys)
(set/intersection defined-translation-keys))
referenced-translation-keys (->> [(grep-direct-translation-keys)
(grep-i18n-payload-keys)
(grep-derived-translation-keys)
built-in-defined-translation-keys
(grep-config-deprecation-translation-keys)
(grep-shortcut-command-keys)]
(apply concat)
set)
undefined-references (set/difference referenced-translation-keys defined-translation-keys)
unreferenced-definitions (set/difference defined-translation-keys referenced-translation-keys)]
(if (and (empty? undefined-references) (empty? unreferenced-definitions))
(println "All defined :en translation keys match the ones that are used!")
(do
(when (seq undefined-references)
(println "\nThese translation keys are invalid because they are referenced in translated code paths but not defined:")
(task-util/print-table (map #(hash-map :invalid-key %) undefined-references)))
(when (seq unreferenced-definitions)
(println "\nThese translation keys are invalid because they are defined but not referenced in translated code paths:")
(task-util/print-table (map #(hash-map :invalid-key %) unreferenced-definitions))
(when fix?
(delete-not-used-key-from-dict-file unreferenced-definitions)
(println "These unreferenced translation keys have been removed.")))
(System/exit 1)))))
(defn- check-translation-keys
"Use logseq-i18n-lint to detect unused translation keys."
[args]
(run-i18n-lint-command! "check-keys" args))
(defn- validate-rich-translations
"Checks that localized rich translations remain rich zero-arg functions.
@@ -309,133 +177,14 @@
(defn validate-translations
"Runs multiple translation validations that fail fast if one of them is invalid"
[& args]
(validate-non-default-languages {:fix? (contains? (set args) "--fix")})
(validate-ui-translations-are-used {:fix? (contains? (set args) "--fix")})
(validate-non-default-languages (contains? (set args) "--fix"))
(check-translation-keys args)
(validate-rich-translations)
(validate-translation-placeholders))
(def ^:private hardcoded-default-paths
["src/main/frontend"
"src/main/mobile"
"src/electron"
"deps"
"packages/ui"])
(def ^:private hardcoded-allowed-extensions
#{"clj" "cljs" "cljc" "ts" "tsx"})
(def ^:private hardcoded-ignored-segments
["/test/" "/tests/" "/dev/" "/node_modules/" "/target/" "/static/" "/cljs-test-runner-out/"])
;; These files are outside the product UI translation surface even though they
;; contain readable strings:
;; - component demos/playgrounds under `deps/shui`
;; - MCP tool metadata under the CLI implementation
;; - internal command definitions used only for worker-side matching
;; - language autonyms curated outside the translation dictionaries
(def ^:private hardcoded-ignored-path-patterns
[#"^deps/shui/src/logseq/shui/demo\d*\.cljs$"
#"^deps/cli/src/logseq/cli/common/mcp/"
#"^deps/publish/"
#"^deps/db/src/logseq/db/frontend/class\.cljs$"
#"^deps/db/src/logseq/db/frontend/property\.cljs$"
#"^src/main/frontend/worker/commands\.cljs$"
#"^src/main/frontend/dicts\.cljc$"])
(def ^:private hardcoded-ignored-dirs
#{"test" "tests" "dev" "node_modules" "target" "static"})
(defn- normalize-path
[path]
(-> (str path)
(string/replace "\\" "/")
(string/replace-first #"^\./" "")))
(defn- hardcoded-lint-file?
[path]
(let [normalized (normalize-path path)]
(and (hardcoded-allowed-extensions (fs/extension normalized))
(not-any? #(string/includes? normalized %) hardcoded-ignored-segments)
(not-any? #(re-find % normalized) hardcoded-ignored-path-patterns))))
(defn- ignored-dir?
"Return true if the directory should be skipped during lint traversal."
[^java.io.File f]
(let [n (.getName f)]
(or (string/starts-with? n ".")
(hardcoded-ignored-dirs n))))
(defn- directory-files
[path]
(let [root (fs/file path)
acc (volatile! [])]
(letfn [(walk [^java.io.File f]
(if (.isDirectory f)
(when-not (ignored-dir? f)
(run! walk (.listFiles f)))
(vswap! acc conj (str f))))]
(walk root))
@acc))
(defn- collect-files
[paths]
(->> paths
(map normalize-path)
(filter fs/exists?)
(mapcat (fn [path]
(if (fs/directory? path)
(directory-files path)
[path])))
(map normalize-path)
(filter hardcoded-lint-file?)
distinct
sort))
(defn- changed-files-from-git-status
[]
(->> (shell {:out :string :continue true}
"git" "status" "--porcelain" "--untracked-files=all")
:out
string/split-lines
(map #(subs % 3))
(map #(if (string/includes? % " -> ")
(last (string/split % #" -> "))
%))
(map normalize-path)
(filter seq)))
(defn- lint-findings
[paths]
(->> paths
(mapcat #(lang-lint/hardcoded-string-findings % (slurp %)))
(sort-by (juxt :file :line :kind))
vec))
(defn lint-hardcoded
"Lint likely hardcoded user-facing strings in UI-oriented source files.
Use --warn-only to report findings without failing and --changed-only to scan
only files changed in git status. Optional positional args limit the scan to
specific files or directories."
"Run logseq-i18n-lint to lint likely hardcoded user-facing strings in UI-oriented source files.
Use -w or --warn-only to report findings without failing and -g or --git-changed to scan
only files changed in git status."
[& args]
(let [arg-set (set args)
warn-only? (contains? arg-set "--warn-only")
changed-only? (contains? arg-set "--changed-only")
explicit-paths (remove #(#{"--warn-only" "--changed-only"} %) args)
paths (cond
(seq explicit-paths) (collect-files explicit-paths)
changed-only? (collect-files (changed-files-from-git-status))
:else (collect-files hardcoded-default-paths))
findings (lint-findings paths)]
(cond
(empty? paths)
(println "No files matched the hardcoded-string lint scope.")
(empty? findings)
(println "No hardcoded user-facing string literals found in the selected files.")
:else
(do
(println "Potential hardcoded user-facing strings:")
(task-util/print-table findings)
(when-not warn-only?
(System/exit 1))))))
(run-i18n-lint-command! "lint" args))

View File

@@ -1,150 +1,9 @@
(ns logseq.tasks.lang-lint
(:require [clojure.string :as string]))
;; Matches `notification/show!` calls whose first argument is a literal message.
(def ^:private notification-pattern
#"notification/show!\s+\"([^\"\n]+)\"")
;; Matches supported user-facing Hiccup attributes with literal string values.
(def ^:private user-facing-attr-pattern
#"(?<!\[):(placeholder|title|aria-label|label|alt)\s+\"([^\"\n]+)\"")
;; Matches literal text children in supported Hiccup tags, including tag
;; shorthand such as `:div.foo` or `:button#id`.
(def ^:private hiccup-text-pattern
#"\[:(?:button|span|div|label|a|p|small|strong|li|h1|h2|h3|h4|h5|h6)(?:[#.][^\"\s\[\{]+)*\s+\"([A-Za-z][^\"\n]*)\"")
(ns logseq.tasks.lang-lint)
;; Matches numbered placeholders like `{1}` in translation strings.
(def ^:private translation-placeholder-pattern
#"\{(\d+)\}")
;; Matches keyword literals embedded in source text.
(def ^:private translation-key-pattern
#":[^ )}\],\s]+")
;; Matches translation calls whose first argument starts with an `if` form.
(def ^:private translation-call-if-prefix-pattern
#"\((?:[[:alnum:]._-]+/)?tt?\s+\(if\b")
;; Matches translation calls whose first argument starts with an `or` form.
(def ^:private translation-call-or-prefix-pattern
#"\((?:[[:alnum:]._-]+/)?tt?\s+\(or\b")
;; Matches literal `i18n-key` assignments backed by an `if` form.
(def ^:private i18n-key-if-prefix-pattern
#":?i18n-key\s+\(if\b")
;; Matches the `built-in-colors` vector body for later string extraction.
(def ^:private built-in-colors-pattern
#"(?s)\(def\s+built-in-colors\s+\[(.*?)\]\)")
;; Matches quoted string literals inside extracted list or vector content.
(def ^:private string-literal-pattern
#"\"([^\"]+)\"")
;; Matches the left sidebar `navs` vector body for derived key extraction.
(def ^:private left-sidebar-navs-pattern
#"(?s)\bnavs\s+\[(.*?)\]")
;; Matches the `nlp-pages` vector body for derived date NLP translation keys.
(def ^:private date-nlp-pages-pattern
#"(?s)\(def\s+nlp-pages\s+\[(.*?)\]\)")
;; Matches built-in property/class definition forms for db-ident derived keys.
(def ^:private built-in-properties-form-pattern
#"\(def\s+\^:large-vars/data-var\s+built-in-properties\b")
(def ^:private built-in-classes-form-pattern
#"\(def\s+\^:large-vars/data-var\s+built-in-classes\b")
;; Matches `:tag/*` entries inside left sidebar nav definitions.
(def ^:private tag-nav-key-pattern
#":tag/([A-Za-z][A-Za-z0-9._-]*)")
;; Matches namespaced built-in shortcut ids declared as map keys.
(def ^:private shortcut-command-id-pattern
#"(?m)^\s*\{?:([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)\s+\{")
;; Built-in card review shortcuts are local-only actions whose visible labels
;; come from flashcard UI state instead of `:command.*` descriptions.
(def ^:private shortcut-command-translation-exemptions
#{:cards/again
:cards/easy
:cards/good
:cards/hard
:cards/toggle-answers})
;; Matches literal shortcut category translation keys.
(def ^:private shortcut-category-key-pattern
#":shortcut\.category/[A-Za-z0-9._-]+")
;; Matches FSRS flashcard rating translation keys derived with
;; `(keyword "flashcard.rating" ...)`.
(def ^:private flashcard-rating-label-keyword-pattern
#"\(keyword\s+\"flashcard\.rating\"\s+\(name\s+[^\)]+\)\)")
(def ^:private flashcard-rating-desc-keyword-pattern
#"\(keyword\s+\"flashcard\.rating\"\s+\(str\s+\(name\s+[^\)]+\)\s+\"-desc\"\)\)")
;; Matches the start of `(comment ...)` forms.
(def ^:private comment-form-prefix-pattern
#"\(comment\b")
;; Lists literal strings that are intentionally excluded from hardcoded UI findings.
(def ^:private ignored-hardcoded-texts
#{"Logseq"
"Logseq "
"ID: "
"http://"
"https://"
"{}"
"Ag"
"git commit -m ..."})
(defn- make-finding
[kind file-path line text]
{:kind kind
:file file-path
:line line
:text text})
(defn- ignorable-hardcoded-text?
[text]
(contains? ignored-hardcoded-texts text))
(defn- line-findings
[file-path line-number line commented-lines]
(if (or (commented-lines line-number)
(string/starts-with? (string/triml line) ";"))
[]
(let [notification-findings
(for [[_ text] (re-seq notification-pattern line)]
(when-not (ignorable-hardcoded-text? text)
(make-finding :notification file-path line-number text)))
attr-findings
(for [[_ attr text] (re-seq user-facing-attr-pattern line)]
(when-not (ignorable-hardcoded-text? text)
(make-finding (keyword attr) file-path line-number text)))
hiccup-findings
(for [[_ text] (re-seq hiccup-text-pattern line)]
(when-not (ignorable-hardcoded-text? text)
(make-finding :hiccup-text file-path line-number text)))]
(into [] (remove nil? (concat notification-findings attr-findings hiccup-findings))))))
(declare comment-form-lines)
(defn hardcoded-string-findings
"Return user-facing hardcoded string findings for `content`.
Each finding contains `:kind`, `:file`, `:line`, and `:text`."
[file-path content]
(let [commented-lines (comment-form-lines content)]
(->> (string/split-lines content)
(map-indexed (fn [index line]
(line-findings file-path (inc index) line commented-lines)))
(apply concat)
vec)))
(defn translation-placeholders
"Return the placeholder indexes referenced by translation string `value`.
@@ -156,239 +15,6 @@
set)
#{}))
(defn- keyword-literals
([value] (keyword-literals value #{}))
([value excluded-keys]
(->> (re-seq translation-key-pattern value)
(map #(keyword (subs % 1)))
(remove excluded-keys)
set)))
(defn- keyword-literals-in-order
[value]
(->> (re-seq translation-key-pattern value)
(map #(keyword (subs % 1)))))
(defn- conditional-branch-keys
[value]
(->> (keyword-literals-in-order value)
(remove #{:i18n-key})
(take-last 2)
set))
(defn- extract-balanced-list-form
[content start-index]
(loop [index start-index
depth 0
in-string? false
escape? false]
(when (< index (count content))
(let [ch (.charAt content index)
next-escape? (and in-string? (= ch \\) (not escape?))
next-in-string? (if (and (= ch \") (not escape?))
(not in-string?)
in-string?)
next-depth (cond
next-in-string? depth
(= ch \() (inc depth)
(= ch \)) (dec depth)
:else depth)
end? (and (= ch \)) (not in-string?) (= next-depth 0))]
(if end?
(subs content start-index (inc index))
(recur (inc index) next-depth next-in-string? next-escape?))))))
(defn- matched-list-form-matches
[pattern list-head content]
(loop [forms []
search-start 0]
(let [remaining (subs content search-start)
match (re-find pattern remaining)]
(if match
(let [match-start (+ search-start (string/index-of remaining match))
list-offset (string/index-of match list-head)
list-start (+ match-start list-offset)
form (extract-balanced-list-form content list-start)]
(recur (cond-> forms
form (conj {:start list-start
:form form}))
(inc match-start)))
forms))))
(defn- matched-list-forms
[pattern list-head content]
(mapv :form (matched-list-form-matches pattern list-head content)))
(defn- comment-form-lines
[content]
(->> (matched-list-form-matches comment-form-prefix-pattern "(comment" content)
(mapcat (fn [{:keys [start form]}]
(let [start-line (inc (count (re-seq #"\n" (subs content 0 start))))
line-count (count (string/split-lines form))]
(range start-line (+ start-line line-count)))))
set))
(defn conditional-translation-keys
"Return translation keys referenced by supported `(if ...)` forms.
This covers direct translation calls like `(t (if ...))` and `:i18n-key`
payload bindings whose then/else branches both resolve to translation keys."
[content]
(->> [translation-call-if-prefix-pattern i18n-key-if-prefix-pattern]
(mapcat #(matched-list-forms % "(if" content))
(mapcat conditional-branch-keys)
set))
(defn translation-call-fallback-keys
"Return fallback translation keys referenced by supported `(t (or ...))`
forms."
[content]
(->> (matched-list-forms translation-call-or-prefix-pattern "(or" content)
(mapcat #(take-last 1 (keyword-literals-in-order %)))
set))
(defn option-translation-keys
"Return literal translation keys assigned to option key `option-key`.
Example option keys include `:prompt-key` and `:title-key`."
[content option-key]
(let [pattern (re-pattern (str ":" (name option-key) "\\s+:[^ )}\\],\\s]+"))]
(->> (re-seq pattern content)
(mapcat #(keyword-literals % #{option-key}))
set)))
(defn built-in-color-keys
"Return `:color/*` translation keys derived from `built-in-colors`."
[content]
(if-let [[_ colors-content] (re-find built-in-colors-pattern content)]
(->> (re-seq string-literal-pattern colors-content)
(map second)
(map #(keyword "color" %))
set)
#{}))
(defn left-sidebar-translation-keys
"Return left sidebar navigation translation keys derived from tag nav entries
in the left sidebar `navs` vector."
[content]
(if-let [[_ navs-content] (re-find left-sidebar-navs-pattern content)]
(->> (re-seq tag-nav-key-pattern navs-content)
(map second)
(map #(keyword "nav" %))
set)
#{}))
(defn date-nlp-translation-keys
"Return `:date.nlp/*` translation keys derived from `nlp-pages`."
[content]
(if-let [[_ nlp-pages-content] (re-find date-nlp-pages-pattern content)]
(->> (re-seq string-literal-pattern nlp-pages-content)
(map second)
(map #(keyword "date.nlp"
(-> %
string/lower-case
(string/replace " " "-"))))
set)
#{}))
(defn- built-in-db-ident->i18n-key
[db-ident]
(let [ns-str (namespace db-ident)
n (name db-ident)]
(cond
(= ns-str "logseq.class")
(keyword "class.built-in" (string/lower-case n))
(or (= ns-str "logseq.property")
(and (string? ns-str)
(string/starts-with? ns-str "logseq.property.")))
(let [sub-ns (when (not= ns-str "logseq.property")
(subs ns-str (count "logseq.property.")))
dot-idx (string/index-of n ".")
clean-n (string/replace n #"\?$" "")]
(if dot-idx
(let [prop-part (subs clean-n 0 dot-idx)
choice-part (subs clean-n (inc dot-idx))
subdomain (if sub-ns (str sub-ns "-" prop-part) prop-part)]
(keyword (str "property." subdomain) choice-part))
(if sub-ns
(keyword "property.built-in" (str sub-ns "-" clean-n))
(keyword "property.built-in" clean-n))))
(= ns-str "block")
(keyword "property.built-in" (string/replace n #"\?$" ""))
:else nil)))
(defn built-in-db-ident-translation-keys
"Return translation keys derived from built-in db-ident keyword literals."
[content]
(->> [(matched-list-forms built-in-properties-form-pattern "(def" content)
(matched-list-forms built-in-classes-form-pattern "(def" content)]
(apply concat)
(mapcat keyword-literals)
(keep built-in-db-ident->i18n-key)
set))
(defn shortcut-command-keys
"Return `:command.*` translation keys derived from built-in shortcut ids.
Shortcut handler ids like `:shortcut.handler/*` are ignored because they are
config group keys, not user-visible command ids.
The five built-in `:cards/*` review shortcuts are also ignored because their
visible labels come from flashcard UI state, not `:command.*`
descriptions."
[content]
(->> (re-seq shortcut-command-id-pattern content)
(map (fn [[_ shortcut-ns shortcut-name]]
(keyword shortcut-ns shortcut-name)))
(remove (fn [shortcut-id]
(or (string/starts-with? (namespace shortcut-id) "shortcut.handler")
(contains? shortcut-command-translation-exemptions shortcut-id))))
(map (fn [shortcut-id]
(keyword (str "command." (namespace shortcut-id))
(name shortcut-id))))
set))
(defn shortcut-category-translation-keys
"Return `:shortcut.category/*` translation keys referenced in shortcut UI
category declarations."
[content]
(->> (re-seq shortcut-category-key-pattern content)
(map #(keyword (subs % 1)))
set))
(defn flashcard-rating-translation-keys
"Return `:flashcard.rating/*` keys derived from supported FSRS dynamic
keyword construction."
[content]
(let [ratings [:again :hard :good :easy]]
(into #{}
(concat
(when (re-find flashcard-rating-label-keyword-pattern content)
(map #(keyword "flashcard.rating" (name %)) ratings))
(when (re-find flashcard-rating-desc-keyword-pattern content)
(map #(keyword "flashcard.rating" (str (name %) "-desc")) ratings))))))
(defn derived-translation-keys
"Return translation keys derived from supported non-literal UI patterns.
This combines conditional calls, fallback calls, option keys, built-in color
labels, left-sidebar derived nav labels, and shortcut category labels."
[content]
(->> [(conditional-translation-keys content)
(translation-call-fallback-keys content)
(option-translation-keys content :prompt-key)
(option-translation-keys content :title-key)
(built-in-color-keys content)
(left-sidebar-translation-keys content)
(date-nlp-translation-keys content)
(shortcut-category-translation-keys content)
(flashcard-rating-translation-keys content)]
(apply concat)
set))
(defn- placeholders-compatible?
[default-value localized-value]
(or (not (string? default-value))