Electron Chinese Search

Electron Chinese Search
Electron Chinese Search

This is a quick demo of how to use js-search, nodejieba to implement Chinese search in Electron.

It’s fast, real-time, faster than any other chinese search solutions, fast like never before.

techversion
electron30.0.6
nodejieba2.6.0
js-search2.0.1

This article will walk you through a number of issues you may encounter when using npm mirrors and nodejieba in mainland China:

  1. nodejieba package in npmmirror.com does not exist or cannot be downloaded.
  2. nodejieba is unmaintained and is not supported on win11 and vs studio 2022 versions.
  3. nodejieba does not support typescript.

Add dependencies

1npm i js-search
2npm i nodejieba@2.6.0 --save-optional --ignore-scripts

Why does nodejieba take this approach?

Because nodejieba is written in c++ and its community is no longer active.

Its installation scripts will fail. We need to skip its scripts and compile it ourselves.

** You need to install vs studio 2022 and check Use c++ desktop development **.

Or use the following powershell command to install only the needed components:

1Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile "$env:TEMP\vs_BuildTools.exe"
2
3& "$env:TEMP\vs_BuildTools.exe" --passive --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended

Fix nodejieba

nodejieba does not support the c++ 17 standard, and the way to fix it is simple.

You just need to replace StringUtil_latest.hpp in github.com/yanyiwu/limonp with nodejieba before it compiles.

Here’s a sample.

 1const fs = require('fs');
 2const path = require('path');
 3const projectDir = path.dirname(path.resolve(__dirname));
 4
 5const patchFile = path.resolve(projectDir, 'SOME_FOLDER', 'StringUtil_latest.hpp'); // Save StringUtil.hpp to a local location such as SOME_FOLDER/StringUtil_latest.hpp
 6
 7const dest = path.resolve(projectDir, 'node_modules', ...'/nodejieba/deps/limonp/StringUtil.hpp'.split("/"));
 8// first install nodejieba with `npm install nodejieba@2.6.0 --ignore-scripts`
 9// https://github.com/yanyiwu/limonp/issues/34
10fs.copyFile(patchFile, dest, (err) => {
11  err && console.error(err) && process.exit(1);
12})

limonp-StringUtil.hpp

You can also choose to create a pr to the nodejieba repository.

I hope that all China’s open source software will have a good start and also a good finish.

Modify package.json

We still want nodejieba to be recognized by electron-rebuild when it is packaged.

