diff --git a/.i18n-lint.toml b/.i18n-lint.toml
index 96ba168c86..a1bb609afb 100644
--- a/.i18n-lint.toml
+++ b/.i18n-lint.toml
@@ -29,6 +29,8 @@ i18n_functions = [
"tt",
"i18n/t",
"i18n/tt",
+ # Parameterized translation function.
+ "t-fn",
]
# Alert/notification functions.
diff --git a/package.json b/package.json
index 2916d83f13..a227ffdc89 100644
--- a/package.json
+++ b/package.json
@@ -169,6 +169,7 @@
"react-transition-group": "4.4.5",
"react-virtuoso": "4.18.3",
"remove-accents": "0.5.0",
+ "tiny-pinyin": "1.3.2",
"sanitize-filename": "1.6.4",
"send-intent": "^7.0.0",
"url": "^0.11.4",
diff --git a/src/main/frontend/commands.cljs b/src/main/frontend/commands.cljs
index 9412f29994..a9ff344e99 100644
--- a/src/main/frontend/commands.cljs
+++ b/src/main/frontend/commands.cljs
@@ -1,7 +1,7 @@
(ns frontend.commands
"Provides functionality for commands and advanced commands"
(:require [clojure.string :as string]
- [frontend.context.i18n :refer [interpolate-sentence t]]
+ [frontend.context.i18n :as i18n :refer [interpolate-sentence t t-en]]
[frontend.date :as date]
[frontend.db :as db]
[frontend.extensions.video.youtube :as youtube]
@@ -148,42 +148,44 @@
[:editor/set-property :logseq.property.node/display-type :math]])
(defn get-statuses
- []
- (let [group-label (t :editor.slash/group-task-status)
- result (->>
- (db-based-statuses)
- (mapv (fn [status]
- (let [command (:block/title status)
- label (db-property/built-in-display-title status t)
- icon (case command
- "Canceled" "Cancelled"
- "Doing" "InProgress50"
- command)]
- [label (->marker command) (t :editor.slash/status-desc label) icon]))))]
- (when (seq result)
- (map (fn [v] (conj v group-label)) result))))
+ ([] (get-statuses t))
+ ([t-fn]
+ (let [group-label (t-fn :editor.slash/group-task-status)
+ result (->>
+ (db-based-statuses)
+ (mapv (fn [status]
+ (let [command (:block/title status)
+ label (db-property/built-in-display-title status t-fn)
+ icon (case command
+ "Canceled" "Cancelled"
+ "Doing" "InProgress50"
+ command)]
+ [label (->marker command) (t-fn :editor.slash/status-desc label) icon]))))]
+ (when (seq result)
+ (map (fn [v] (conj v group-label)) result)))))
(defn db-based-priorities
[]
(db-pu/get-closed-property-values :logseq.property/priority))
(defn get-priorities
- []
- (let [group-label (t :editor.slash/group-priority)
- with-no-priority #(cons [(t :editor.slash/no-priority) (->priority nil) "" :icon/priorityLvlNone] %)
- result (->>
- (db-based-priorities)
- (mapv (fn [priority]
- (let [value (:block/title priority)
- label (db-property/built-in-display-title priority t)]
- [(t :editor.slash/priority-label label)
- (->priority value)
- (t :editor.slash/priority-desc label)
- (str "priorityLvl" value)])))
- (with-no-priority)
- (vec))]
- (when (seq result)
- (map (fn [v] (into v [group-label])) result))))
+ ([] (get-priorities t))
+ ([t-fn]
+ (let [group-label (t-fn :editor.slash/group-priority)
+ with-no-priority #(cons [(t-fn :editor.slash/no-priority) (->priority nil) "" :icon/priorityLvlNone] %)
+ result (->>
+ (db-based-priorities)
+ (mapv (fn [priority]
+ (let [value (:block/title priority)
+ label (db-property/built-in-display-title priority t-fn)]
+ [(t-fn :editor.slash/priority-label label)
+ (->priority value)
+ (t-fn :editor.slash/priority-desc label)
+ (str "priorityLvl" value)])))
+ (with-no-priority)
+ (vec))]
+ (when (seq result)
+ (map (fn [v] (into v [group-label])) result)))))
;; Credits to roamresearch.com
@@ -194,158 +196,188 @@
[:editor/move-cursor-to-end]])
(defn- headings
- []
- (into [[(t :editor.slash/normal-text)
- (->heading nil)
- (t :editor.slash/normal-text-desc)
- :icon/text
- (t :editor.slash/group-heading)]]
- (mapv (fn [level]
- (let [heading (t :editor.slash/heading-label level)]
- [heading (->heading level) heading (str "h-" level) (t :editor.slash/group-heading)]))
- (range 1 7))))
+ ([] (headings t))
+ ([t-fn]
+ (into [[(t-fn :editor.slash/normal-text)
+ (->heading nil)
+ (t-fn :editor.slash/normal-text-desc)
+ :icon/text
+ (t-fn :editor.slash/group-heading)]]
+ (mapv (fn [level]
+ (let [heading (t-fn :editor.slash/heading-label level)]
+ [heading (->heading level) heading (str "h-" level) (t-fn :editor.slash/group-heading)]))
+ (range 1 7)))))
(defonce *latest-matched-command (atom ""))
(defonce *matched-commands (atom nil))
(defonce *initial-commands (atom nil))
(defn ^:large-vars/cleanup-todo commands-map
- [get-page-ref-text]
- (let [embed-block db-based-embed-block]
- (->>
- (concat
- ;; basic
- [[(t :editor.slash/node-reference)
- [[:editor/input page-ref/left-and-right-brackets {:backward-pos 2}]
- [:editor/search-page]]
- (t :editor.slash/node-reference-desc)
- :icon/pageRef
- (t :editor.slash/group-basic)]
- [(t :editor.slash/node-embed)
- (embed-block)
- (t :editor.slash/node-embed-desc)
- :icon/blockEmbed]]
+ ([get-page-ref-text] (commands-map get-page-ref-text t))
+ ([get-page-ref-text t-fn]
+ (let [embed-block db-based-embed-block]
+ (->>
+ (concat
+ ;; basic
+ [[(t-fn :editor.slash/node-reference)
+ [[:editor/input page-ref/left-and-right-brackets {:backward-pos 2}]
+ [:editor/search-page]]
+ (t-fn :editor.slash/node-reference-desc)
+ :icon/pageRef
+ (t-fn :editor.slash/group-basic)]
+ [(t-fn :editor.slash/node-embed)
+ (embed-block)
+ (t-fn :editor.slash/node-embed-desc)
+ :icon/blockEmbed]]
- ;; format
- [[(t :ui/link) (link-steps) (t :editor.slash/link-desc) :icon/link (t :editor.slash/group-format)]
- [(t :editor.slash/image-link) (image-link-steps) (t :editor.slash/image-link-desc) :icon/photoLink]
- (when (state/markdown?)
- [(t :editor.slash/underline) [[:editor/input ""
- {:last-pattern command-trigger
- :backward-pos 6}]] (t :editor.slash/underline-desc)
- :icon/underline])
- [(t :editor.slash/code-block)
- (code-block-steps)
- (t :editor.slash/code-block-desc)
- :icon/code]
- [(t :class.built-in/quote-block)
- (quote-block-steps)
- (t :editor.slash/quote-desc)
- :icon/quote]
- [(t :editor.slash/math-block)
- (math-block-steps)
- (t :editor.slash/math-block-desc)
- :icon/math]]
+ ;; format
+ [[(t-fn :ui/link) (link-steps) (t-fn :editor.slash/link-desc) :icon/link (t-fn :editor.slash/group-format)]
+ [(t-fn :editor.slash/image-link) (image-link-steps) (t-fn :editor.slash/image-link-desc) :icon/photoLink]
+ (when (state/markdown?)
+ [(t-fn :editor.slash/underline)
+ [[:editor/input "" {:last-pattern command-trigger :backward-pos 6}]]
+ (t-fn :editor.slash/underline-desc)
+ :icon/underline])
+ [(t-fn :editor.slash/code-block)
+ (code-block-steps)
+ (t-fn :editor.slash/code-block-desc)
+ :icon/code]
+ [(t-fn :class.built-in/quote-block)
+ (quote-block-steps)
+ (t-fn :editor.slash/quote-desc)
+ :icon/quote]
+ [(t-fn :editor.slash/math-block)
+ (math-block-steps)
+ (t-fn :editor.slash/math-block-desc)
+ :icon/math]]
- (headings)
+ (headings t-fn)
- ;; task management
- (get-statuses)
+ ;; task management
+ (get-statuses t-fn)
- ;; task date
- [[(t :property.built-in/deadline)
- [[:editor/clear-current-slash]
- [:editor/set-deadline]]
- ""
- :icon/calendar-stats
- (t :editor.slash/group-task-date)]
- [(t :property.built-in/scheduled)
- [[:editor/clear-current-slash]
- [:editor/set-scheduled]]
- ""
- :icon/calendar-month
- (t :editor.slash/group-task-date)]]
+ ;; task date
+ [[(t-fn :property.built-in/deadline)
+ [[:editor/clear-current-slash]
+ [:editor/set-deadline]]
+ ""
+ :icon/calendar-stats
+ (t-fn :editor.slash/group-task-date)]
+ [(t-fn :property.built-in/scheduled)
+ [[:editor/clear-current-slash]
+ [:editor/set-scheduled]]
+ ""
+ :icon/calendar-month
+ (t-fn :editor.slash/group-task-date)]]
- ;; priority
- (get-priorities)
+ ;; priority
+ (get-priorities t-fn)
- ;; time & date
- [[(t :date.nlp/tomorrow)
- #(get-page-ref-text (db/get-journal-page-title (date/tomorrow)))
- (t :editor.slash/tomorrow-desc)
- :icon/tomorrow
- (t :editor.slash/group-time-and-date)]
- [(t :date.nlp/yesterday) #(get-page-ref-text (db/get-journal-page-title (date/yesterday))) (t :editor.slash/yesterday-desc) :icon/yesterday]
- [(t :date.nlp/today) #(get-page-ref-text (db/get-today-journal-title)) (t :editor.slash/today-desc) :icon/calendar]
- [(t :editor.slash/current-time) #(date/get-current-time) (t :editor.slash/current-time-desc) :icon/clock]
- [(t :editor.slash/date-picker) [[:editor/show-date-picker]] (t :editor.slash/date-picker-desc) :icon/calendar-dots]]
+ ;; time & date
+ [[(t-fn :date.nlp/tomorrow)
+ #(get-page-ref-text (db/get-journal-page-title (date/tomorrow)))
+ (t-fn :editor.slash/tomorrow-desc)
+ :icon/tomorrow
+ (t-fn :editor.slash/group-time-and-date)]
+ [(t-fn :date.nlp/yesterday)
+ #(get-page-ref-text (db/get-journal-page-title (date/yesterday)))
+ (t-fn :editor.slash/yesterday-desc)
+ :icon/yesterday]
+ [(t-fn :date.nlp/today)
+ #(get-page-ref-text (db/get-today-journal-title))
+ (t-fn :editor.slash/today-desc)
+ :icon/calendar]
+ [(t-fn :editor.slash/current-time)
+ #(date/get-current-time)
+ (t-fn :editor.slash/current-time-desc)
+ :icon/clock]
+ [(t-fn :editor.slash/date-picker)
+ [[:editor/show-date-picker]]
+ (t-fn :editor.slash/date-picker-desc)
+ :icon/calendar-dots]]
- ;; order list
- [[(t :editor.slash/number-list)
- [[:editor/clear-current-slash]
- [:editor/toggle-own-number-list]]
- (t :editor.slash/number-list)
- :icon/numberedParents
- (t :editor.slash/group-list-type)]
- [(t :editor.slash/number-children) [[:editor/clear-current-slash]
- [:editor/toggle-children-number-list]]
- (t :editor.slash/number-children)
- :icon/numberedChildren]]
+ ;; order list
+ [[(t-fn :editor.slash/number-list)
+ [[:editor/clear-current-slash]
+ [:editor/toggle-own-number-list]]
+ (t-fn :editor.slash/number-list)
+ :icon/numberedParents
+ (t-fn :editor.slash/group-list-type)]
+ [(t-fn :editor.slash/number-children)
+ [[:editor/clear-current-slash]
+ [:editor/toggle-children-number-list]]
+ (t-fn :editor.slash/number-children)
+ :icon/numberedChildren]]
- ;; advanced
- [[(t :property.built-in/query) (query-steps) (query-doc) :icon/query (t :editor.slash/group-advanced)]
- [(t :editor.slash/advanced-query) (advanced-query-steps) (t :editor.slash/advanced-query-desc) :icon/query]
- [(t :editor.slash/query-function) [[:editor/input "{{function }}" {:backward-pos 2}]] (t :editor.slash/query-function-desc) :icon/queryCode]
- [(t :editor.slash/calculator)
- (calc-steps)
- (t :editor.slash/calculator-desc) :icon/calculator]
+ ;; advanced
+ [[(t-fn :property.built-in/query) (query-steps) (query-doc) :icon/query (t-fn :editor.slash/group-advanced)]
+ [(t-fn :editor.slash/advanced-query) (advanced-query-steps) (t-fn :editor.slash/advanced-query-desc) :icon/query]
+ [(t-fn :editor.slash/query-function)
+ [[:editor/input "{{function }}" {:backward-pos 2}]]
+ (t-fn :editor.slash/query-function-desc)
+ :icon/queryCode]
+ [(t-fn :editor.slash/calculator)
+ (calc-steps)
+ (t-fn :editor.slash/calculator-desc)
+ :icon/calculator]
+ [(t-fn :editor.slash/upload-asset)
+ [[:editor/click-hidden-file-input :id]]
+ (t-fn :editor.slash/upload-asset-desc)
+ :icon/upload]
+ [(t-fn :class.built-in/template)
+ [[:editor/input command-trigger nil]
+ [:editor/search-template]]
+ (t-fn :editor.slash/template-desc)
+ :icon/template]
+ [(t-fn :editor.slash/embed-html) (->inline "html") "" :icon/htmlEmbed]
+ [(t-fn :editor.slash/embed-video-url)
+ [[:editor/input "{{video }}" {:last-pattern command-trigger :backward-pos 2}]]
+ ""
+ :icon/videoEmbed]
+ [(t-fn :editor.slash/embed-youtube-timestamp) [[:youtube/insert-timestamp]] "" :icon/videoEmbed]
+ [(t-fn :editor.slash/embed-twitter-tweet)
+ [[:editor/input "{{tweet }}" {:last-pattern command-trigger :backward-pos 2}]]
+ ""
+ :icon/xEmbed]
+ [(t-fn :command.editor/add-property)
+ [[:editor/clear-current-slash]
+ [:editor/new-property]]
+ ""
+ :icon/cube-plus]]
- [(t :editor.slash/upload-asset)
- [[:editor/click-hidden-file-input :id]]
- (t :editor.slash/upload-asset-desc)
- :icon/upload]
-
- [(t :class.built-in/template) [[:editor/input command-trigger nil]
- [:editor/search-template]] (t :editor.slash/template-desc)
- :icon/template]
-
- [(t :editor.slash/embed-html) (->inline "html") "" :icon/htmlEmbed]
-
- [(t :editor.slash/embed-video-url) [[:editor/input "{{video }}" {:last-pattern command-trigger
- :backward-pos 2}]] ""
- :icon/videoEmbed]
-
- [(t :editor.slash/embed-youtube-timestamp) [[:youtube/insert-timestamp]] "" :icon/videoEmbed]
-
- [(t :editor.slash/embed-twitter-tweet) [[:editor/input "{{tweet }}" {:last-pattern command-trigger
- :backward-pos 2}]] ""
- :icon/xEmbed]
-
- [(t :command.editor/add-property) [[:editor/clear-current-slash]
- [:editor/new-property]] ""
- :icon/cube-plus]]
-
- (let [commands (->> @*extend-slash-commands
- (map resolve-slash-command)
- (remove (fn [command] (when (map? (last command))
- (false? (:db-graph? (last command)))))))]
- commands)
+ (let [commands (->> @*extend-slash-commands
+ (map resolve-slash-command)
+ (remove (fn [command]
+ (when (map? (last command))
+ (false? (:db-graph? (last command)))))))]
+ commands)
;; Allow user to modify or extend, should specify how to extend.
- (state/get-commands)
- (when-let [plugin-commands (seq (some->> (state/get-plugins-slash-commands)
- (mapv #(vec (concat % [nil :icon/puzzle])))))]
- (-> plugin-commands (vec) (update 0 (fn [v] (conj v (t :editor.slash/group-plugins)))))))
- (remove nil?)
- (util/distinct-by-last-wins first))))
+ (state/get-commands)
+ (when-let [plugin-commands (seq (some->> (state/get-plugins-slash-commands)
+ (mapv #(vec (concat % [nil :icon/puzzle])))))]
+ (-> plugin-commands
+ (vec)
+ (update 0 (fn [v] (conj v (t-fn :editor.slash/group-plugins)))))))
+ (remove nil?)
+ (util/distinct-by-last-wins first)))))
(defn init-commands!
[get-page-ref-text]
- (let [commands (commands-map get-page-ref-text)]
+ (let [commands (commands-map get-page-ref-text)
+ en-commands (commands-map get-page-ref-text t-en)
+ lang (or (some-> (:preferred-language @state/state) keyword) :en)
+ zh-cn? (= lang :zh-CN)
+ commands-with-meta
+ (mapv (fn [cmd en-cmd]
+ (let [m (cond-> {:en-text (first en-cmd)}
+ zh-cn? (assoc :pinyin-text (search/hanzi->initials (first cmd))))]
+ (with-meta cmd m)))
+ commands en-commands)]
(reset! *latest-matched-command "")
- (reset! *initial-commands commands)
- (reset! *matched-commands commands)))
+ (reset! *initial-commands commands-with-meta)
+ (reset! *matched-commands commands-with-meta)))
(defn set-matched-commands!
[command matched-commands]
@@ -513,9 +545,21 @@
([text]
(get-matched-commands text @*initial-commands))
([text commands]
- (search/fuzzy-search commands text
- :extract-fn first
- :limit 50)))
+ (let [lang (or (some-> (:preferred-language @state/state) keyword) :en)
+ en? (= lang :en)
+ zh-cn? (= lang :zh-CN)
+ extract-fns (cond
+ en? [first]
+ zh-cn? [first
+ #(-> % meta :en-text)
+ #(-> % meta :pinyin-text)]
+ :else [first
+ #(-> % meta :en-text)])]
+ (search/fuzzy-search-multi
+ commands
+ text
+ {:extract-fns extract-fns
+ :limit 50}))))
(defmulti handle-step first)
diff --git a/src/main/frontend/common/search_fuzzy.cljs b/src/main/frontend/common/search_fuzzy.cljs
index d9f5de6875..8c465010b1 100644
--- a/src/main/frontend/common/search_fuzzy.cljs
+++ b/src/main/frontend/common/search_fuzzy.cljs
@@ -1,6 +1,7 @@
(ns frontend.common.search-fuzzy
"fuzzy search. Used by frontend and worker namespaces"
(:require ["remove-accents" :as removeAccents]
+ ["tiny-pinyin" :as tp]
[cljs-bean.core :as bean]
[clojure.string :as string]))
@@ -88,3 +89,84 @@
{:data item
:score (score query s)})))))
(map :data)))
+
+(defn fuzzy-search-multi
+ "Like fuzzy-search but scores each item against multiple extract-fns,
+ taking the best score. Extract-fns that return nil or blank strings are
+ skipped — no score is computed for those fields."
+ [data query & {:keys [limit extract-fns]
+ :or {limit 20}}]
+ (->> (take limit
+ (sort-by :score (comp - compare)
+ (filter #(< 0 (:score %))
+ (for [item data]
+ (let [strings (->> extract-fns
+ (map (fn [f] (str (f item))))
+ (remove string/blank?))
+ best-score (if (seq strings)
+ (apply max (map #(score query %) strings))
+ 0)]
+ {:data item
+ :score best-score})))))
+ (map :data)))
+
+
+(defn- zh-simplified-char?
+ "True for characters in the CJK Unified Ideographs blocks
+ used by Simplified Chinese."
+ [c]
+ (let [code (.charCodeAt c 0)]
+ (or (and (>= code 0x4E00) (<= code 0x9FFF))
+ (and (>= code 0x3400) (<= code 0x4DBF)))))
+
+(defn- zh-initial
+ "Return the lowercase pinyin initial letter for a single Chinese character."
+ [c]
+ (string/lower-case (subs (tp/convertToPinyin c "" false) 0 1)))
+
+(defn- segment->initials
+ "Strip punctuation and symbols from a non-Chinese text segment, split on
+ whitespace, and collect the first character of each resulting word.
+ Numbers are treated as words — '12345' contributes '1'; '1 23 45'
+ contributes '1', '2', '4'."
+ [seg]
+ (when (seq seg)
+ (->> (string/replace seg #"[^a-zA-Z0-9 ]" "")
+ (#(string/split % #"\s+"))
+ (remove string/blank?)
+ (map #(string/lower-case (subs % 0 1))))))
+
+(defn hanzi->initials
+ "Derive a compact initial-letter string from mixed Chinese/English text for
+ Simplified-Chinese prefix search (pinyin-style). Processes Simplified
+ Chinese only — other CJK scripts are treated as non-Chinese segments.
+
+ Rules:
+ - Each Simplified Chinese character maps to the first letter of its pinyin.
+ - Non-Chinese text is stripped of punctuation, split on whitespace, and
+ contributes the first character of each resulting word.
+ - Numbers follow word rules: '12345' → '1'; '1 23 45' → '1', '2', '4'.
+
+ Examples:
+ '设置' → 'sz'
+ '删除块' → 'sck'
+ '新建页面' → 'xjym'
+ '插入 block embed' → 'crbe'
+ '导出为 PDF' → 'dcwp'
+ '今日日志' → 'jrrz'"
+ [s]
+ (when (string? s)
+ (loop [cs (seq s)
+ seg []
+ result []]
+ (cond
+ (empty? cs)
+ (string/join (into result (segment->initials (string/join seg))))
+
+ (zh-simplified-char? (str (first cs)))
+ (let [seg-initials (segment->initials (string/join seg))
+ init (zh-initial (str (first cs)))]
+ (recur (rest cs) [] (-> result (into seg-initials) (conj init))))
+
+ :else
+ (recur (rest cs) (conj seg (str (first cs))) result)))))
diff --git a/src/main/frontend/components/cmdk/core.cljs b/src/main/frontend/components/cmdk/core.cljs
index 20cd4f4a61..78ef7c0583 100644
--- a/src/main/frontend/components/cmdk/core.cljs
+++ b/src/main/frontend/components/cmdk/core.cljs
@@ -7,7 +7,7 @@
[frontend.components.cmdk.state :as cmdk-state]
[frontend.components.icon :as icon-component]
[frontend.config :as config]
- [frontend.context.i18n :as i18n :refer [t]]
+ [frontend.context.i18n :refer [interpolate-rich-text t t-en t-locale]]
[frontend.db :as db]
[frontend.db.async :as db-async]
[frontend.db.model :as model]
@@ -42,9 +42,10 @@
[]
(:action (:search/args @state/state)))
-(defn translate [t {:keys [id desc]}]
+(defn translate
+ [t-fn {:keys [id desc]}]
(when id
- (let [desc-i18n (t (shortcut-utils/decorate-namespace id))]
+ (let [desc-i18n (t-fn (shortcut-utils/decorate-namespace id))]
(if (string/starts-with? desc-i18n "{Missing key")
desc
desc-i18n))))
@@ -240,15 +241,55 @@
(reset! !results (assoc-in default-results [:recently-updated-pages :items] recent-pages)))))
;; The commands search uses the command-palette handler
+(defn- translate-locale
+ "Return the locale-only translation for a command.
+ Returns nil when the locale is :en or the key has no translation in the
+ current locale — no English fallback is applied."
+ [{:keys [id]}]
+ (when id
+ (t-locale (shortcut-utils/decorate-namespace id))))
+
+(defonce ^:private !commands-cache (atom {:lang nil :commands nil}))
+
+(defn- get-commands-for-search
+ "Return commands with locale, English, and (for :zh-CN) pinyin-initial fields.
+ :locale-t — locale-only translation; nil when locale is :en or key has no
+ locale entry (no English fallback).
+ :en-t — English translation; always present.
+ :pinyin-t — Simplified Chinese pinyin initials; present only for :zh-CN.
+ Cached by language — rebuilt only when preferred-language changes."
+ []
+ (let [lang (or (some-> (:preferred-language @state/state) keyword) :en)
+ cache @!commands-cache]
+ (if (= (:lang cache) lang)
+ (:commands cache)
+ (let [zh-cn? (= lang :zh-CN)
+ cmds (->> (cp-handler/top-commands 1000)
+ (map (fn [cmd]
+ (let [locale-t (when-not (= lang :en) (translate-locale cmd))
+ en-t (translate t-en cmd)]
+ (cond-> (assoc cmd :en-t en-t)
+ locale-t (assoc :locale-t locale-t)
+ (and zh-cn? locale-t) (assoc :pinyin-t (search/hanzi->initials locale-t)))))))]
+ (reset! !commands-cache {:lang lang :commands cmds})
+ cmds))))
+
(defmethod load-results :commands [group state]
- (let [!input (::input state)
+ (let [!input (::input state)
!results (::results state)]
(swap! !results assoc-in [group :status] :loading)
- (let [commands (->> (cp-handler/top-commands 1000)
- (map #(assoc % :t (translate t %))))
+ (let [lang (or (some-> (:preferred-language @state/state) keyword) :en)
+ en? (= lang :en)
+ zh-cn? (= lang :zh-CN)
+ commands (get-commands-for-search)
+ extract-fns (cond
+ en? [:en-t]
+ zh-cn? [:locale-t :en-t :pinyin-t]
+ :else [:locale-t :en-t])
search-results (if (string/blank? @!input)
commands
- (search/fuzzy-search commands @!input {:extract-fn :t}))]
+ (search/fuzzy-search-multi commands @!input
+ {:extract-fns extract-fns}))]
(->> search-results
(map #(hash-map :icon "command"
:icon-theme :gray
@@ -1111,7 +1152,7 @@
(defn- tip-with-shortcut
[template shortcut & [shortcut-opts]]
(into [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100]
- (i18n/interpolate-rich-text
+ (interpolate-rich-text
template
[(shui/shortcut shortcut shortcut-opts)])))
@@ -1213,6 +1254,10 @@
(defn- cmdk-init-state
"Initialize cmdk component state atoms."
[state]
+ ;; Invalidate the commands cache so that each new CMDK session gets a fresh
+ ;; commands list from cp-handler/top-commands (plugins, graph state, etc. may
+ ;; have changed since the last session).
+ (reset! !commands-cache {:lang nil :commands nil})
(let [raw-search-mode (:search/mode @state/state)
search-mode (or raw-search-mode :global)
search-args (:search/args @state/state)
diff --git a/src/main/frontend/context/i18n.cljs b/src/main/frontend/context/i18n.cljs
index b5d05147c3..9fb4add3d2 100644
--- a/src/main/frontend/context/i18n.cljs
+++ b/src/main/frontend/context/i18n.cljs
@@ -41,6 +41,31 @@
:zh-CN ","
:zh-Hant ","})
+(def ^:private translate-strict
+ "tongue translator built against the raw locale dicts without any fallback.
+ Returns a '{Missing key ...}' string for keys absent in the requested locale."
+ (tongue/build-translate dicts/dicts))
+
+(defn t-locale
+ "Translate using the user's current locale without English fallback for
+ missing keys — returns nil when the key has no translation in the current
+ locale. If translation throws (e.g. malformed value format), falls back to
+ English and logs the error; that is distinct from a missing-key nil return.
+ Callers that need a guaranteed string should use t."
+ [& args]
+ (let [lang (preferred-locale)
+ result (try
+ (apply translate-strict lang args)
+ (catch :default e
+ (log/error :failed-translation {:arguments args :lang lang})
+ (state/pub-event! [:capture-error {:error e
+ :payload {:type :failed-translation
+ :arguments args
+ :lang lang}}])
+ (apply translate :en args)))]
+ (when-not (string/starts-with? (str result) "{Missing key")
+ result)))
+
(defn t-en
"Translate using English locale, ignoring user preference.
Useful for user-facing text that also requires output to the console."
diff --git a/src/main/frontend/search.cljs b/src/main/frontend/search.cljs
index 5322678059..fbd623dfb2 100644
--- a/src/main/frontend/search.cljs
+++ b/src/main/frontend/search.cljs
@@ -12,6 +12,8 @@
[promesa.core :as p]))
(def fuzzy-search fuzzy/fuzzy-search)
+(def fuzzy-search-multi fuzzy/fuzzy-search-multi)
+(def hanzi->initials fuzzy/hanzi->initials)
(defn get-engine
[repo]
diff --git a/src/test/frontend/common/search_fuzzy_test.cljs b/src/test/frontend/common/search_fuzzy_test.cljs
new file mode 100644
index 0000000000..2ebd5dc09e
--- /dev/null
+++ b/src/test/frontend/common/search_fuzzy_test.cljs
@@ -0,0 +1,74 @@
+(ns frontend.common.search-fuzzy-test
+ (:require [cljs.test :refer [deftest is testing]]
+ [frontend.common.search-fuzzy :as fuzzy]))
+
+(deftest fuzzy-search-multi-finds-by-first-field
+ (testing "locale query still returns correct item when first field matches"
+ (let [data [{:locale "粗体" :en "Bold"}
+ {:locale "斜体" :en "Italic"}]]
+ (is (seq (fuzzy/fuzzy-search-multi data "粗体"
+ {:extract-fns [:locale :en]
+ :limit 5}))))))
+
+(deftest fuzzy-search-multi-finds-by-second-field
+ (testing "returns item when query matches second field but not first"
+ (let [data [{:locale "粗体" :en "Bold"}
+ {:locale "斜体" :en "Italic"}
+ {:locale "代码" :en "Code"}]]
+ (is (= [{:locale "粗体" :en "Bold"}]
+ (fuzzy/fuzzy-search-multi data "bold"
+ {:extract-fns [:locale :en]
+ :limit 5}))))))
+
+(deftest fuzzy-search-multi-score-prefers-best-field
+ (testing "item with exact match on any field ranks first"
+ (let [data [{:locale "粗体" :en "Bold"}
+ {:locale "粗" :en "Thick"}]
+ results (fuzzy/fuzzy-search-multi data "粗体"
+ {:extract-fns [:locale :en]
+ :limit 5})]
+ (is (= "粗体" (:locale (first results)))))))
+
+(deftest fuzzy-search-multi-returns-empty-when-no-match
+ (testing "returns empty seq when query matches nothing"
+ (let [data [{:locale "粗体" :en "Bold"}]]
+ (is (empty? (fuzzy/fuzzy-search-multi data "xyz"
+ {:extract-fns [:locale :en]
+ :limit 5}))))))
+
+(deftest fuzzy-search-multi-skips-nil-extract-fields
+ (testing "nil fields from extract-fns are silently skipped, non-nil fields still score"
+ (let [data [{:en "Delete Page" :locale nil}
+ {:en "New Page" :locale nil}]]
+ (is (= [{:en "Delete Page" :locale nil}]
+ (fuzzy/fuzzy-search-multi data "delete"
+ {:extract-fns [:locale :en]
+ :limit 5}))))))
+
+(deftest hanzi->initials-all-chinese
+ (testing "pure Chinese command names produce correct pinyin initials"
+ (is (= "sck" (fuzzy/hanzi->initials "删除块")))
+ (is (= "xjym" (fuzzy/hanzi->initials "新建页面")))
+ (is (= "jrrz" (fuzzy/hanzi->initials "今日日志")))
+ (is (= "sz" (fuzzy/hanzi->initials "设置")))))
+
+(deftest hanzi->initials-mixed-chinese-english
+ (testing "mixed Chinese and English text combines pinyin and word initials"
+ (is (= "crbe" (fuzzy/hanzi->initials "插入 block embed")))
+ (is (= "dcwp" (fuzzy/hanzi->initials "导出为 PDF")))
+ (is (= "sck" (fuzzy/hanzi->initials "删除块")))))
+
+(deftest hanzi->initials-punctuation-not-word-boundary
+ (testing "punctuation inside a word is stripped, not treated as a word separator"
+ (is (= "b" (fuzzy/hanzi->initials "block(s)")))
+ (is (= "ti" (fuzzy/hanzi->initials "TODO: items")))))
+
+(deftest hanzi->initials-numbers
+ (testing "contiguous digits form one word; space-separated digit groups each contribute first digit"
+ (is (= "1" (fuzzy/hanzi->initials "12345")))
+ (is (= "124" (fuzzy/hanzi->initials "1 23 45")))))
+
+(deftest hanzi->initials-nil-and-blank
+ (testing "nil input returns nil; non-string input returns nil"
+ (is (nil? (fuzzy/hanzi->initials nil)))
+ (is (nil? (fuzzy/hanzi->initials 42)))))
diff --git a/yarn.lock b/yarn.lock
index a0790bbe8d..6ce7c7f231 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7172,6 +7172,11 @@ through@2, "through@>=2.2.7 <3":
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+tiny-pinyin@1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/tiny-pinyin/-/tiny-pinyin-1.3.2.tgz#ce31f0f3afc2a80ee9df708fc7f4e914854d534a"
+ integrity sha512-uHNGu4evFt/8eNLldazeAM1M8JrMc1jshhJJfVRARTN3yT8HEEibofeQ7QETWQ5ISBjd6fKtTVBCC/+mGS6FpA==
+
tiny-typed-emitter@^2.0.3:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5"