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"