1"scripts": {
2    "preinstall": "npm i nodejieba@2.6.0 --save-optional --ignore-scripts",
3    "build:plugin": "electron-rebuild -f",

electron-rebuild helps you do what node-gyp needs to do.

electron-rebuild

Write a tool to load nodejieba.

Copying nodejieba’s dictionary file

Assuming you are using Electron Builder, this code copies node_modules/nodejieba/dict/ to the root of the installation directory.

1"build": {
2    "extraFiles": [
3      {
4        "from": "node_modules/nodejieba/dict/",
5        "to": "dict/"
6      }
7    ],

Do not change any of the following lines of code.

The tool to load a local node addon

 1import fs from "fs";
 2import path from "path";
 3import * as process from "process";
 4import bindings from "bindings";
 5// eslint-disable-next-line import/no-extraneous-dependencies
 6import logger from "_main/logger";
 7import nconsole from "_rutils/nconsole";
 8import { dev } from '_utils/node-env';
 9
10function loadAddon(pluginName: string) {
11  logger.log("preloading plugin");
12  let moduleRoot = path.dirname(process.execPath);
13  let tries = [["module_root", "bindings"]];
14  if (dev) {
15    moduleRoot = process.cwd();
16    tries = [["module_root", "build", "bindings"]];
17    if (!fs.existsSync(path.join(moduleRoot, "build", pluginName + ".node"))) {
18      tries = [["module_root", "bindings"]];
19    }
20  }
21  logger.log("using tries: " + JSON.stringify(tries));
22  let nodeAddon;
23  try {
24    nodeAddon = bindings({
25      bindings: pluginName,
26      module_root: moduleRoot,
27      try: tries,
28    });
29  } catch (e) {
30    logger.error(e);
31  }
32  return nodeAddon;
33}
34
35export default loadAddon;

Load nodejieba

 1import path from "path";
 2import loadAddon from './load_node_addon';
 3
 4const jbAddon = loadAddon("fastx");
 5
 6let dictDirRoot = process.cwd();
 7if (process.env.NODE_ENV === 'development') {
 8  dictDirRoot = path.resolve(process.cwd(), 'node_modules', 'nodejieba');
 9}
10
11let isDictLoaded = false;
12
13const defaultDict = {
14  dict: `${dictDirRoot}/dict/jieba.dict.utf8`,
15  hmmDict: `${dictDirRoot}/dict/hmm_model.utf8`,
16  userDict: `${dictDirRoot}/dict/user.dict.utf8`,
17  idfDict: `${dictDirRoot}/dict/idf.utf8`,
18  stopWordDict: `${dictDirRoot}/dict/stop_words.utf8`,
19};
20
21interface LoadOptions {
22  dict?: string;
23  hmmDict?: string;
24  userDict?: string;
25  idfDict?: string;
26  stopWordDict?: string;
27}
28
29export const load = (dictJson?: LoadOptions) => {
30  const finalDictJson = {
31    ...defaultDict,
32    ...dictJson,
33  };
34  isDictLoaded = true;
35  return jbAddon.load(
36    finalDictJson.dict,
37    finalDictJson.hmmDict,
38    finalDictJson.userDict,
39    finalDictJson.idfDict,
40    finalDictJson.stopWordDict,
41  );
42};
43
44export const DEFAULT_DICT = defaultDict.dict;
45export const DEFAULT_HMM_DICT = defaultDict.hmmDict;
46export const DEFAULT_USER_DICT = defaultDict.userDict;
47export const DEFAULT_IDF_DICT = defaultDict.idfDict;
48export const DEFAULT_STOP_WORD_DICT = defaultDict.stopWordDict;
49
50export interface TagResult {
51  word: string;
52  tag: string;
53}
54
55export interface ExtractResult {
56  word: string;
57  weight: number;
58}
59
60const mustLoadDict = (f: any, ...args: any[]):any => {
61  if (!isDictLoaded) {
62    load();
63  }
64  return f.apply(undefined, [...args]);
65};
66
67export const cut = (content: string, strict: boolean): string[] => mustLoadDict(jbAddon.cut, content, strict);
68export const cutAll = (content: string): string[] => mustLoadDict(jbAddon.cutAll, content);
69export const cutForSearch = (content: string, strict: boolean): string[] => mustLoadDict(jbAddon.cutForSearch, content, strict);
70export const cutHMM = (content: string): string[] => mustLoadDict(jbAddon.cutHMM, content);
71export const cutSmall = (content: string, limit: number): string[] => mustLoadDict(jbAddon.cutSmall, content, limit);
72export const extract = (content: string, threshold: number): ExtractResult[] => mustLoadDict(jbAddon.extract, content, threshold);
73export const textRankExtract = (content: string, threshold: number): ExtractResult[] => mustLoadDict(jbAddon.textRankExtract, content, threshold);
74export const insertWord = (word: string): boolean => mustLoadDict(jbAddon.insertWord, word);
75export const tag = (content: string): TagResult[] => mustLoadDict(jbAddon.tag, content);
76
77export default {
78  load,
79  cut,
80  cutAll,
81  cutForSearch,
82  cutHMM,
83  cutSmall,
84  extract,
85  textRankExtract,
86  insertWord,
87  tag,
88  DEFAULT_DICT,
89  DEFAULT_HMM_DICT,
90  DEFAULT_USER_DICT,
91  DEFAULT_IDF_DICT,
92  DEFAULT_STOP_WORD_DICT,
93};

You should expose the tool, through the global object like window, to the renderer process, which can then call methods such as window.myAddons.cutForSearch.

Combine js-search and nodejieba

Assuming you want to search an object like this:

1export interface Product {
2  [key: string]: any;
3
4  productCode: string;
5  name: string;
6  namePinyin: string;
7  nameEnglish: string;
8}

Write the code in your search component like this:

  1import * as JsSearch from 'js-search';
  2import { Search } from 'js-search';
  3
  4const [search, setSearch] = React.useState<string>("");
  5const jsSearchGames = React.useRef<Search>();
  6const [omnisearch_games, setOmnisearchGames] = React.useState<any[]>([]);
  7const [omnisearch_loading, setOmnisearchLoading] = React.useState(false);
  8
  9// ... 
 10
 11// construct search component and data on load
 12useEffect(() => {
 13  const buildJsSearch = (uidField: string, documents: any[], ...index: string[]) => {
 14    const jsSearch = new JsSearch.Search(uidField);
 15    jsSearch.tokenizer = {
 16      tokenize: (text) => {
 17        const r = window.myAddons.cutForSearch(text, true); // cutForSearch is the method in the tool
 18        return r;
 19      },
 20    };
 21    index.forEach((i) => jsSearch.addIndex(i));
 22    jsSearch.addDocuments(documents);
 23    return jsSearch;
 24  };
 25
 26
 27  jsSearchGames.current = buildJsSearch('productCode', p, 'productCode', 'name', 'namePinyin', 'nameEnglish');
 28}, []);
 29
 30// start to search if use type something in the search input
 31useEffect((): (() => void) | void => {
 32  if (!search) {
 33    return;
 34  }
 35  const q = search.trim();
 36  if (!q) {
 37    return;
 38  }
 39  setOmnisearchGames([]);
 40  setOmnisearchLoading(true);
 41  // cancel last search if duration between this search and last search is less than 1 second
 42  // this is something you need to consider while user using chinese input method 
 43  if (currentSearchId.current) {
 44    clearTimeout(currentSearchId.current);
 45  }
 46  const doSearch = async () => new Promise<searchResult>((resolve, reject) => {
 47    // start search after 1 second
 48    currentSearchId.current = setTimeout(() => {
 49      const result = {
 50        sitemap: match_sitemap(q),
 51        games: jsSearchGames.current?.search(q) as Product[],
 52        gamesPrecisely: jsSearchGamesPrecisely.current?.search(q) as Product[],
 53        orders: jsSearchOrders.current?.search(q) as Order[],
 54        news: jsSearchNews.current?.search(q) as NotificationType[],
 55        tags: jsSearchTags.current?.search(q) as Tags[],
 56      };
 57      resolve(result);
 58    }, 200);
 59  });
 60  doSearch().then((d) => {
 61    setOmnisearchGames(d.games.filter((p) => p.type !== Constants.API_TYPE_PRODUCT && p.type !== Constants.API_TYPE_GAMEBOX_APP));
 62    if (d.games.length === 0 && q.length >= 2 && q.indexOf("'") < 0) {
 63      Object.keys(requests_in_flght.current).forEach((k) => {
 64        if (q.indexOf(k) === 0) {
 65          clearTimeout(requests_in_flght.current[k]);
 66          delete requests_in_flght.current[k];
 67        }
 68      });
 69      // cut q to keep its largest length to 32
 70      // save those record that returns empty results to server so we can improve
 71      requests_in_flght.current[q] = setTimeout(() => {
 72        post("/saveRecord", {
 73          searchString: q.substring(0, 32),
 74        }).catch(() => {
 75        });
 76      }, 5000);
 77    }
 78  })
 79    .catch(openError)
 80    .finally(() => setOmnisearchLoading(false));
 81}, [search]);
 82
 83
 84return (
 85  <div className="OmniSearch-container">
 86    {inputElement()}
 87    {(search_focus || omniMouseOver || null) && search && (
 88      <aside className="OmniSearch-results-container">
 89        {(omnisearch_loading || null) && <div className="loading">Loading</div>}
 90        {((!omnisearch_loading && omnisearch_result_count === 0) || null) && (
 91          <div className="no-results">
 92            Not Found
 93          </div>
 94        )}
 95        {(omnisearch_games.length || null) && (
 96          <div className="results">
 97            <h3>Games</h3>
 98            {omnisearch_games.map((e) => (
 99              <div className="result" key={e.productCode}>
100                <Link to={`/productDetail/${e.type}/${e.productCode}`}>{e.name}</Link>
101              </div>
102            ))}
103          </div>
104        )}
105      </aside>
106    )}
107  </div>
108);

Done

Well, now you can achieve such a search effect as below.