├── public
├── ts
│ ├── README.md
│ ├── package.json
│ ├── tsconfig.json
│ ├── types
│ │ └── dayjs.d.ts
│ ├── package-lock.json
│ ├── dist
│ │ ├── mj.js
│ │ ├── settings.js
│ │ ├── word-info.js
│ │ ├── labels.js
│ │ ├── util.js
│ │ ├── edit-word.js
│ │ └── index.js
│ └── src
│ │ ├── mj.ts
│ │ ├── settings.ts
│ │ ├── word-info.ts
│ │ ├── labels.ts
│ │ ├── edit-word.ts
│ │ ├── util.ts
│ │ └── index.ts
├── settings.html
├── word-info.html
├── .prettierrc
├── edit-word.html
├── labels.html
├── index.html
├── style.css
└── dayjs.min.js
├── .gitignore
├── db-dictplus.sqlite2
├── screenshots
├── screenshot-01.webp
├── screenshot-02.webp
├── screenshot-03.webp
├── screenshot-04.webp
├── screenshot-dark-01.webp
├── screenshot-dark-02.webp
├── screenshot-dark-03.webp
└── screenshot-dark-04.webp
├── model
├── model.go
└── id.go
├── go.mod
├── init.go
├── LICENSE
├── main.go
├── stringset
└── stringset.go
├── database
├── tx.go
├── metadata.go
└── database.go
├── stmt
└── statements.go
├── util
└── util.go
├── README.md
├── go.sum
└── handler.go
/public/ts/README.md:
--------------------------------------------------------------------------------
1 | # ts
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | db-dictplus.sqlite
3 | *.exe
4 | node_modules
5 | *.zip
6 | to_temp.yaml
7 |
--------------------------------------------------------------------------------
/db-dictplus.sqlite2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/db-dictplus.sqlite2
--------------------------------------------------------------------------------
/screenshots/screenshot-01.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-01.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-02.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-02.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-03.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-03.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-04.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-04.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-dark-01.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-dark-01.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-dark-02.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-dark-02.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-dark-03.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-dark-03.webp
--------------------------------------------------------------------------------
/screenshots/screenshot-dark-04.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahui2016/dictplus/HEAD/screenshots/screenshot-dark-04.webp
--------------------------------------------------------------------------------
/public/ts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dictplus",
3 | "version": "1.0.0",
4 | "devDependencies": {
5 | "@types/jquery": "^3.5.8"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/public/ts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "ES6",
5 | "strict": true,
6 | "skipLibCheck": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "moduleResolution": "Classic",
9 | "outDir": "dist",
10 | "baseUrl": ".",
11 | },
12 | "include": ["src/**/*", "types"],
13 | "exclude": [
14 | "dist",
15 | "node_modules"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/model/model.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | // localtags 的 IP 和端口,与 Images 里的 ID 组成一个完整网址
4 | const ImageLocalIP = ""
5 |
6 | // Word 可以是一个单词或一个短句
7 | type Word struct {
8 | ID string // ShortID
9 | CN string
10 | EN string
11 | JP string
12 | Kana string // 与 JP 对应的平假名
13 | Other string // 其他任何语种
14 | Label string // 每个单词只有一个标签,一般用来记录出处(书名或文章名)
15 | Notes string
16 | Links string // 用换行符分隔的网址
17 | Images string // 用逗号分隔的图片 ID, 与 localtags 搭配使用
18 | CTime int64
19 | }
20 |
21 | type Settings struct {
22 | DictplusAddr string
23 | LocaltagsAddr string
24 | Delay bool
25 | }
26 |
--------------------------------------------------------------------------------
/public/ts/types/dayjs.d.ts:
--------------------------------------------------------------------------------
1 | // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
2 | // https://github.com/iamkun/dayjs/blob/dev/types/index.d.ts
3 |
4 | declare function dayjs (date?: dayjs.ConfigType): dayjs.Dayjs
5 |
6 | declare namespace dayjs {
7 | interface ConfigTypeMap {
8 | default: string | number | Date | Dayjs | null | undefined
9 | }
10 |
11 | export type ConfigType = ConfigTypeMap[keyof ConfigTypeMap];
12 |
13 | class Dayjs {
14 | constructor (config?: ConfigType)
15 |
16 | format(template?: string): string
17 | unix(): number
18 | }
19 |
20 | export function unix(t: number): Dayjs;
21 | }
22 |
--------------------------------------------------------------------------------
/public/settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | settings - dictplus
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module ahui2016.github.com/dictplus
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/labstack/echo/v4 v4.6.1
7 | github.com/mattn/go-sqlite3 v1.14.9
8 | )
9 |
10 | require (
11 | github.com/labstack/gommon v0.3.0 // indirect
12 | github.com/mattn/go-colorable v0.1.8 // indirect
13 | github.com/mattn/go-isatty v0.0.14 // indirect
14 | github.com/valyala/bytebufferpool v1.0.0 // indirect
15 | github.com/valyala/fasttemplate v1.2.1 // indirect
16 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
17 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect
18 | golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect
19 | golang.org/x/text v0.3.7 // indirect
20 | )
21 |
--------------------------------------------------------------------------------
/init.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "net/http"
6 |
7 | "ahui2016.github.com/dictplus/database"
8 | "ahui2016.github.com/dictplus/model"
9 | "ahui2016.github.com/dictplus/util"
10 | )
11 |
12 | type (
13 | Word = model.Word
14 | Settings = model.Settings
15 | )
16 |
17 | const (
18 | OK = http.StatusOK
19 | dbFileName = "db-dictplus.sqlite"
20 | DefaultPageLimit = 100 // 搜索结果每页上限的默认值,具体值由前端传过来
21 | )
22 |
23 | var (
24 | db = new(database.DB)
25 | addr = flag.String("addr", "", "local IP address")
26 | )
27 |
28 | func init() {
29 | flag.Parse()
30 | util.Panic(db.Open(dbFileName))
31 | if *addr == "" {
32 | s, err := db.GetSettings()
33 | util.Panic(err)
34 | *addr = s.DictplusAddr
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/public/word-info.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | dictplus
12 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/public/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "website-address": "https://prettier.io/docs/en/options.html",
3 | "bracketSpacing": false,
4 | "endOfLine": "lf",
5 | "printWidth": 80,
6 | "proseWrap": "preserve",
7 | "quoteProps": "as-needed",
8 | "tabWidth": 4,
9 | "useTabs": false,
10 | "insertPragma": false,
11 | "requirePragma": false,
12 | "vueIndentScriptAndStyle": false,
13 | "embeddedLanguageFormatting": "auto",
14 |
15 | "overrides": [
16 | {
17 | "files": [ "*.js", "*.ts" ],
18 | "options": {
19 | "tabWidth": 2,
20 | "printWidth": 100,
21 | "arrowParens": "avoid",
22 | "htmlWhitespaceSensitivity": "css",
23 | "semi": true,
24 | "singleQuote": true,
25 | "trailingComma": "es5"
26 | }
27 | },
28 | {
29 | "files": [ "*.html" ],
30 | "options": {
31 | "tabWidth": 2,
32 | "printWidth": 120,
33 | "bracketSameLine": true
34 | }
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/public/edit-word.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | dictplus
12 |
13 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 102419@gmail.com
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/public/labels.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Labels - dictplus
12 |
13 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | )
6 |
7 | func main() {
8 | defer db.DB.Close()
9 |
10 | e := echo.New()
11 | e.IPExtractor = echo.ExtractIPFromXFFHeader()
12 | e.HTTPErrorHandler = errorHandler
13 |
14 | e.Use(jsFile)
15 | e.Static("/public", "public")
16 | e.File("/", "public/index.html")
17 | // e.GET("/", func(c echo.Context) error {
18 | // return c.Redirect(http.StatusFound, "/public/index.html")
19 | // })
20 | // public := e.Group("/public")
21 | // public.GET("/:filename", publicFileHandler)
22 | // public.GET("/ts/dist/:filename", scriptsFileHandler)
23 |
24 | api := e.Group("/api", sleep)
25 | api.POST("/get-word", getWordHandler)
26 | api.POST("/add-word", addWordHandler)
27 | api.POST("/update-word", updateWordHandler)
28 | api.POST("/delete-word", deleteWordHandler)
29 | api.POST("/search-words", searchHandler)
30 | api.GET("/count-words", countHandler)
31 | api.GET("/get-history", getHistoryHandler)
32 | api.POST("/update-history", updateHistory)
33 | api.GET("/get-all-labels", getAllLabels)
34 | api.GET("/get-recent-labels", getRecentLabels)
35 | api.GET("/get-settings", getSettingsHandler)
36 | api.POST("/update-settings", updateSettings)
37 | api.GET("/download-db", downloadDB)
38 |
39 | e.Logger.Fatal(e.Start(*addr))
40 | }
41 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | dictplus
12 |
13 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/stringset/stringset.go:
--------------------------------------------------------------------------------
1 | package stringset
2 |
3 | import (
4 | "sort"
5 | )
6 |
7 | // Set .
8 | type Set struct {
9 | Map map[string]bool
10 | }
11 |
12 | func NewSet() *Set {
13 | return &Set{make(map[string]bool)}
14 | }
15 |
16 | func From(arr []string) *Set {
17 | set := NewSet()
18 | for _, v := range arr {
19 | set.Map[v] = true
20 | }
21 | return set
22 | }
23 |
24 | // Has .
25 | func (set *Set) Has(item string) bool {
26 | return set.Map[item]
27 | }
28 |
29 | // Add .
30 | func (set *Set) Add(item string) {
31 | set.Map[item] = true
32 | }
33 |
34 | // Intersect .
35 | func (set *Set) Intersect(other *Set) *Set {
36 | result := NewSet()
37 | for key := range set.Map {
38 | if other.Has(key) {
39 | result.Add(key)
40 | }
41 | }
42 | return result
43 | }
44 |
45 | // Slice converts the set to a string slice.
46 | func (set *Set) Slice() (arr []string) {
47 | for key := range set.Map {
48 | if set.Has(key) {
49 | arr = append(arr, key)
50 | }
51 | }
52 | return
53 | }
54 |
55 | // Unique 利用 Set 对 arr 进行除重处理。
56 | func Unique(arr []string) (result []string) {
57 | if len(arr) == 0 {
58 | return
59 | }
60 | return From(arr).Slice()
61 | }
62 |
63 | // UniqueSort 利用 Set 对 arr 进行除重和排序。
64 | func UniqueSort(arr []string) (result []string) {
65 | sort.Strings(result)
66 | return
67 | }
68 |
69 | // Intersect 取 group 里全部集合的交集。
70 | func Intersect(group []*Set) *Set {
71 | length := len(group)
72 | if length == 0 {
73 | return NewSet()
74 | }
75 | result := group[0]
76 | for i := 1; i < length; i++ {
77 | result = result.Intersect(group[i])
78 | }
79 | return result
80 | }
81 |
--------------------------------------------------------------------------------
/public/ts/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dictplus",
3 | "version": "1.0.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "dictplus",
9 | "version": "1.0.0",
10 | "devDependencies": {
11 | "@types/jquery": "^3.5.8"
12 | }
13 | },
14 | "node_modules/@types/jquery": {
15 | "version": "3.5.8",
16 | "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.8.tgz",
17 | "integrity": "sha512-cXk6NwqjDYg+UI9p2l3x0YmPa4m7RrXqmbK4IpVVpRJiYXU/QTo+UZrn54qfE1+9Gao4qpYqUnxm5ZCy2FTXAw==",
18 | "dev": true,
19 | "dependencies": {
20 | "@types/sizzle": "*"
21 | }
22 | },
23 | "node_modules/@types/sizzle": {
24 | "version": "2.3.3",
25 | "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
26 | "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
27 | "dev": true
28 | }
29 | },
30 | "dependencies": {
31 | "@types/jquery": {
32 | "version": "3.5.8",
33 | "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.8.tgz",
34 | "integrity": "sha512-cXk6NwqjDYg+UI9p2l3x0YmPa4m7RrXqmbK4IpVVpRJiYXU/QTo+UZrn54qfE1+9Gao4qpYqUnxm5ZCy2FTXAw==",
35 | "dev": true,
36 | "requires": {
37 | "@types/sizzle": "*"
38 | }
39 | },
40 | "@types/sizzle": {
41 | "version": "2.3.3",
42 | "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
43 | "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
44 | "dev": true
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/database/tx.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 |
6 | "ahui2016.github.com/dictplus/stmt"
7 | )
8 |
9 | type TX interface {
10 | Exec(string, ...interface{}) (sql.Result, error)
11 | Query(string, ...interface{}) (*sql.Rows, error)
12 | QueryRow(string, ...interface{}) *sql.Row
13 | }
14 |
15 | // getText1 gets one text value from the database.
16 | func getText1(tx TX, query string, args ...interface{}) (text string, err error) {
17 | row := tx.QueryRow(query, args...)
18 | err = row.Scan(&text)
19 | return
20 | }
21 |
22 | // getInt1 gets one number value from the database.
23 | func getInt1(tx TX, query string, arg ...interface{}) (n int64, err error) {
24 | row := tx.QueryRow(query, arg...)
25 | err = row.Scan(&n)
26 | return
27 | }
28 |
29 | type Row interface {
30 | Scan(...interface{}) error
31 | }
32 |
33 | func insertWord(tx TX, w *Word) error {
34 | _, err := tx.Exec(
35 | stmt.InsertWord,
36 | w.ID,
37 | w.CN,
38 | w.EN,
39 | w.JP,
40 | w.Kana,
41 | w.Other,
42 | w.Label,
43 | w.Notes,
44 | w.Links,
45 | w.Images,
46 | w.CTime,
47 | )
48 | return err
49 | }
50 |
51 | func scanWords(rows *sql.Rows) (words []Word, err error) {
52 | for rows.Next() {
53 | w, err := scanWord(rows)
54 | if err != nil {
55 | return nil, err
56 | }
57 | words = append(words, w)
58 | }
59 | return words, rows.Err()
60 | }
61 |
62 | func scanWord(row Row) (w Word, err error) {
63 | err = row.Scan(
64 | &w.ID,
65 | &w.CN,
66 | &w.EN,
67 | &w.JP,
68 | &w.Kana,
69 | &w.Other,
70 | &w.Label,
71 | &w.Notes,
72 | &w.Links,
73 | &w.Images,
74 | &w.CTime,
75 | )
76 | return
77 | }
78 |
79 | func updateWord(tx TX, w *Word) error {
80 | _, err := tx.Exec(
81 | stmt.UpdateWord,
82 | w.CN,
83 | w.EN,
84 | w.JP,
85 | w.Kana,
86 | w.Other,
87 | w.Label,
88 | w.Notes,
89 | w.Links,
90 | w.Images,
91 | w.ID,
92 | )
93 | return err
94 | }
95 |
--------------------------------------------------------------------------------
/public/ts/dist/mj.js:
--------------------------------------------------------------------------------
1 | // 受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | /**
3 | * 函数名 m 来源于 Mithril, 也可以理解为 make 的简称,用来创建一个元素。
4 | */
5 | export function m(name) {
6 | if (typeof name == 'string') {
7 | return $(document.createElement(name));
8 | }
9 | return name.view;
10 | }
11 | function newComponent(name, id) {
12 | return {
13 | id: '#' + id,
14 | raw_id: id,
15 | view: m(name).attr('id', id),
16 | elem: () => $('#' + id)
17 | };
18 | }
19 | /**
20 | * 函数名 cc 意思是 create a component, 用来创建一个简单的组件。
21 | */
22 | export function cc(name, options) {
23 | let id = `r${Math.round(Math.random() * 100000000)}`;
24 | // 如果没有 options
25 | if (!options) {
26 | return newComponent(name, id);
27 | }
28 | // 后面就可以默认有 options
29 | if (options.id)
30 | id = options.id;
31 | const component = newComponent(name, id);
32 | if (options.attr)
33 | component.view.attr(options.attr);
34 | if (options.prop)
35 | component.view.prop(options.prop);
36 | if (options.css)
37 | component.view.css(options.css);
38 | if (options.classes)
39 | component.view.addClass(options.classes);
40 | if (options.text) {
41 | component.view.text(options.text);
42 | }
43 | else if (options.children) {
44 | component.view.append(options.children);
45 | }
46 | return component;
47 | }
48 | export function span(text) {
49 | return m('span').text(text);
50 | }
51 | export function appendToList(list, items) {
52 | items.forEach(item => {
53 | var _a;
54 | list.elem().append(m(item));
55 | (_a = item.init) === null || _a === void 0 ? void 0 : _a.call(item);
56 | });
57 | }
58 | export async function appendToListAsync(list, items) {
59 | var _a;
60 | for (const item of items) {
61 | list.elem().append(m(item));
62 | await ((_a = item.init) === null || _a === void 0 ? void 0 : _a.call(item));
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/stmt/statements.go:
--------------------------------------------------------------------------------
1 | package stmt
2 |
3 | const CreateTables = `
4 |
5 | CREATE TABLE IF NOT EXISTS word
6 | (
7 | id text PRIMARY KEY COLLATE NOCASE,
8 | cn text NOT NULL,
9 | en text NOT NULL,
10 | jp text NOT NULL,
11 | kana text NOT NULL,
12 | other text NOT NULL,
13 | label text NOT NULL,
14 | notes text NOT NULL,
15 | links text NOT NULL,
16 | images text NOT NULL,
17 | ctime int NOT NULL
18 | );
19 |
20 | CREATE INDEX IF NOT EXISTS idx_word_cn ON word(cn);
21 | CREATE INDEX IF NOT EXISTS idx_word_en ON word(en);
22 | CREATE INDEX IF NOT EXISTS idx_word_jp ON word(jp);
23 | CREATE INDEX IF NOT EXISTS idx_word_kana ON word(kana);
24 | CREATE INDEX IF NOT EXISTS idx_word_other ON word(other);
25 | CREATE INDEX IF NOT EXISTS idx_word_label ON word(label);
26 | CREATE INDEX IF NOT EXISTS idx_word_ctime ON word(ctime);
27 |
28 | CREATE TABLE IF NOT EXISTS metadata
29 | (
30 | name text NOT NULL UNIQUE,
31 | int_value int NOT NULL DEFAULT 0,
32 | text_value text NOT NULL DEFAULT ""
33 | );
34 | `
35 | const InsertIntValue = `INSERT INTO metadata (name, int_value) VALUES (?, ?);`
36 | const GetIntValue = `SELECT int_value FROM metadata WHERE name=?;`
37 | const UpdateIntValue = `UPDATE metadata SET int_value=? WHERE name=?;`
38 |
39 | const InsertTextValue = `INSERT INTO metadata (name, text_value) VALUES (?, ?);`
40 | const GetTextValue = `SELECT text_value FROM metadata WHERE name=?;`
41 | const UpdateTextValue = `UPDATE metadata SET text_value=? WHERE name=?;`
42 |
43 | const DeleteWord = `DELETE FROM word WHERE id=?;`
44 |
45 | const GetWordByID = `SELECT * FROM word WHERE id=?;`
46 |
47 | const CountAllWords = `SELECT count(*) FROM word;`
48 |
49 | // 由于要除重,最终会失去顺序,因此这里不用 order by
50 | const GetAllLabels = `SELECT label FROM word;`
51 |
52 | const InsertWord = `INSERT INTO word (
53 | id, cn, en, jp, kana, other, label, notes, links, images, ctime
54 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
55 |
56 | const UpdateWord = `UPDATE word SET
57 | cn=?, en=?, jp=?, kana=?, other=?, label=?, notes=?, links=?, images=?
58 | WHERE id=?;`
59 |
60 | const NewWords = `SELECT * FROM word ORDER BY ctime DESC LIMIT ?;`
61 |
62 | const GetByLabel = `
63 | SELECT * FROM word WHERE label LIKE ? ORDER BY ctime DESC LIMIT ?;`
64 |
65 | const GetByEmptyLabel = `
66 | SELECT * FROM word WHERE label='' ORDER BY ctime DESC LIMIT ?;`
67 |
--------------------------------------------------------------------------------
/public/ts/src/mj.ts:
--------------------------------------------------------------------------------
1 | // 受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 |
3 | // 安全提醒:使用 mjElement.append() 时,如果直接 append 字符串是可以注入 html 的,
4 | // 因此要特别小心,可以考虑用 span() 来包裹字符串。
5 |
6 | export type mjElement = JQuery;
7 |
8 | export interface mjComponent {
9 | id: string;
10 | raw_id: string;
11 | view: mjElement;
12 | elem: () => mjElement;
13 | init?: (arg?: any) => void;
14 | }
15 |
16 | /**
17 | * 函数名 m 来源于 Mithril, 也可以理解为 make 的简称,用来创建一个元素。
18 | */
19 | export function m(name: string | mjComponent): mjElement {
20 | if (typeof name == 'string') {
21 | return $(document.createElement(name));
22 | }
23 | return name.view;
24 | }
25 |
26 | interface ComponentOptions {
27 | id?: string;
28 | text?: string;
29 | children?: mjElement[];
30 | classes?: string;
31 | css?: {[index: string]:any};
32 | attr?: {[index: string]:any};
33 | prop?: {[index: string]:any};
34 | }
35 |
36 | function newComponent(name: string, id: string): mjComponent {
37 | return {
38 | id: '#'+id,
39 | raw_id: id,
40 | view: m(name).attr('id', id),
41 | elem: () => $('#'+id)
42 | };
43 | }
44 |
45 | /**
46 | * 函数名 cc 意思是 create a component, 用来创建一个简单的组件。
47 | */
48 | export function cc(name: string, options?: ComponentOptions): mjComponent {
49 | let id = `r${Math.round(Math.random() * 100000000)}`;
50 |
51 | // 如果没有 options
52 | if (!options) {
53 | return newComponent(name, id);
54 | }
55 | // 后面就可以默认有 options
56 |
57 | if (options.id) id = options.id;
58 | const component = newComponent(name, id);
59 |
60 | if (options.attr) component.view.attr(options.attr);
61 | if (options.prop) component.view.prop(options.prop);
62 | if (options.css) component.view.css(options.css);
63 | if (options.classes) component.view.addClass(options.classes);
64 | if (options.text) {
65 | component.view.text(options.text);
66 | } else if (options.children) {
67 | component.view.append(options.children);
68 | }
69 | return component;
70 | }
71 |
72 | export function span(text: string): mjElement {
73 | return m('span').text(text);
74 | }
75 |
76 | export function appendToList(list: mjComponent, items: mjComponent[]): void {
77 | items.forEach(item => {
78 | list.elem().append(m(item));
79 | item.init?.();
80 | });
81 | }
82 |
83 | export async function appendToListAsync(list: mjComponent, items: mjComponent[]) {
84 | for (const item of items) {
85 | list.elem().append(m(item));
86 | await item.init?.();
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/model/id.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "crypto/rand"
5 | "fmt"
6 | "math/big"
7 | "strconv"
8 | "strings"
9 | "sync"
10 | "time"
11 | )
12 |
13 | var mutex = sync.Mutex{}
14 |
15 | // ShortID 用来生成 “可爱” 的自增 ID.
16 | // 说它可爱是因为它的字符串形式:
17 | // 1.短 2.由数字和字母组成,不分大小写,且确保以字母开头 3.趋势自增,但又不明显自增 4.可利用前缀分类
18 | // 该 ID 由前缀、年份与自增数三部分组成,年份与自增数分别转 36 进制字符。
19 | // 前缀只能是单个字母,因为 ID 还是短一些的好。
20 | // 注意:前缀不分大小写, ShortID 本身也不分大小写。
21 | type ShortID struct {
22 | Prefix string
23 | Year int64
24 | Count int64
25 | }
26 |
27 | // FirstID 生成对应前缀的初始 id, 后续使用 Next 函数来获取下一个 id.
28 | // prefix 只能是单个英文字母。
29 | func FirstID(prefix string) (id ShortID, err error) {
30 | if len(prefix) > 1 {
31 | err = fmt.Errorf("the prefix [%s] is too long", prefix)
32 | return
33 | }
34 | prefix = strings.ToUpper(prefix)
35 | if prefix < "A" || prefix > "Z" {
36 | err = fmt.Errorf("the prefix [%s] is not an English character", prefix)
37 | return
38 | }
39 | id.Prefix = prefix
40 | id.Year = int64(time.Now().Year())
41 | return
42 | }
43 |
44 | // ParseID 把字符串形式的 id 转换为 IncreaseID.
45 | // (有“万年虫”问题,大概公元五万年时本算法会出错,当然,这个问题可以忽略。)
46 | func ParseID(strID string) (id ShortID, err error) {
47 | prefix := strID[:1]
48 | strYear := strID[1:4] // 可以姑且认为年份总是占三个字符
49 | strCount := strID[4:]
50 | year, err := strconv.ParseInt(strYear, 36, 0)
51 | if err != nil {
52 | return id, err
53 | }
54 | count, err := strconv.ParseInt(strCount, 36, 0)
55 | if err != nil {
56 | return id, err
57 | }
58 | id.Prefix = prefix
59 | id.Year = year
60 | id.Count = count
61 | return
62 | }
63 |
64 | // Next 使 id 自增一次,输出自增后的新 id.
65 | // 如果当前年份大于 id 中的年份,则年份进位,Count 重新计数。
66 | // 否则,年份不变,Count 加一。
67 | func (id ShortID) Next() ShortID {
68 | mutex.Lock()
69 | defer mutex.Unlock()
70 |
71 | nowYear := int64(time.Now().Year())
72 | if nowYear > id.Year {
73 | return ShortID{id.Prefix, nowYear, 0}
74 | }
75 | return ShortID{id.Prefix, id.Year, id.Count + 1}
76 | }
77 |
78 | // String 返回 id 的字符串形式。
79 | func (id ShortID) String() string {
80 | year := strconv.FormatInt(id.Year, 36)
81 | count := strconv.FormatInt(id.Count, 36)
82 | strID := id.Prefix + year + count
83 | return strings.ToUpper(strID)
84 | }
85 |
86 | // RandomID 返回一个上升趋势的随机 id, 由时间戳与随机数组成。
87 | // 时间戳确保其上升趋势(大致有序),随机数确保其随机性(防止被穷举, 防冲突)。
88 | // RandomID 考虑了 “生成 id 的速度”、 “并发防冲突” 与 “id 长度”
89 | // 这三者的平衡,适用于大多数中、小规模系统(当然,不适用于大型系统)。
90 | func RandomID() string {
91 | var max int64 = 100_000_000
92 | n, err := rand.Int(rand.Reader, big.NewInt(max))
93 | if err != nil {
94 | panic(err)
95 | }
96 | timestamp := time.Now().Unix()
97 | idInt64 := timestamp*max + n.Int64()
98 | return strconv.FormatInt(idInt64, 36)
99 | }
100 |
--------------------------------------------------------------------------------
/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "encoding/base64"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // WrapErrors 把多个错误合并为一个错误.
13 | func WrapErrors(allErrors ...error) (wrapped error) {
14 | for _, err := range allErrors {
15 | if err != nil {
16 | if wrapped == nil {
17 | wrapped = err
18 | } else {
19 | wrapped = fmt.Errorf("%v | %v", err, wrapped)
20 | }
21 | }
22 | }
23 | return
24 | }
25 |
26 | // ErrorContains returns NoCaseContains(err.Error(), substr)
27 | // Returns false if err is nil.
28 | func ErrorContains(err error, substr string) bool {
29 | if err == nil {
30 | return false
31 | }
32 | return noCaseContains(err.Error(), substr)
33 | }
34 |
35 | // noCaseContains reports whether substr is within s case-insensitive.
36 | func noCaseContains(s, substr string) bool {
37 | s = strings.ToLower(s)
38 | substr = strings.ToLower(substr)
39 | return strings.Contains(s, substr)
40 | }
41 |
42 | // Panic panics if err != nil
43 | func Panic(err error) {
44 | if err != nil {
45 | panic(err)
46 | }
47 | }
48 |
49 | func PathIsNotExist(name string) (ok bool) {
50 | _, err := os.Lstat(name)
51 | if os.IsNotExist(err) {
52 | ok = true
53 | err = nil
54 | }
55 | Panic(err)
56 | return
57 | }
58 |
59 | // PathIsExist .
60 | func PathIsExist(name string) bool {
61 | return !PathIsNotExist(name)
62 | }
63 |
64 | func TimeNow() int64 {
65 | return time.Now().Unix()
66 | }
67 |
68 | // Base64Encode .
69 | func Base64Encode(data []byte) string {
70 | return base64.StdEncoding.EncodeToString(data)
71 | }
72 |
73 | // Base64Decode .
74 | func Base64Decode(s string) ([]byte, error) {
75 | return base64.StdEncoding.DecodeString(s)
76 | }
77 |
78 | // Marshal64 converts data to json and encodes to base64 string.
79 | func Marshal64(data interface{}) (string, error) {
80 | dataJSON, err := json.Marshal(data)
81 | if err != nil {
82 | return "", err
83 | }
84 | return Base64Encode(dataJSON), err
85 | }
86 |
87 | // Unmarshal64_Wrong 是一个错误的的函数,不可使用!
88 | // 因为 value 是值,不是指针,因此 &value 无法传出去。
89 | func Unmarshal64_Wrong(data64 string, value interface{}) error {
90 | data, err := Base64Decode(data64)
91 | if err != nil {
92 | return err
93 | }
94 | return json.Unmarshal(data, &value)
95 | }
96 |
97 | // StrIndexNoCase returns the index of a string in the slice, case-insensitivly.
98 | // returns -1 if not found.
99 | func StrIndexNoCase(slice []string, item string) int {
100 | item = strings.ToLower(item)
101 | for i, v := range slice {
102 | if strings.ToLower(v) == item {
103 | return i
104 | }
105 | }
106 | return -1
107 | }
108 |
109 | // DeleteFromSlice .
110 | func DeleteFromSlice(slice []string, i int) []string {
111 | return append(slice[:i], slice[i+1:]...)
112 | }
113 |
114 | func BoolToInt(b bool) int64 {
115 | if b {
116 | return 1
117 | }
118 | return 0
119 | }
120 |
121 | func IntToBool(i int64) bool {
122 | return i >= 1
123 | }
124 |
--------------------------------------------------------------------------------
/public/ts/dist/settings.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc } from './mj.js';
3 | import * as util from './util.js';
4 | const Loading = util.CreateLoading('center');
5 | const Alerts = util.CreateAlerts();
6 | const Title = cc('h1', { text: 'Settings' });
7 | const naviBar = m('div')
8 | .addClass('text-right')
9 | .append(util.LinkElem('/', { text: 'Home' }));
10 | const DictplusAddrInput = util.create_input();
11 | const LocaltagsAddrInput = util.create_input();
12 | const DelayInput = util.create_box('checkbox', 'delay', 'checked');
13 | const delayCheck = util.create_check(DelayInput, 'Delay', '延迟');
14 | const SubmitAlerts = util.CreateAlerts();
15 | const SubmitBtn = cc('button', { id: 'submit', text: 'submit' }); // 这个按钮是隐藏不用的,为了防止按回车键提交表单
16 | const UpdateBtn = cc('button', { text: 'Update', classes: 'btn btn-fat' });
17 | const Form = cc('form', {
18 | attr: { autocomplete: 'off' },
19 | children: [
20 | util.create_item(DictplusAddrInput, 'Dictplus Address', 'dictplus 的默认网址,修改后下次启动时生效。'),
21 | util.create_item(LocaltagsAddrInput, 'Localtags Address', 'localtags 的网址,详细说明请看 README.md 中的 "插图" 部分。'),
22 | m('div')
23 | .addClass('mb-3')
24 | .append(delayCheck, m('div')
25 | .addClass('form-text')
26 | .text('延迟效果。有延迟可看到一部分渐变过程,没有延迟则程序反应速度会更快。')),
27 | m(SubmitAlerts),
28 | m('div')
29 | .addClass('text-center my-5')
30 | .append(m(SubmitBtn)
31 | .hide()
32 | .on('click', e => {
33 | e.preventDefault();
34 | return false; // 这个按钮是隐藏不用的,为了防止按回车键提交表单。
35 | }), m(UpdateBtn).on('click', e => {
36 | e.preventDefault();
37 | const delay = DelayInput.elem().prop('checked');
38 | const body = {
39 | DictplusAddr: util.val(DictplusAddrInput, 'trim'),
40 | LocaltagsAddr: util.val(LocaltagsAddrInput, 'trim'),
41 | Delay: delay,
42 | };
43 | util.ajax({
44 | method: 'POST',
45 | url: '/api/update-settings',
46 | alerts: SubmitAlerts,
47 | buttonID: UpdateBtn.id,
48 | contentType: 'json',
49 | body: body,
50 | }, () => {
51 | SubmitAlerts.insert('success', '更新成功');
52 | });
53 | })),
54 | ],
55 | });
56 | const download_db_area = m('div').append(m('h3').text('Database Backup'), m('hr'), m('p').text('点击下面的链接(或右键点击“另存为”)可下载数据库文件:'), m('p').append(util
57 | .LinkElem('/api/download-db', { text: 'sqlite database file' })
58 | .attr({ download: 'db-dictplus.sqlite' })));
59 | $('#root').append(m(Title), naviBar, m(Loading), m(Alerts), m(Form).hide(), download_db_area.addClass('my-5'));
60 | init();
61 | function init() {
62 | util.ajax({ method: 'GET', url: '/api/get-settings', alerts: Alerts }, resp => {
63 | const settings = resp;
64 | Form.elem().show();
65 | DictplusAddrInput.elem().val(settings.DictplusAddr);
66 | LocaltagsAddrInput.elem().val(settings.LocaltagsAddr);
67 | DelayInput.elem().prop('checked', settings.Delay);
68 | }, undefined, () => {
69 | Loading.hide();
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/public/ts/src/settings.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import {mjElement, mjComponent, m, cc, span, appendToList} from './mj.js';
3 | import * as util from './util.js';
4 |
5 | const Loading = util.CreateLoading('center');
6 | const Alerts = util.CreateAlerts();
7 |
8 | const Title = cc('h1', {text: 'Settings'});
9 |
10 | const naviBar = m('div')
11 | .addClass('text-right')
12 | .append(util.LinkElem('/', {text: 'Home'}));
13 |
14 | const DictplusAddrInput = util.create_input();
15 | const LocaltagsAddrInput = util.create_input();
16 | const DelayInput = util.create_box('checkbox', 'delay', 'checked');
17 | const delayCheck = util.create_check(DelayInput, 'Delay', '延迟');
18 |
19 | const SubmitAlerts = util.CreateAlerts();
20 | const SubmitBtn = cc('button', {id: 'submit', text: 'submit'}); // 这个按钮是隐藏不用的,为了防止按回车键提交表单
21 | const UpdateBtn = cc('button', {text: 'Update', classes: 'btn btn-fat'});
22 |
23 | const Form = cc('form', {
24 | attr: {autocomplete: 'off'},
25 | children: [
26 | util.create_item(
27 | DictplusAddrInput,
28 | 'Dictplus Address',
29 | 'dictplus 的默认网址,修改后下次启动时生效。'
30 | ),
31 | util.create_item(
32 | LocaltagsAddrInput,
33 | 'Localtags Address',
34 | 'localtags 的网址,详细说明请看 README.md 中的 "插图" 部分。'
35 | ),
36 | m('div')
37 | .addClass('mb-3')
38 | .append(
39 | delayCheck,
40 | m('div')
41 | .addClass('form-text')
42 | .text('延迟效果。有延迟可看到一部分渐变过程,没有延迟则程序反应速度会更快。')
43 | ),
44 | m(SubmitAlerts),
45 | m('div')
46 | .addClass('text-center my-5')
47 | .append(
48 | m(SubmitBtn)
49 | .hide()
50 | .on('click', e => {
51 | e.preventDefault();
52 | return false; // 这个按钮是隐藏不用的,为了防止按回车键提交表单。
53 | }),
54 | m(UpdateBtn).on('click', e => {
55 | e.preventDefault();
56 | const delay = DelayInput.elem().prop('checked');
57 | const body: util.Settings = {
58 | DictplusAddr: util.val(DictplusAddrInput, 'trim'),
59 | LocaltagsAddr: util.val(LocaltagsAddrInput, 'trim'),
60 | Delay: delay,
61 | };
62 | util.ajax(
63 | {
64 | method: 'POST',
65 | url: '/api/update-settings',
66 | alerts: SubmitAlerts,
67 | buttonID: UpdateBtn.id,
68 | contentType: 'json',
69 | body: body,
70 | },
71 | () => {
72 | SubmitAlerts.insert('success', '更新成功');
73 | }
74 | );
75 | })
76 | ),
77 | ],
78 | });
79 |
80 | const download_db_area = m('div').append(
81 | m('h3').text('Database Backup'),
82 | m('hr'),
83 | m('p').text('点击下面的链接(或右键点击“另存为”)可下载数据库文件:'),
84 | m('p').append(
85 | util
86 | .LinkElem('/api/download-db', {text: 'sqlite database file'})
87 | .attr({download: 'db-dictplus.sqlite'})
88 | )
89 | );
90 |
91 | $('#root').append(
92 | m(Title),
93 | naviBar,
94 | m(Loading),
95 | m(Alerts),
96 | m(Form).hide(),
97 | download_db_area.addClass('my-5')
98 | );
99 |
100 | init();
101 |
102 | function init() {
103 | util.ajax(
104 | {method: 'GET', url: '/api/get-settings', alerts: Alerts},
105 | resp => {
106 | const settings = resp as util.Settings;
107 | Form.elem().show();
108 | DictplusAddrInput.elem().val(settings.DictplusAddr);
109 | LocaltagsAddrInput.elem().val(settings.LocaltagsAddr);
110 | DelayInput.elem().prop('checked', settings.Delay);
111 | },
112 | undefined,
113 | () => {
114 | Loading.hide();
115 | }
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # dictplus
2 | 一个词典程序,但不只是一个词典程序
3 |
4 | ## 更新记录
5 |
6 | 这里只是简单记录最近的更新情况,更详细的更新说明请看 [Releases](https://github.com/ahui2016/dictplus/releases)
7 |
8 | - `2021-12-05` 修复了一个 html 标签注入的 bug (由于本软件是本地单用户使用,因此这个 bug 不算严重)
9 | - `2021-11-29` 添加了 Delay 选项、"无标签"按钮、数据库文件下载按钮
10 | - `2021-11-27` 修复了 [issues/1](https://github.com/ahui2016/dictplus/issues/1), 另外 Label 高级搜索增加了自动切换模式功能
11 | - `2021-11-26` 添加了 Label 高级搜索页面(当最近标签超过 10 个时才会出现入口), 添加了 gitee 仓库方便国内使用
12 | - `2021-11-25` 在 README 添加了 dark mode 截图(受到 [https://v2ex.com/t/817790](https://v2ex.com/t/817790) 的启发)
13 | - `2021-11-20` 添加了 Settings 页面,可设置默认端口
14 |
15 | 
16 | 
17 |
18 | ## 用途/目的
19 |
20 | 1. 记录一些不容易查到的单词
21 | 2. 记录一些一两句话就能说清楚的知识 (包括编程、常识、冷知识,甚至当作书签也很好用)
22 |
23 | ## 安装使用
24 |
25 | - Windows 用户可
26 | [直接下载 exe 文件](https://github.com/ahui2016/dictplus/releases) (国内下载地址 https://gitee.com/ipelago/dictplus/releases)
27 | - Mac 或 Linux 先正确安装 [git](https://git-scm.com/downloads) 和 [Go 语言环境](https://golang.google.cn/doc/install) 然后在终端执行以下命令:
28 | (国内用户可使用 `https://gitee.com/ipelago/dictplus.git` 来替换下面 git clone 后的网址)
29 | ```
30 | $ cd ~
31 | $ git clone https://github.com/ahui2016/dictplus.git
32 | $ cd dictplus
33 | $ git checkout tags/2021-12-05 -b 2021-12-05
34 | $ go build
35 | $ ./dictplus
36 | ```
37 | - 如果一切顺利,用浏览器访问 http://127.0.0.1 即可进行本地访问。如有端口冲突,可使用参数 `-addr` 更改端口,比如:
38 | ```
39 | $ ./dictplus -addr 127.0.0.1:955
40 | ```
41 |
42 | ## 搜索
43 |
44 | - 搜索不区分大小写。
45 | - 在首页的 Recent Labels (最近标签) 末尾有一个 all labels 按钮,点击该按钮可进入 Search by Label 页面。
46 | - 在 Search by Label 页面列出了全部标签,并且区分了大类小类,还能选择不同的搜索方式,非常方便。
47 |
48 | ## 链接
49 |
50 | - 每个词条可以拥有多个链接,在输入框中用回车区分(即每行一个链接)
51 | - 一个词条如果有链接,在搜索结果列表中就会有 link 按钮
52 | - 点击 link 按钮相当于点击该词条的第一个链接,更多链接则需要点击 view 查看详细内容
53 | - 如果该词条有多个链接, 那么 link 按钮的文字会变成 links, 因此如果看到 link 没有复数就知道不需要点击 view 按钮去查看更多链接了
54 | - 这样设计是为了兼顾功能性与界面的简洁
55 |
56 | ## 插图
57 |
58 | - 插图功能需要与 [localtags](https://github.com/ahui2016/localtags) 搭配使用,把图片上传到 localtags 后可获得文件 ID。
59 | - 在添加或编辑词条时,可在 Images 栏内填写 localtags 里的图片文件的 ID。
60 | - 点击 view 按钮查看词条的详细信息即可看到图片,点击图片名称可跳转到 localtags, 方便更改图片名称或删除图片。(参考下面的截图1)
61 |
62 | ## 备份
63 |
64 | - 第一次运行程序后,会在程序所在文件夹内自动生成 db-dictplus.sqlite 文件,只要备份这一个文件即可。
65 | - 在 Settings 页面也可下载 db-dictplus.sqlite 文件。
66 | - 以后会增加将整个数据库导出为一个 json 文件的功能。
67 |
68 | ## 更新
69 |
70 | - 更新前请先备份 db-dictplus.sqlite 文件(通常不备份也不影响更新,只是以防万一,总之常备份准没错)
71 | - 如果你是下载 zip 包在 Windows 里使用,直接用新文件覆盖旧文件即可(注意别覆盖 db-dictplus.sqlite 文件)。
72 | - 如果你是通过源码安装,可使用以下命令
73 | ```
74 | $ cd ~/dictplus
75 | $ git pull
76 | $ git checkout tags/2021-12-05 -b 2021-12-05
77 | $ go build
78 | $ ./dictplus
79 | ```
80 | - 更新后有时前端会受缓存影响,可在浏览器里按 Ctrl-Shift-R 强制刷新。
81 |
82 |
83 | ## 本站前端使用 mj.js
84 |
85 | - mj.js 是一个受 Mithril.js 启发的基于 jQuery 实现的极简框架,对于曾经用过 jQuery 的人来说,学习成本接近零。详见 https://github.com/ahui2016/mj.js
86 | - 如果需要修改本软件的前端代码,可以直接修改 public/ts/dist 里的 js 文件。
87 | - 也可修改 public/ts/src 文件夹内的 ts 文件,修改后在 public/ts/ 文件夹内执行 tsc 命令即可重新生成必要的 js 文件。
88 |
89 | ## 截图
90 |
91 | - 本页面的图片会跟随 github 的 light mode / dark mode 设定而自动切换,参考 [https://v2ex.com/t/817790](https://v2ex.com/t/817790)
92 | - dictplus 本身也会跟随系统的 light mode / dark mode 设定而自动切换主题。
93 |
94 | 图1:
95 |
96 | 
97 | 
98 |
99 | 图2:
100 |
101 | 
102 | 
103 |
104 | 图3:
105 |
106 | 
107 | 
108 |
--------------------------------------------------------------------------------
/database/metadata.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "encoding/json"
6 |
7 | "ahui2016.github.com/dictplus/model"
8 | "ahui2016.github.com/dictplus/stmt"
9 | "ahui2016.github.com/dictplus/util"
10 | )
11 |
12 | const (
13 | word_id_key = "word-id-key"
14 | word_id_prefix = "W"
15 | history_id_key = "history-id-key" // 搜索历史,用换行符分隔
16 | recent_labels_key = "recent-labels-key" // 最近标签,用换行符分隔
17 | dictplus_addr_key = "dictplus-address"
18 | localtags_addr_key = "localtags-address"
19 | settings_key = "settings-key"
20 | )
21 |
22 | func getTextValue(key string, tx TX) (value string, err error) {
23 | row := tx.QueryRow(stmt.GetTextValue, key)
24 | err = row.Scan(&value)
25 | return
26 | }
27 |
28 | func updateTextValue(key, v string, tx TX) error {
29 | _, err := tx.Exec(stmt.UpdateTextValue, v, key)
30 | return err
31 | }
32 |
33 | func getIntValue(key string, tx TX) (value int64, err error) {
34 | row := tx.QueryRow(stmt.GetIntValue, key)
35 | err = row.Scan(&value)
36 | return
37 | }
38 |
39 | func updateIntValue(key string, v int64, tx TX) error {
40 | _, err := tx.Exec(stmt.UpdateIntValue, v, key)
41 | return err
42 | }
43 |
44 | func getCurrentID(key string, tx TX) (id model.ShortID, err error) {
45 | strID, err := getTextValue(key, tx)
46 | if err != nil {
47 | return
48 | }
49 | return model.ParseID(strID)
50 | }
51 |
52 | func initFirstID(key, prefix string, tx TX) (err error) {
53 | if _, err = getCurrentID(key, tx); err != sql.ErrNoRows {
54 | return err
55 | }
56 | id, err := model.FirstID(prefix)
57 | if err != nil {
58 | return err
59 | }
60 | _, err = tx.Exec(stmt.InsertTextValue, key, id.String())
61 | return
62 | }
63 |
64 | func getNextID(tx TX, key string) (nextID string, err error) {
65 | currentID, err := getCurrentID(key, tx)
66 | if err != nil {
67 | return
68 | }
69 | nextID = currentID.Next().String()
70 | err = updateTextValue(key, nextID, tx)
71 | return
72 | }
73 |
74 | func (db *DB) initTextEntry(k, v string) error {
75 | if _, err := getTextValue(k, db.DB); err != sql.ErrNoRows {
76 | return err
77 | }
78 | return db.Exec(stmt.InsertTextValue, k, v)
79 | }
80 |
81 | func (db *DB) initIntEntry(k string, v int64) error {
82 | if _, err := getIntValue(k, db.DB); err != sql.ErrNoRows {
83 | return err
84 | }
85 | return db.Exec(stmt.InsertIntValue, k, v)
86 | }
87 |
88 | func (db *DB) initSettings(s Settings) error {
89 | if _, err := getTextValue(settings_key, db.DB); err != sql.ErrNoRows {
90 | return err
91 | }
92 | // 由于以前使用了 localtags_addr_key 和 dictplus_addr_key, 因此需要这几行兼容代码。
93 | localtagsAddr, _ := getTextValue(localtags_addr_key, db.DB)
94 | if localtagsAddr != "" {
95 | s.LocaltagsAddr = localtagsAddr
96 | if err := updateTextValue(localtags_addr_key, "", db.DB); err != nil {
97 | return nil
98 | }
99 | }
100 | dictplusAddr, _ := getTextValue(dictplus_addr_key, db.DB)
101 | if dictplusAddr != "" {
102 | s.DictplusAddr = dictplusAddr
103 | if err := updateTextValue(dictplus_addr_key, "", db.DB); err != nil {
104 | return err
105 | }
106 | }
107 | data64, err := util.Marshal64(s)
108 | if err != nil {
109 | return err
110 | }
111 | return db.Exec(stmt.InsertTextValue, settings_key, data64)
112 | }
113 |
114 | func (db *DB) GetHistory() (string, error) {
115 | return getTextValue(history_id_key, db.DB)
116 | }
117 |
118 | func (db *DB) UpdateHistory(v string) error {
119 | oldHistory, err := db.GetHistory()
120 | if err != nil {
121 | return err
122 | }
123 | newHistory := addAndLimit(v, oldHistory, HistoryLimit)
124 | if newHistory == oldHistory {
125 | return nil
126 | }
127 | return updateTextValue(history_id_key, newHistory, db.DB)
128 | }
129 |
130 | func (db *DB) GetRecentLabels() (string, error) {
131 | return getTextValue(recent_labels_key, db.DB)
132 | }
133 |
134 | func (db *DB) GetSettings() (s Settings, err error) {
135 | data64, err := getTextValue(settings_key, db.DB)
136 | if err != nil {
137 | return s, err
138 | }
139 | data, err := util.Base64Decode(data64)
140 | if err != nil {
141 | return s, err
142 | }
143 | err = json.Unmarshal(data, &s)
144 | return
145 | }
146 |
147 | func (db *DB) UpdateSettings(s Settings) error {
148 | data64, err := util.Marshal64(s)
149 | if err != nil {
150 | return err
151 | }
152 | return updateTextValue(settings_key, data64, db.DB)
153 | }
154 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
4 | github.com/labstack/echo/v4 v4.6.1 h1:OMVsrnNFzYlGSdaiYGHbgWQnr+JM7NG+B9suCPie14M=
5 | github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
6 | github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
7 | github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
8 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
9 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
10 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
11 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
12 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
13 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
14 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
15 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
16 | github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
17 | github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
21 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
22 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
23 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
24 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
25 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
26 | github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
27 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
28 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
29 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
30 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
31 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e h1:+b/22bPvDYt4NPDcy4xAGCmON713ONAWFeY3Z7I3tR8=
32 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
33 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
34 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
35 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
36 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
37 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
38 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
39 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
40 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41 | golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 h1:xrCZDmdtoloIiooiA9q0OQb9r8HejIHYoHGhGCe1pGg=
42 | golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
43 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
44 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
45 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
46 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
47 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
48 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
49 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
51 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
52 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
53 |
--------------------------------------------------------------------------------
/public/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-size: 14px;
3 | max-width: 680px;
4 | padding: 0 10px;
5 | margin: 40px auto;
6 | line-height: 1.5;
7 | }
8 |
9 | td {
10 | vertical-align: top;
11 | }
12 |
13 | a {
14 | text-decoration: none;
15 | }
16 |
17 | a:hover {
18 | text-decoration: underline;
19 | }
20 |
21 | .form-label {
22 | font-weight: bold;
23 | display: inline-block;
24 | }
25 |
26 | .WordItem {
27 | margin-bottom: 1.5rem;
28 | }
29 |
30 | .cursor-pointer {
31 | cursor: pointer !important;
32 | }
33 |
34 | .pl-2 {
35 | padding-left: 0.5rem;
36 | }
37 | .ml-1 {
38 | margin-left: 0.25rem;
39 | }
40 | .ml-2 {
41 | margin-left: 0.5rem;
42 | }
43 | .ml-3 {
44 | margin-left: 1rem;
45 | }
46 | .mt-2 {
47 | margin-top: 0.5rem;
48 | }
49 | .mt-3 {
50 | margin-top: 1rem;
51 | }
52 | .mb-0 {
53 | margin-bottom: 0;
54 | }
55 | .mb-3 {
56 | margin-bottom: 1rem;
57 | }
58 | .mb-4 {
59 | margin-bottom: 2rem;
60 | }
61 | .mb-5 {
62 | margin-bottom: 3rem;
63 | }
64 | .my-3 {
65 | margin-top: 1rem;
66 | margin-bottom: 1rem;
67 | }
68 | .my-5 {
69 | margin-top: 3rem;
70 | margin-bottom: 3rem;
71 | }
72 |
73 | .nowrap {
74 | white-space: nowrap;
75 | }
76 |
77 | .text-center {
78 | text-align: center;
79 | }
80 | .text-right {
81 | text-align: right;
82 | }
83 | .form-textarea {
84 | resize: vertical;
85 | }
86 | .btn-fat {
87 | padding: .375rem .75rem;
88 | /* border-radius: .25rem; */
89 | }
90 | .form-textinput {
91 | border: 1px solid;
92 | border-radius: .25rem;
93 | }
94 | .form-textinput-fat {
95 | display: block;
96 | width: 95%;
97 | line-height: 1.5;
98 | padding: .375rem .75rem;
99 | border: 1px solid;
100 | border-radius: .25rem;
101 | }
102 |
103 | .badge-grey {
104 | cursor: default;
105 | line-height: 1;
106 | font-size: .75em;
107 | padding: .35em .65em;
108 | border-radius: 50rem;
109 | display: inline-block;
110 | }
111 |
112 | .Loading {
113 | font-weight: bold;
114 | animation: blinker 2s linear infinite;
115 | }
116 |
117 | .Hint {
118 | border-radius: .5rem;
119 | padding: .375rem .75rem;
120 | }
121 |
122 | .ImagesPreview img {
123 | width: 100%;
124 | }
125 |
126 | @keyframes blinker {
127 | 50% {
128 | opacity: 0;
129 | }
130 | }
131 |
132 | @media (prefers-color-scheme: dark) {
133 | :root {
134 | --background-color: #000000;
135 | --grey: #52525b;
136 | --default-color: #71717a;
137 | --primary-color: #a1a1aa;
138 | --brighter: #c4c4c8;
139 | }
140 | body {
141 | color: var(--default-color);
142 | background-color: var(--background-color);
143 | }
144 |
145 | a {
146 | color:#0b7285;
147 | }
148 |
149 | h1 a {
150 | color: var(--default-color);
151 | }
152 | .BlogIDArea a {
153 | color: var(--grey);
154 | }
155 | .BlogName a {
156 | font-weight: bold;
157 | color: var(--default-color);
158 | }
159 |
160 | .text-default {
161 | color: var(--default-color);
162 | }
163 |
164 | .text-grey,
165 | .form-text {
166 | color: var(--grey);
167 | }
168 |
169 | .badge-grey {
170 | color: var(--primary-color);
171 | background-color: var(--grey);
172 | }
173 |
174 | .alert-danger {
175 | color: #d6336c;
176 | }
177 | .alert-success {
178 | color: #099268;
179 | }
180 | .alert-primary {
181 | color: var(--primary-color);
182 | }
183 |
184 | .btn {
185 | border: 1px solid;
186 | border-radius: .25rem;
187 | background-color: var(--grey);
188 | color: var(--brighter);
189 | border-color: var(--brighter);
190 | }
191 | .btn:hover {
192 | background-color: var(--default-color);
193 | color: #e4e4e7;
194 | }
195 |
196 | .form-textinput {
197 | color: var(--primary-color);
198 | border-color: var(--default-color);
199 | background-color: var(--background-color);
200 | }
201 |
202 | .Hint {
203 | background-color: #454545;
204 | }
205 | }
206 |
207 | @media (prefers-color-scheme: light) {
208 | :root {
209 | --background-color: #fefefe;
210 | --border-color: #71717a;
211 | --default-color: #454545;
212 | --grey: #a1a1aa;
213 | }
214 | body {
215 | color: var(--default-color);
216 | background-color: var(--background-color);
217 | }
218 |
219 | a {
220 | color:#1864ab;
221 | }
222 |
223 | h1 a {
224 | color: var(--default-color);
225 | }
226 | .BlogIDArea a {
227 | color: var(--grey);
228 | }
229 | .BlogName a {
230 | font-weight: bold;
231 | color: var(--default-color);
232 | }
233 |
234 | .text-default {
235 | color: var(--default-color);
236 | }
237 |
238 | .text-grey,
239 | .form-text {
240 | color: var(--grey);
241 | }
242 |
243 | .badge-grey {
244 | color: var(--background-color);
245 | background-color: var(--grey);
246 | }
247 |
248 | pre {
249 | background-color: #fafafa;
250 | }
251 | .alert-danger {
252 | color: #e64980;
253 | }
254 | .alert-success {
255 | color: #0ca678;
256 | }
257 | .alert-primary {
258 | color: black;
259 | }
260 | .btn {
261 | color: var(--default-color);
262 | }
263 | .form-textinput {
264 | border-color: var(--border-color);
265 | }
266 |
267 | .Hint {
268 | background-color: #f4fce3;
269 | }
270 | }
271 |
272 |
--------------------------------------------------------------------------------
/public/ts/dist/word-info.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc, span } from './mj.js';
3 | import * as util from './util.js';
4 | let wordID = util.getUrlParam('id');
5 | let localtagsAddr = "http://127.0.0.1:53549";
6 | const Loading = util.CreateLoading('center');
7 | const Alerts = util.CreateAlerts();
8 | const titleArea = m('div').addClass('text-center').append(m('h1').text('Details of an item'));
9 | const EditBtn = cc('a', {
10 | text: 'Edit',
11 | attr: { href: '/public/edit-word.html?id=' + wordID },
12 | classes: 'ml-2',
13 | });
14 | const DelBtn = cc('a', { text: 'delete', classes: 'ml-2', attr: { href: '#' } });
15 | const naviBar = m('div').addClass('text-right').append(util.LinkElem('/', { text: 'Home' }), m(EditBtn).hide(), m(DelBtn).on('click', e => {
16 | e.preventDefault();
17 | util.disable(DelBtn);
18 | Alerts.insert('danger', '当 delete 按钮变红时,再点击一次可删除该词条,不可恢复。');
19 | setTimeout(() => {
20 | util.enable(DelBtn);
21 | DelBtn.elem().css('color', 'red').off().on('click', e => {
22 | e.preventDefault();
23 | util.ajax({ method: 'POST', url: '/api/delete-word', alerts: Alerts, buttonID: DelBtn.id, body: { id: wordID } }, () => {
24 | Alerts.clear().insert('success', '已彻底删除该词条。');
25 | WordInfo.elem().hide();
26 | EditBtn.elem().hide();
27 | DelBtn.elem().hide();
28 | });
29 | });
30 | }, 2000);
31 | }).hide());
32 | const WordInfo = cc('table');
33 | WordInfo.append = (key, value) => {
34 | WordInfo.elem().append(create_table_row(key, value));
35 | return WordInfo;
36 | };
37 | const ImagesAlerts = util.CreateAlerts();
38 | const ImagesList = cc('div', { classes: 'ImagesPreview' });
39 | const ImagesListArea = cc('div', { children: [
40 | m('h3').text('Images Preview'),
41 | m('hr'),
42 | m(ImagesAlerts),
43 | m(ImagesList).addClass('my-3'),
44 | ] });
45 | $('#root').append(titleArea, naviBar, m(Loading), m(Alerts).addClass('my-5'), m(WordInfo).hide(), m(ImagesListArea).addClass('my-5').hide());
46 | init();
47 | function init() {
48 | if (!wordID) {
49 | Loading.hide();
50 | Alerts.insert('danger', 'the blog id is empty, need a blog id');
51 | return;
52 | }
53 | initLocaltagsAddr();
54 | }
55 | function initLocaltagsAddr() {
56 | util.ajax({ method: 'GET', url: '/api/get-settings', alerts: Alerts }, resp => {
57 | const settings = resp;
58 | localtagsAddr = settings.LocaltagsAddr;
59 | initWord();
60 | });
61 | }
62 | function initWord() {
63 | util.ajax({ method: 'POST', url: '/api/get-word', alerts: Alerts, body: { id: wordID } }, resp => {
64 | const w = resp;
65 | $('title').text(`Details (id:${wordID}) - dictplus`);
66 | const Links = cc('div', { classes: 'WordLinks' });
67 | const Images = cc('div', { classes: 'WordImages' });
68 | const Notes = cc('pre', { classes: 'WordNotes' });
69 | const ctime = dayjs.unix(w.CTime).format('YYYY-MM-DD HH:mm:ss');
70 | EditBtn.elem().show();
71 | DelBtn.elem().show();
72 | WordInfo.elem().show();
73 | WordInfo
74 | .append('ID', w.ID)
75 | .append('CN', w.CN)
76 | .append('EN', w.EN)
77 | .append('JP', w.JP)
78 | .append('Kana', w.Kana)
79 | .append('Other', w.Other)
80 | .append('Label', w.Label)
81 | .append('Links', m(Links))
82 | .append('Images', m(Images))
83 | .append('Notes', m(Notes).text(w.Notes))
84 | .append('CTime', ctime);
85 | if (w.Links) {
86 | w.Links.split('\n').forEach(link => {
87 | Links.elem().append(util.LinkElem(link, { blank: true }));
88 | });
89 | }
90 | if (w.Images) {
91 | ImagesListArea.elem().show();
92 | w.Images.split(', ').forEach(id => {
93 | const img = imageUrl(id);
94 | Images.elem().append(span(id));
95 | const ImageItem = cc('div', { id: id, classes: 'mb-4' });
96 | ImagesList.elem().append(m(ImageItem).append('↓ ', util.LinkElem(`${localtagsAddr}/light/search?fileid=${id}`, { text: id, blank: true }).addClass('ImageName'), m('img').attr({ src: img })));
97 | fillImageName(id);
98 | });
99 | }
100 | }, undefined, () => {
101 | Loading.hide();
102 | });
103 | }
104 | function fillImageName(id) {
105 | util.ajax({ method: 'POST', url: `${localtagsAddr}/api/search-by-id`, alerts: ImagesAlerts, body: { id: id } }, resp => {
106 | const files = resp;
107 | $(`#${id} .ImageName`).text(files[0].Name);
108 | });
109 | }
110 | function create_table_row(key, value) {
111 | const tr = m('tr').append(m('td').addClass('nowrap').text(key));
112 | if (typeof value == 'string') {
113 | tr.append(m('td').addClass('pl-2').text(value));
114 | }
115 | else {
116 | tr.append(m('td').addClass('pl-2').append(value));
117 | }
118 | return tr;
119 | }
120 | function imageUrl(id) {
121 | return `${localtagsAddr}/mainbucket/${id}`;
122 | }
123 |
--------------------------------------------------------------------------------
/public/ts/src/word-info.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { mjElement, mjComponent, m, cc, span, appendToList } from './mj.js';
3 | import * as util from './util.js';
4 |
5 | interface ImageFile {
6 | Name: string;
7 | }
8 |
9 | let wordID = util.getUrlParam('id');
10 | let localtagsAddr = "http://127.0.0.1:53549";
11 |
12 | const Loading = util.CreateLoading('center');
13 | const Alerts = util.CreateAlerts();
14 |
15 | const titleArea = m('div').addClass('text-center').append(
16 | m('h1').text('Details of an item')
17 | );
18 |
19 | const EditBtn = cc('a', {
20 | text:'Edit',
21 | attr:{href:'/public/edit-word.html?id='+wordID},
22 | classes:'ml-2',
23 | });
24 | const DelBtn = cc('a', {text:'delete',classes:'ml-2',attr:{href:'#'}});
25 | const naviBar = m('div').addClass('text-right').append(
26 | util.LinkElem('/',{text:'Home'}),
27 | m(EditBtn).hide(),
28 | m(DelBtn).on('click', e => {
29 | e.preventDefault();
30 | util.disable(DelBtn);
31 | Alerts.insert('danger', '当 delete 按钮变红时,再点击一次可删除该词条,不可恢复。');
32 | setTimeout(() => {
33 | util.enable(DelBtn);
34 | DelBtn.elem().css('color','red').off().on('click', e => {
35 | e.preventDefault();
36 | util.ajax({method:'POST',url:'/api/delete-word',alerts:Alerts,buttonID:DelBtn.id,body:{id:wordID}},
37 | () => {
38 | Alerts.clear().insert('success', '已彻底删除该词条。');
39 | WordInfo.elem().hide();
40 | EditBtn.elem().hide();
41 | DelBtn.elem().hide();
42 | });
43 | });
44 | }, 2000)
45 | }).hide(),
46 | );
47 |
48 | interface WordInfoList extends mjComponent {
49 | append: (key:string, value:string|mjElement) => WordInfoList;
50 | }
51 | const WordInfo = cc('table') as WordInfoList;
52 | WordInfo.append = (key:string, value:string|mjElement) => {
53 | WordInfo.elem().append( create_table_row(key, value) );
54 | return WordInfo;
55 | };
56 |
57 | const ImagesAlerts = util.CreateAlerts();
58 | const ImagesList = cc('div', {classes:'ImagesPreview'});
59 | const ImagesListArea = cc('div', {children:[
60 | m('h3').text('Images Preview'),
61 | m('hr'),
62 | m(ImagesAlerts),
63 | m(ImagesList).addClass('my-3'),
64 | ]});
65 |
66 | $('#root').append(
67 | titleArea,
68 | naviBar,
69 | m(Loading),
70 | m(Alerts).addClass('my-5'),
71 | m(WordInfo).hide(),
72 | m(ImagesListArea).addClass('my-5').hide(),
73 | );
74 |
75 | init();
76 |
77 | function init() {
78 | if (!wordID) {
79 | Loading.hide();
80 | Alerts.insert('danger', 'the blog id is empty, need a blog id');
81 | return;
82 | }
83 |
84 | initLocaltagsAddr();
85 |
86 | }
87 |
88 | function initLocaltagsAddr(): void {
89 | util.ajax({method:'GET',url:'/api/get-settings',alerts:Alerts},
90 | resp => {
91 | const settings = resp as util.Settings;
92 | localtagsAddr = settings.LocaltagsAddr;
93 | initWord();
94 | });
95 | }
96 |
97 | function initWord(): void {
98 | util.ajax({method:'POST',url:'/api/get-word',alerts:Alerts,body:{id:wordID}},
99 | resp => {
100 | const w = resp as util.Word;
101 | $('title').text(`Details (id:${wordID}) - dictplus`);
102 |
103 | const Links = cc('div', {classes:'WordLinks'});
104 | const Images = cc('div', {classes:'WordImages'});
105 | const Notes = cc('pre', {classes:'WordNotes'});
106 | const ctime = dayjs.unix(w.CTime).format('YYYY-MM-DD HH:mm:ss');
107 |
108 | EditBtn.elem().show();
109 | DelBtn.elem().show();
110 | WordInfo.elem().show();
111 | WordInfo
112 | .append('ID', w.ID)
113 | .append('CN', w.CN)
114 | .append('EN', w.EN)
115 | .append('JP', w.JP)
116 | .append('Kana', w.Kana)
117 | .append('Other', w.Other)
118 | .append('Label', w.Label)
119 | .append('Links', m(Links))
120 | .append('Images', m(Images))
121 | .append('Notes', m(Notes).text(w.Notes))
122 | .append('CTime', ctime);
123 |
124 | if (w.Links) {
125 | w.Links.split('\n').forEach(link => {
126 | Links.elem().append(util.LinkElem(link,{blank:true}));
127 | });
128 | }
129 | if (w.Images) {
130 | ImagesListArea.elem().show();
131 | w.Images.split(', ').forEach(id => {
132 | const img = imageUrl(id);
133 | Images.elem().append( span(id) );
134 | const ImageItem = cc('div', {id:id,classes:'mb-4'});
135 | ImagesList.elem().append(m(ImageItem).append(
136 | '↓ ',
137 | util.LinkElem(`${localtagsAddr}/light/search?fileid=${id}`, {text:id,blank:true}).addClass('ImageName'),
138 | m('img').attr({src:img}),
139 | ));
140 | fillImageName(id);
141 | });
142 | }
143 | }, undefined, () => {
144 | Loading.hide();
145 | });
146 | }
147 |
148 | function fillImageName(id: string): void {
149 | util.ajax({method:'POST',url:`${localtagsAddr}/api/search-by-id`,alerts:ImagesAlerts,body:{id:id}},
150 | resp => {
151 | const files = resp as ImageFile[];
152 | $(`#${id} .ImageName`).text(files[0].Name);
153 | });
154 | }
155 |
156 | function create_table_row(key:string,value:string|mjElement): mjElement {
157 | const tr = m('tr').append(m('td').addClass('nowrap').text(key));
158 | if (typeof value == 'string') {
159 | tr.append(m('td').addClass('pl-2').text(value));
160 | } else {
161 | tr.append(m('td').addClass('pl-2').append(value));
162 | }
163 | return tr;
164 | }
165 |
166 | function imageUrl(id:string): string {
167 | return `${localtagsAddr}/mainbucket/${id}`;
168 | }
--------------------------------------------------------------------------------
/public/dayjs.min.js:
--------------------------------------------------------------------------------
1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs=e()}(this,(function(){"use strict";var t=1e3,e=6e4,n=36e5,r="millisecond",i="second",s="minute",u="hour",a="day",o="week",f="month",h="quarter",c="year",d="date",$="Invalid Date",l=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,M={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_")},m=function(t,e,n){var r=String(t);return!r||r.length>=e?t:""+Array(e+1-r.length).join(n)+t},g={s:m,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+m(r,2,"0")+":"+m(i,2,"0")},m:function t(e,n){if(e.date() limit {
106 | itemArr = itemArr[:limit]
107 | }
108 | return strings.Join(itemArr, "\n")
109 | }
110 |
111 | func addOrMoveToTop(items []string, item string) []string {
112 | i := util.StrIndexNoCase(items, item)
113 | if i == 0 {
114 | return items
115 | }
116 | if i > 0 {
117 | items = util.DeleteFromSlice(items, i)
118 | }
119 | return append([]string{item}, items...)
120 | }
121 |
122 | func updateLabels(label string, tx TX) error {
123 | oldLabels, err := getTextValue(recent_labels_key, tx)
124 | if err != nil {
125 | return err
126 | }
127 | newLabels := addAndLimit(label, oldLabels, LabelsLimit)
128 | if newLabels == oldLabels {
129 | return nil
130 | }
131 | return updateTextValue(recent_labels_key, newLabels, tx)
132 | }
133 |
134 | func (db *DB) UpdateWord(w *Word) error {
135 | word, err := db.GetWordByID(w.ID)
136 | if err != nil {
137 | return err
138 | }
139 | tx := db.mustBegin()
140 | defer tx.Rollback()
141 |
142 | if w.Label != word.Label {
143 | if err = updateLabels(w.Label, tx); err != nil {
144 | return err
145 | }
146 | }
147 | if err = updateWord(tx, w); err != nil {
148 | return err
149 | }
150 | err = tx.Commit()
151 | return err
152 | }
153 |
154 | func (db *DB) getWordsByLabel(mode, pattern string, limit int) (*sql.Rows, error) {
155 | if mode == "StartsWith" {
156 | pattern = pattern + "%"
157 | } else if mode == "Contains" {
158 | pattern = "%" + pattern + "%"
159 | } else if mode == "EndsWith" {
160 | pattern = "%" + pattern
161 | } else {
162 | return nil, fmt.Errorf("unknown mode [], the mode should be StartsWith or Contains or EndsWith")
163 | }
164 | return db.DB.Query(stmt.GetByLabel, pattern, limit)
165 | }
166 |
167 | func (db *DB) GetWords(pattern string, fields []string, limit int) (words []Word, err error) {
168 | if len(fields) == 0 {
169 | return nil, fmt.Errorf("no field to search")
170 | }
171 | if pattern == "" {
172 | return nil, fmt.Errorf("nothing to search")
173 | }
174 |
175 | var rows *sql.Rows
176 |
177 | query := "SELECT * FROM word where"
178 |
179 | if fields[0] == "SearchByEmptyLabel" {
180 | rows, err = db.DB.Query(stmt.GetByEmptyLabel, limit)
181 | } else if fields[0] == "SearchByLabel" {
182 | rows, err = db.getWordsByLabel(fields[1], pattern, limit)
183 | } else if fields[0] == "Recently-Added" {
184 | rows, err = db.DB.Query(stmt.NewWords, NewWordsLimit)
185 | } else {
186 | for i, field := range fields {
187 | if i == 0 {
188 | query += fmt.Sprintf(" %s LIKE ?", field)
189 | } else {
190 | query += fmt.Sprintf(" OR %s LIKE ?", field)
191 | }
192 | }
193 | query += " ORDER BY ctime DESC LIMIT ?;"
194 | // 拼接后的 query 大概像这个样子 SELECT * FROM word WHERE CN LIKE ? OR EN LIKE ? OR JP LIKE ? ORDER BY ctime DESC LIMIT ?;
195 | args := []interface{}{}
196 | pattern = "%" + pattern + "%"
197 | for range fields {
198 | args = append(args, pattern)
199 | }
200 | args = append(args, limit)
201 | rows, err = db.DB.Query(query, args...)
202 | }
203 | if err != nil {
204 | return nil, err
205 | }
206 | defer rows.Close()
207 | words, err = scanWords(rows)
208 | return
209 | }
210 |
211 | func (db *DB) GetAllLabels() (labels []string, err error) {
212 | rows, err := db.DB.Query(stmt.GetAllLabels)
213 | if err != nil {
214 | return nil, err
215 | }
216 | for rows.Next() {
217 | var label string
218 | if err := rows.Scan(&label); err != nil {
219 | return nil, err
220 | }
221 | labels = append(labels, label)
222 | }
223 | if err = rows.Err(); err != nil {
224 | return nil, err
225 | }
226 | return stringset.Unique(labels), nil
227 | }
228 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | "ahui2016.github.com/dictplus/util"
9 | "github.com/labstack/echo/v4"
10 | )
11 |
12 | // Text 用于向前端返回一个简单的文本消息。
13 | // 为了保持一致性,总是向前端返回 JSON, 因此即使是简单的文本消息也使用 JSON.
14 | type Text struct {
15 | Message string `json:"message"`
16 | }
17 |
18 | type Number struct {
19 | N int64 `json:"n"`
20 | }
21 |
22 | type SearchForm struct {
23 | Pattern string `json:"pattern"`
24 | Fields []string `json:"fields"`
25 | Limit int `json:"limit"`
26 | }
27 |
28 | func sleep(next echo.HandlerFunc) echo.HandlerFunc {
29 | return func(c echo.Context) error {
30 | s, err := db.GetSettings()
31 | if err != nil {
32 | return err
33 | }
34 | if s.Delay {
35 | time.Sleep(time.Second)
36 | }
37 | return next(c)
38 | }
39 | }
40 |
41 | func jsFile(next echo.HandlerFunc) echo.HandlerFunc {
42 | return func(c echo.Context) error {
43 | if strings.HasSuffix(c.Request().RequestURI, ".js") {
44 | c.Response().Header().Set(echo.HeaderContentType, "application/javascript")
45 | }
46 | return next(c)
47 | }
48 | }
49 |
50 | func downloadDB(c echo.Context) error {
51 | return c.Attachment(dbFileName, dbFileName)
52 | }
53 |
54 | func errorHandler(err error, c echo.Context) {
55 | if e, ok := err.(*echo.HTTPError); ok {
56 | c.JSON(e.Code, e.Message)
57 | }
58 | util.Panic(c.JSON(500, Text{err.Error()}))
59 | }
60 |
61 | func getWordHandler(c echo.Context) error {
62 | id := c.FormValue("id")
63 | w, err := db.GetWordByID(id)
64 | if err != nil {
65 | return err
66 | }
67 | return c.JSON(OK, w)
68 | }
69 |
70 | func deleteWordHandler(c echo.Context) error {
71 | id := c.FormValue("id")
72 | if _, err := db.GetWordByID(id); err != nil {
73 | return err
74 | }
75 | return db.DeleteWord(id)
76 | }
77 |
78 | func addWordHandler(c echo.Context) error {
79 | w, err := getWordValue(c)
80 | if err != nil {
81 | return err
82 | }
83 | if err := db.InsertNewWord(w); err != nil {
84 | return err
85 | }
86 | return c.JSON(OK, Text{w.ID})
87 | }
88 |
89 | func updateWordHandler(c echo.Context) error {
90 | w, err := getWordValue(c)
91 | if err != nil {
92 | return err
93 | }
94 | if w.ID == "" {
95 | return fmt.Errorf("id is empty, need an id")
96 | }
97 | // 确保 w.ID 存在于数据库中
98 | if _, err = db.GetWordByID(w.ID); err != nil {
99 | return err
100 | }
101 | return db.UpdateWord(w)
102 | }
103 |
104 | func countHandler(c echo.Context) error {
105 | n, err := db.CountAllWords()
106 | if err != nil {
107 | return err
108 | }
109 | return c.JSON(OK, Number{n})
110 | }
111 |
112 | func searchHandler(c echo.Context) error {
113 | f := new(SearchForm)
114 | if err := c.Bind(f); err != nil {
115 | return err
116 | }
117 | if f.Limit == 0 {
118 | f.Limit = DefaultPageLimit
119 | }
120 | words, err := db.GetWords(strings.TrimSpace(f.Pattern), f.Fields, f.Limit)
121 | if err != nil {
122 | return err
123 | }
124 | return c.JSON(OK, words)
125 | }
126 |
127 | func getAllLabels(c echo.Context) error {
128 | labels, err := db.GetAllLabels()
129 | if err != nil {
130 | return err
131 | }
132 | return c.JSON(OK, labels)
133 | }
134 |
135 | func getRecentLabels(c echo.Context) error {
136 | labels, err := db.GetRecentLabels()
137 | if err != nil {
138 | return err
139 | }
140 | return c.JSON(OK, strings.Fields(labels))
141 | }
142 |
143 | func getHistoryHandler(c echo.Context) error {
144 | history, err := db.GetHistory()
145 | if err != nil {
146 | return err
147 | }
148 | return c.JSON(OK, strings.Split(history, "\n"))
149 | }
150 |
151 | func updateHistory(c echo.Context) error {
152 | history, err := getFormValue(c, "history")
153 | if err != nil {
154 | return err
155 | }
156 | return db.UpdateHistory(history)
157 | }
158 |
159 | func getSettingsHandler(c echo.Context) error {
160 | s, err := db.GetSettings()
161 | if err != nil {
162 | return err
163 | }
164 | return c.JSON(OK, s)
165 | }
166 |
167 | func updateSettings(c echo.Context) error {
168 | s := new(Settings)
169 | if err := c.Bind(s); err != nil {
170 | return err
171 | }
172 | s.DictplusAddr = strings.TrimSpace(s.DictplusAddr)
173 | s.LocaltagsAddr = strings.TrimSpace(s.LocaltagsAddr)
174 | return db.UpdateSettings(*s)
175 | }
176 |
177 | func publicFileHandler(c echo.Context) error {
178 | filename := c.Param("filename")
179 | return c.File("public/" + filename)
180 | }
181 |
182 | func scriptsFileHandler(c echo.Context) error {
183 | filename := c.Param("filename")
184 | c.Response().Header().Set(echo.HeaderContentType, "application/javascript")
185 | return c.File("public/ts/dist/" + filename)
186 | }
187 |
188 | // getFormValue gets the c.FormValue(key), trims its spaces,
189 | // and checks if it is empty or not.
190 | func getFormValue(c echo.Context, key string) (string, error) {
191 | value := strings.TrimSpace(c.FormValue(key))
192 | if value == "" {
193 | return "", fmt.Errorf("form value [%s] is empty", key)
194 | }
195 | return value, nil
196 | }
197 |
198 | func getWordValue(c echo.Context) (word *Word, err error) {
199 | w := new(Word)
200 | if err = c.Bind(w); err != nil {
201 | return
202 | }
203 | word = new(Word)
204 | word.ID = strings.TrimSpace(w.ID)
205 | word.CN = strings.TrimSpace(w.CN)
206 | word.EN = strings.TrimSpace(w.EN)
207 | word.JP = strings.TrimSpace(w.JP)
208 | word.Kana = strings.TrimSpace(w.Kana)
209 | word.Other = strings.TrimSpace(w.Other)
210 | if word.EN+word.CN+word.JP+word.Other == "" {
211 | return nil, fmt.Errorf("必须至少填写一个: CN, EN, JP, Other")
212 | }
213 | if word.Kana != "" && word.JP == "" {
214 | return nil, fmt.Errorf("如果填写了 Kana, 就必须填写 JP")
215 | }
216 | word.Label = normalizeLabel(w.Label)
217 | word.Notes = strings.TrimSpace(w.Notes)
218 | word.Links = strings.TrimSpace(w.Links)
219 | word.Images = strings.TrimSpace(w.Images)
220 | return
221 | }
222 |
223 | // Label 由用户自由输入,但可以用分隔符 ("-" 或 "/" 或空格) 来区分大类与小类,
224 | // 第一个分隔符之前的内容被视为大类,后面的都是小类。
225 | // 在 Label 专属的搜索页面对大类和小类有合理的特殊处理。
226 | func normalizeLabel(label string) string {
227 | label = strings.TrimSpace(label)
228 | label = strings.Join(strings.Fields(label), " ")
229 | label = strings.Join(strings.FieldsFunc(label, func(c rune) bool {
230 | return c == '-'
231 | }), "-")
232 | label = strings.Join(strings.FieldsFunc(label, func(c rune) bool {
233 | return c == '/'
234 | }), "/")
235 | return label
236 | }
237 |
--------------------------------------------------------------------------------
/public/ts/dist/labels.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc, appendToList } from './mj.js';
3 | import * as util from './util.js';
4 | const PageLimit = 100;
5 | const Loading = util.CreateLoading('center');
6 | const Alerts = util.CreateAlerts();
7 | const titleArea = m('div').addClass('text-center').append(m('h1').text('Search by Label'));
8 | const HintBtn = cc('a', { text: 'Hint', attr: { href: '#', title: '显示说明' } });
9 | const Hint = cc('div', {
10 | classes: 'Hint',
11 | children: [
12 | m('button')
13 | .text('hide')
14 | .addClass('btn')
15 | .on('click', () => {
16 | Hint.elem().hide();
17 | HintBtn.elem().css('visibility', 'visible');
18 | }),
19 | m('ul').append(m('li').text('在添加或编辑词条时,可采用 "大类-小类" 的形式填写 Label'), m('li').text('比如 "编程-数据库-sql", 其中分隔符可以是 "-" 或 "/" 或空格。'), m('li').text('第一个分隔符前的第一个词被视为大类(比如上面例子中的 "编程"),后面的各个词都是小类(比如上面例子中的 "数据库" 和 "sql")'), m('li').text('一般建议采用 Starts With 方式,如果搜不到词条,会自动切换成 Contains 方式。')),
20 | ],
21 | });
22 | const LimitInput = cc('input', {
23 | classes: 'form-textinput',
24 | attr: { type: 'number', min: 1, max: 9999 },
25 | });
26 | const LimitInputArea = cc('div', {
27 | classes: 'text-right',
28 | children: [
29 | m('label').text('Page Limit').attr('for', LimitInput.raw_id),
30 | m(LimitInput).val(PageLimit).addClass('ml-1').css('width', '4em'),
31 | ],
32 | });
33 | const LimitBtn = cc('a', {
34 | text: 'Limit',
35 | attr: { href: '#', title: '搜索结果条数上限' },
36 | classes: 'ml-2',
37 | });
38 | const naviBar = m('div')
39 | .addClass('text-right')
40 | .append(util.LinkElem('/', { text: 'Home' }), m(HintBtn)
41 | .addClass('ml-2')
42 | .on('click', e => {
43 | e.preventDefault();
44 | HintBtn.elem().css('visibility', 'hidden');
45 | Hint.elem().show();
46 | }), m(LimitBtn).on('click', e => {
47 | e.preventDefault();
48 | LimitBtn.elem().css('visibility', 'hidden');
49 | LimitInputArea.elem().show();
50 | }));
51 | const radioName = 'mode';
52 | const radioValues = ['StartsWith', 'Contains', 'EndsWith'];
53 | const radioTitles = ['以此开头', '包含', '以此结尾'];
54 | const Radio_StartWith = util.create_box('radio', radioName, 'checked');
55 | const Radio_Contains = util.create_box('radio', radioName);
56 | const Radio_EndWith = util.create_box('radio', radioName);
57 | const SearchInput = cc('input', { attr: { type: 'text' }, prop: { autofocus: true } });
58 | const SearchAlerts = util.CreateAlerts(2);
59 | const SearchBtn = cc('button', { text: 'Search', classes: 'btn btn-fat text-right' });
60 | const SearchForm = cc('form', {
61 | attr: { autocomplete: 'off' },
62 | children: [
63 | util.create_check(Radio_StartWith, radioValues[0], radioTitles[0]),
64 | util.create_check(Radio_Contains, radioValues[1], radioTitles[1]),
65 | util.create_check(Radio_EndWith, radioValues[2], radioTitles[2]),
66 | m(SearchInput).addClass('form-textinput form-textinput-fat'),
67 | m(SearchAlerts),
68 | m('div')
69 | .addClass('text-center mt-2')
70 | .append(m(SearchBtn).on('click', e => {
71 | e.preventDefault();
72 | const pattern = util.val(SearchInput, 'trim');
73 | if (!pattern) {
74 | SearchInput.elem().trigger('focus');
75 | return;
76 | }
77 | let href = `/public/index.html/?mode=${getChecked()}&search=${encodeURIComponent(pattern)}`;
78 | if (parseInt(util.val(LimitInput), 10) != PageLimit) {
79 | href += `&limit=${util.val(LimitInput)}`;
80 | }
81 | location.href = href;
82 | })),
83 | ],
84 | });
85 | const MainLabelsList = cc('div', { classes: 'LabelsList' });
86 | const MainLabelsArea = cc('div', {
87 | classes: 'LabelsArea',
88 | children: [m('h3').text('Main Labels (大类)'), m('hr'), m(MainLabelsList).addClass('mt-3')],
89 | });
90 | const SubLabelsList = cc('div', { classes: 'LabelsList' });
91 | const SubLabelsArea = cc('div', {
92 | classes: 'LabelsArea',
93 | children: [m('h3').text('Sub-Labels (小类)'), m('hr'), m(SubLabelsList).addClass('mt-3')],
94 | });
95 | const AllLabelsList = cc('div', { classes: 'LabelsList' });
96 | const AllLabelsArea = cc('div', {
97 | classes: 'LabelsArea',
98 | children: [m('h3').text('All Labels (全部标签)'), m('hr'), m(AllLabelsList).addClass('mt-3')],
99 | });
100 | const EmptyLabelBtn = cc('button', { text: '无标签', classes: 'btn' });
101 | const EmptyLabelArea = cc('div', {
102 | children: [
103 | m('h3').text('No Label (无标签)'),
104 | m('hr'),
105 | m('p').addClass('mt-3').text('点击下面的按钮可列出无标签的词条'),
106 | m('p').append(m(EmptyLabelBtn).on('click', e => {
107 | e.preventDefault();
108 | let href = `/public/index.html/?mode=EmptyLabel&search=abc`;
109 | if (parseInt(util.val(LimitInput), 10) != PageLimit) {
110 | href += `&limit=${util.val(LimitInput)}`;
111 | }
112 | location.href = href;
113 | })),
114 | ],
115 | });
116 | $('#root').append(titleArea, naviBar, m(LimitInputArea).hide(), m(Loading).addClass('my-5'), m(Alerts).addClass('my-5'), m(Hint).addClass('my-3').hide(), m(SearchForm).addClass('my-5').hide(), m(MainLabelsArea).addClass('my-5').hide(), m(SubLabelsArea).addClass('my-5').hide(), m(AllLabelsArea).addClass('my-5').hide(), m(EmptyLabelArea).addClass('my-5').hide());
117 | init();
118 | function init() {
119 | initMainLabels();
120 | }
121 | function initMainLabels() {
122 | util.ajax({ method: 'GET', url: '/api/get-all-labels', alerts: Alerts }, resp => {
123 | let allLabels = resp;
124 | if (allLabels.includes('')) {
125 | EmptyLabelArea.elem().show();
126 | }
127 | allLabels = allLabels.filter(x => !!x);
128 | if (!resp || allLabels.length == 0) {
129 | Alerts.insert('danger', '数据库中还没有标签,请在添加或编辑词条时在 Label 栏填写内容。');
130 | return;
131 | }
132 | // 注意避免 allLabels 里有字符串而导致产生 undefined 的问题,
133 | // 因此要确保 allLabels 里没有空字符串(在上面处理了)。
134 | const mainLabels = allLabels
135 | .map(label => label.split(/[\s-/]/).filter(x => !!x)[0])
136 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i) // 除重并不打乱位置
137 | .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
138 | const subLabels = allLabels
139 | .join(' ')
140 | .split(/[\s-/]/)
141 | .filter(x => !!x)
142 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i) // 除重并不打乱位置
143 | .filter(x => util.noCaseIndexOf(mainLabels, x) < 0)
144 | .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
145 | allLabels.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
146 | SearchForm.elem().show();
147 | $('.LabelsArea').show();
148 | initLabels(AllLabelsList, allLabels);
149 | initLabels(SubLabelsList, subLabels);
150 | initLabels(MainLabelsList, mainLabels);
151 | }, undefined, () => {
152 | Loading.hide();
153 | });
154 | }
155 | function initLabels(list, labels) {
156 | appendToList(list, labels.map(LabelItem));
157 | }
158 | function LabelItem(label) {
159 | const self = cc('a', {
160 | text: label,
161 | attr: { href: '#' },
162 | classes: 'LabelItem badge-grey',
163 | });
164 | self.init = () => {
165 | self.elem().on('click', e => {
166 | e.preventDefault();
167 | SearchInput.elem().val(label).trigger('focus');
168 | });
169 | };
170 | return self;
171 | }
172 | function getChecked() {
173 | return $(`input[name=${radioName}]:checked`).val();
174 | }
175 |
--------------------------------------------------------------------------------
/public/ts/src/labels.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import {mjElement, mjComponent, m, cc, span, appendToList} from './mj.js';
3 | import * as util from './util.js';
4 |
5 | const PageLimit = 100;
6 |
7 | const Loading = util.CreateLoading('center');
8 | const Alerts = util.CreateAlerts();
9 |
10 | const titleArea = m('div').addClass('text-center').append(m('h1').text('Search by Label'));
11 |
12 | const HintBtn = cc('a', {text: 'Hint', attr: {href: '#', title: '显示说明'}});
13 | const Hint = cc('div', {
14 | classes: 'Hint',
15 | children: [
16 | m('button')
17 | .text('hide')
18 | .addClass('btn')
19 | .on('click', () => {
20 | Hint.elem().hide();
21 | HintBtn.elem().css('visibility', 'visible');
22 | }),
23 | m('ul').append(
24 | m('li').text('在添加或编辑词条时,可采用 "大类-小类" 的形式填写 Label'),
25 | m('li').text('比如 "编程-数据库-sql", 其中分隔符可以是 "-" 或 "/" 或空格。'),
26 | m('li').text(
27 | '第一个分隔符前的第一个词被视为大类(比如上面例子中的 "编程"),后面的各个词都是小类(比如上面例子中的 "数据库" 和 "sql")'
28 | ),
29 | m('li').text('一般建议采用 Starts With 方式,如果搜不到词条,会自动切换成 Contains 方式。')
30 | ),
31 | ],
32 | });
33 |
34 | const LimitInput = cc('input', {
35 | classes: 'form-textinput',
36 | attr: {type: 'number', min: 1, max: 9999},
37 | });
38 | const LimitInputArea = cc('div', {
39 | classes: 'text-right',
40 | children: [
41 | m('label').text('Page Limit').attr('for', LimitInput.raw_id),
42 | m(LimitInput).val(PageLimit).addClass('ml-1').css('width', '4em'),
43 | ],
44 | });
45 |
46 | const LimitBtn = cc('a', {
47 | text: 'Limit',
48 | attr: {href: '#', title: '搜索结果条数上限'},
49 | classes: 'ml-2',
50 | });
51 |
52 | const naviBar = m('div')
53 | .addClass('text-right')
54 | .append(
55 | util.LinkElem('/', {text: 'Home'}),
56 | m(HintBtn)
57 | .addClass('ml-2')
58 | .on('click', e => {
59 | e.preventDefault();
60 | HintBtn.elem().css('visibility', 'hidden');
61 | Hint.elem().show();
62 | }),
63 | m(LimitBtn).on('click', e => {
64 | e.preventDefault();
65 | LimitBtn.elem().css('visibility', 'hidden');
66 | LimitInputArea.elem().show();
67 | })
68 | );
69 |
70 | const radioName = 'mode';
71 | const radioValues = ['StartsWith', 'Contains', 'EndsWith'];
72 | const radioTitles = ['以此开头', '包含', '以此结尾'];
73 | const Radio_StartWith = util.create_box('radio', radioName, 'checked');
74 | const Radio_Contains = util.create_box('radio', radioName);
75 | const Radio_EndWith = util.create_box('radio', radioName);
76 | const SearchInput = cc('input', {attr: {type: 'text'}, prop: {autofocus: true}});
77 | const SearchAlerts = util.CreateAlerts(2);
78 | const SearchBtn = cc('button', {text: 'Search', classes: 'btn btn-fat text-right'});
79 | const SearchForm = cc('form', {
80 | attr: {autocomplete: 'off'},
81 | children: [
82 | util.create_check(Radio_StartWith, radioValues[0], radioTitles[0]),
83 | util.create_check(Radio_Contains, radioValues[1], radioTitles[1]),
84 | util.create_check(Radio_EndWith, radioValues[2], radioTitles[2]),
85 | m(SearchInput).addClass('form-textinput form-textinput-fat'),
86 | m(SearchAlerts),
87 | m('div')
88 | .addClass('text-center mt-2')
89 | .append(
90 | m(SearchBtn).on('click', e => {
91 | e.preventDefault();
92 | const pattern = util.val(SearchInput, 'trim');
93 | if (!pattern) {
94 | SearchInput.elem().trigger('focus');
95 | return;
96 | }
97 | let href = `/public/index.html/?mode=${getChecked()}&search=${encodeURIComponent(
98 | pattern
99 | )}`;
100 | if (parseInt(util.val(LimitInput), 10) != PageLimit) {
101 | href += `&limit=${util.val(LimitInput)}`;
102 | }
103 | location.href = href;
104 | })
105 | ),
106 | ],
107 | });
108 |
109 | const MainLabelsList = cc('div', {classes:'LabelsList'});
110 | const MainLabelsArea = cc('div', {
111 | classes: 'LabelsArea',
112 | children: [m('h3').text('Main Labels (大类)'), m('hr'), m(MainLabelsList).addClass('mt-3')],
113 | });
114 |
115 | const SubLabelsList = cc('div', {classes:'LabelsList'});
116 | const SubLabelsArea = cc('div', {
117 | classes: 'LabelsArea',
118 | children: [m('h3').text('Sub-Labels (小类)'), m('hr'), m(SubLabelsList).addClass('mt-3')],
119 | });
120 |
121 | const AllLabelsList = cc('div', {classes:'LabelsList'});
122 | const AllLabelsArea = cc('div', {
123 | classes: 'LabelsArea',
124 | children: [m('h3').text('All Labels (全部标签)'), m('hr'), m(AllLabelsList).addClass('mt-3')],
125 | });
126 |
127 | const EmptyLabelBtn = cc('button', {text: '无标签', classes: 'btn'});
128 | const EmptyLabelArea = cc('div', {
129 | children: [
130 | m('h3').text('No Label (无标签)'),
131 | m('hr'),
132 | m('p').addClass('mt-3').text('点击下面的按钮可列出无标签的词条'),
133 | m('p').append(
134 | m(EmptyLabelBtn).on('click', e => {
135 | e.preventDefault();
136 | let href = `/public/index.html/?mode=EmptyLabel&search=abc`;
137 | if (parseInt(util.val(LimitInput), 10) != PageLimit) {
138 | href += `&limit=${util.val(LimitInput)}`;
139 | }
140 | location.href = href;
141 | })
142 | ),
143 | ],
144 | });
145 |
146 | $('#root').append(
147 | titleArea,
148 | naviBar,
149 | m(LimitInputArea).hide(),
150 | m(Loading).addClass('my-5'),
151 | m(Alerts).addClass('my-5'),
152 | m(Hint).addClass('my-3').hide(),
153 | m(SearchForm).addClass('my-5').hide(),
154 | m(MainLabelsArea).addClass('my-5').hide(),
155 | m(SubLabelsArea).addClass('my-5').hide(),
156 | m(AllLabelsArea).addClass('my-5').hide(),
157 | m(EmptyLabelArea).addClass('my-5').hide()
158 | );
159 |
160 | init();
161 |
162 | function init() {
163 | initMainLabels();
164 | }
165 |
166 | function initMainLabels(): void {
167 | util.ajax(
168 | {method: 'GET', url: '/api/get-all-labels', alerts: Alerts},
169 | resp => {
170 | let allLabels = resp as string[];
171 | if (allLabels.includes('')) {
172 | EmptyLabelArea.elem().show();
173 | }
174 | allLabels = allLabels.filter(x => !!x);
175 | if (!resp || allLabels.length == 0) {
176 | Alerts.insert('danger', '数据库中还没有标签,请在添加或编辑词条时在 Label 栏填写内容。');
177 | return;
178 | }
179 | // 注意避免 allLabels 里有字符串而导致产生 undefined 的问题,
180 | // 因此要确保 allLabels 里没有空字符串(在上面处理了)。
181 | const mainLabels = allLabels
182 | .map(label => label.split(/[\s-/]/).filter(x => !!x)[0])
183 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i) // 除重并不打乱位置
184 | .sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
185 | const subLabels = allLabels
186 | .join(' ')
187 | .split(/[\s-/]/)
188 | .filter(x => !!x)
189 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i) // 除重并不打乱位置
190 | .filter(x => util.noCaseIndexOf(mainLabels, x) < 0)
191 | .sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
192 | allLabels.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
193 |
194 | SearchForm.elem().show();
195 | $('.LabelsArea').show();
196 | initLabels(AllLabelsList, allLabels);
197 | initLabels(SubLabelsList, subLabels);
198 | initLabels(MainLabelsList, mainLabels);
199 | },
200 | undefined,
201 | () => {
202 | Loading.hide();
203 | }
204 | );
205 | }
206 |
207 | function initLabels(list: mjComponent, labels: string[]): void {
208 | appendToList(list, labels.map(LabelItem));
209 | }
210 |
211 | function LabelItem(label: string): mjComponent {
212 | const self = cc('a', {
213 | text: label,
214 | attr: {href: '#'},
215 | classes: 'LabelItem badge-grey',
216 | });
217 | self.init = () => {
218 | self.elem().on('click', e => {
219 | e.preventDefault();
220 | SearchInput.elem().val(label).trigger('focus');
221 | });
222 | };
223 | return self;
224 | }
225 |
226 | function getChecked(): string {
227 | return $(`input[name=${radioName}]:checked`).val() as string;
228 | }
229 |
--------------------------------------------------------------------------------
/public/ts/dist/util.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc, span } from './mj.js';
3 | // 获取地址栏的参数。
4 | export function getUrlParam(param) {
5 | var _a;
6 | const queryString = new URLSearchParams(document.location.search);
7 | return (_a = queryString.get(param)) !== null && _a !== void 0 ? _a : '';
8 | }
9 | /**
10 | * @param name is a mjComponent or the mjComponent's id
11 | */
12 | export function disable(name) {
13 | const id = typeof name == 'string' ? name : name.id;
14 | const nodeName = $(id).prop('nodeName');
15 | if (nodeName == 'BUTTON' || nodeName == 'INPUT') {
16 | $(id).prop('disabled', true);
17 | }
18 | else {
19 | $(id).css('pointer-events', 'none');
20 | }
21 | }
22 | /**
23 | * @param name is a mjComponent or the mjComponent's id
24 | */
25 | export function enable(name) {
26 | const id = typeof name == 'string' ? name : name.id;
27 | const nodeName = $(id).prop('nodeName');
28 | if (nodeName == 'BUTTON' || nodeName == 'INPUT') {
29 | $(id).prop('disabled', false);
30 | }
31 | else {
32 | $(id).css('pointer-events', 'auto');
33 | }
34 | }
35 | export function CreateLoading(align) {
36 | let classes = 'Loading';
37 | if (align == 'center') {
38 | classes += ' text-center';
39 | }
40 | const loading = cc('div', {
41 | text: 'Loading...',
42 | classes: classes,
43 | });
44 | loading.hide = () => {
45 | loading.elem().hide();
46 | };
47 | loading.show = () => {
48 | loading.elem().show();
49 | };
50 | return loading;
51 | }
52 | /**
53 | * 当 max == undefined 时,给 max 一个默认值 (比如 3)。
54 | * 当 max <= 0 时,不限制数量。
55 | */
56 | export function CreateAlerts(max) {
57 | const alerts = cc('div');
58 | alerts.max = max == undefined ? 3 : max;
59 | alerts.count = 0;
60 | alerts.insertElem = elem => {
61 | $(alerts.id).prepend(elem);
62 | alerts.count++;
63 | if (alerts.max > 0 && alerts.count > alerts.max) {
64 | $(`${alerts.id} div:last-of-type`).remove();
65 | }
66 | };
67 | alerts.insert = (msgType, msg) => {
68 | const time = dayjs().format('HH:mm:ss');
69 | const time_and_msg = `${time} ${msg}`;
70 | if (msgType == 'danger') {
71 | console.log(time_and_msg);
72 | }
73 | const elem = m('div')
74 | .addClass(`alert alert-${msgType} my-1`)
75 | .append(span(time_and_msg));
76 | alerts.insertElem(elem);
77 | };
78 | alerts.clear = () => {
79 | $(alerts.id).html('');
80 | return alerts;
81 | };
82 | return alerts;
83 | }
84 | /**
85 | * 注意:当 options.contentType 设为 json 时,options.body 应该是一个未转换为 JSON 的 object,
86 | * 因为在 ajax 里会对 options.body 使用 JSON.stringfy
87 | */
88 | export function ajax(options, onSuccess, onFail, onAlways, onReady) {
89 | const handleErr = (that, errMsg) => {
90 | if (onFail) {
91 | onFail(that, errMsg);
92 | return;
93 | }
94 | if (options.alerts) {
95 | options.alerts.insert('danger', errMsg);
96 | }
97 | else {
98 | console.log(errMsg);
99 | }
100 | };
101 | if (options.buttonID)
102 | disable(options.buttonID);
103 | const xhr = new XMLHttpRequest();
104 | xhr.timeout = 10 * 1000;
105 | xhr.ontimeout = () => {
106 | handleErr(xhr, 'timeout');
107 | };
108 | if (options.responseType) {
109 | xhr.responseType = options.responseType;
110 | }
111 | else {
112 | xhr.responseType = 'json';
113 | }
114 | xhr.open(options.method, options.url);
115 | xhr.onerror = () => {
116 | handleErr(xhr, 'An error occurred during the transaction');
117 | };
118 | xhr.onreadystatechange = function () {
119 | onReady === null || onReady === void 0 ? void 0 : onReady(this);
120 | };
121 | xhr.onload = function () {
122 | var _a;
123 | if (this.status == 200) {
124 | onSuccess === null || onSuccess === void 0 ? void 0 : onSuccess(this.response);
125 | }
126 | else {
127 | let errMsg = `${this.status}`;
128 | if (this.responseType == 'text') {
129 | errMsg += ` ${this.responseText}`;
130 | }
131 | else {
132 | errMsg += ` ${(_a = this.response) === null || _a === void 0 ? void 0 : _a.message}`;
133 | }
134 | handleErr(xhr, errMsg);
135 | }
136 | };
137 | xhr.onloadend = function () {
138 | if (options.buttonID)
139 | enable(options.buttonID);
140 | onAlways === null || onAlways === void 0 ? void 0 : onAlways(this);
141 | };
142 | if (options.contentType) {
143 | if (options.contentType == 'json')
144 | options.contentType = 'application/json';
145 | xhr.setRequestHeader('Content-Type', options.contentType);
146 | }
147 | if (options.contentType == 'application/json') {
148 | xhr.send(JSON.stringify(options.body));
149 | }
150 | else if (options.body && !(options.body instanceof FormData)) {
151 | const body = new FormData();
152 | for (const [k, v] of Object.entries(options.body)) {
153 | body.set(k, v);
154 | }
155 | xhr.send(body);
156 | }
157 | else {
158 | xhr.send(options.body);
159 | }
160 | }
161 | /**
162 | * @param n 超时限制,单位是秒
163 | */
164 | export function ajaxPromise(options, n = 5) {
165 | const second = 1000;
166 | return new Promise((resolve, reject) => {
167 | const timeout = setTimeout(() => {
168 | reject('timeout');
169 | }, n * second);
170 | ajax(options,
171 | // onSuccess
172 | result => {
173 | resolve(result);
174 | },
175 | // onError
176 | errMsg => {
177 | reject(errMsg);
178 | },
179 | // onAlways
180 | () => {
181 | clearTimeout(timeout);
182 | });
183 | });
184 | }
185 | export function val(obj, trim) {
186 | let s = '';
187 | if ('elem' in obj) {
188 | s = obj.elem().val();
189 | }
190 | else {
191 | s = obj.val();
192 | }
193 | if (trim) {
194 | return s.trim();
195 | }
196 | else {
197 | return s;
198 | }
199 | }
200 | export function itemID(id) {
201 | return `i${id}`;
202 | }
203 | export function LinkElem(href, options) {
204 | if (!options) {
205 | return m('a').text(href).attr('href', href);
206 | }
207 | if (!options.text)
208 | options.text = href;
209 | const link = m('a').text(options.text).attr('href', href);
210 | if (options.title)
211 | link.attr('title', options.title);
212 | if (options.blank)
213 | link.attr('target', '_blank');
214 | return link;
215 | }
216 | export function create_textarea(rows = 3) {
217 | return cc('textarea', { classes: 'form-textarea', attr: { rows: rows } });
218 | }
219 | export function create_input(type = 'text') {
220 | return cc('input', { attr: { type: type } });
221 | }
222 | export function create_item(comp, name, description, classes = 'mb-3') {
223 | return m('div')
224 | .addClass(classes)
225 | .append(m('label').addClass('form-label').attr({ for: comp.raw_id }).text(name), m(comp).addClass('form-textinput form-textinput-fat'), m('div').addClass('form-text').text(description));
226 | }
227 | export function badge(name) {
228 | return span(name).addClass('badge-grey');
229 | }
230 | /**
231 | * @param item is a checkbox or a radio button
232 | */
233 | export function create_check(item, label, title, value // 由于 value 通常等于 label,因此 value 不常用,放在最后
234 | ) {
235 | value = value ? value : label;
236 | return m('div')
237 | .addClass('form-check-inline')
238 | .append(m(item).attr({ value: value, title: title }), m('label').text(label).attr({ for: item.raw_id, title: title }));
239 | }
240 | export function create_box(type, name, checked = '') {
241 | const c = checked ? true : false;
242 | return cc('input', { attr: { type: type, name: name }, prop: { checked: c } });
243 | }
244 | export function noCaseIndexOf(arr, s) {
245 | return arr.findIndex(x => x.toLowerCase() === s.toLowerCase());
246 | }
247 |
--------------------------------------------------------------------------------
/public/ts/dist/edit-word.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc, appendToList } from './mj.js';
3 | import * as util from './util.js';
4 | let wordID = util.getUrlParam('id');
5 | const Loading = util.CreateLoading('center');
6 | const Alerts = util.CreateAlerts();
7 | const Title = cc('h1', { text: 'Add a new item' });
8 | const ViewBtn = cc('a', { text: 'View', classes: 'ml-2' });
9 | const EditBtn = cc('a', { text: 'Edit', classes: 'ml-2' });
10 | const naviBar = m('div')
11 | .addClass('text-right')
12 | .append(util.LinkElem('/', { text: 'Home' }), m(EditBtn).hide(), m(ViewBtn).hide());
13 | const CN_Input = util.create_input();
14 | const EN_Input = util.create_input();
15 | const JP_Input = util.create_input();
16 | const Kana_Input = util.create_input();
17 | const Other_Input = util.create_input();
18 | const Label_Input = util.create_input();
19 | const RecentLabels = cc('div', { classes: 'RecentLabels' });
20 | const Notes_Input = util.create_textarea();
21 | const Links_Input = util.create_textarea();
22 | const Images_Input = util.create_textarea(2);
23 | const SubmitAlerts = util.CreateAlerts();
24 | const SubmitBtn = cc('button', { id: 'submit', text: 'submit' }); // 这个按钮是隐藏不用的,为了防止按回车键提交表单
25 | const AddBtn = cc('button', { text: 'Add', classes: 'btn btn-fat' });
26 | const UpdateBtn = cc('button', { text: 'Update', classes: 'btn btn-fat' });
27 | const DelBtn = cc('a', {
28 | text: 'delete',
29 | classes: 'ml-2',
30 | attr: { href: '#' },
31 | });
32 | const Form = cc('form', {
33 | attr: { autocomplete: 'off' },
34 | children: [
35 | util.create_item(CN_Input, 'CN', ''),
36 | util.create_item(EN_Input, 'EN', ''),
37 | util.create_item(JP_Input, 'JP', ''),
38 | util.create_item(Kana_Input, 'Kana', '与 JP 对应的平假名,用于辅助搜索'),
39 | util.create_item(Other_Input, 'Other', '其他任何语种 或 其他信息'),
40 | util.create_item(Label_Input, 'Label', '一个标签,建议采用 "大类-小类" 的方式(比如 "编程-算法"),其中分割符可以是 "-" 或 "/" 或空格', 'mb-0'),
41 | m(RecentLabels).addClass('mb-3'),
42 | util.create_item(Notes_Input, 'Notes', '备注/详细描述/补充说明 等等(建议控制字数,尽量简短)'),
43 | util.create_item(Links_Input, 'Links', '参考网址,请以 http 开头,每行一个网址'),
44 | util.create_item(Images_Input, 'Images', '参考图片的 ID, 用逗号或空格分隔 (该功能需要与 localtags 搭配使用)'),
45 | m(SubmitAlerts),
46 | m('div')
47 | .addClass('text-center my-5')
48 | .append(m(SubmitBtn)
49 | .hide()
50 | .on('click', e => {
51 | e.preventDefault();
52 | return false; // 这个按钮是隐藏不用的,为了防止按回车键提交表单。
53 | }), m(AddBtn).on('click', e => {
54 | e.preventDefault();
55 | const body = getFormWord();
56 | util.ajax({
57 | method: 'POST',
58 | url: '/api/add-word',
59 | alerts: SubmitAlerts,
60 | buttonID: AddBtn.id,
61 | contentType: 'json',
62 | body: body,
63 | }, resp => {
64 | wordID = resp.message;
65 | warningIfNoKana(body, Alerts);
66 | warningIfNoLabel(body, Alerts);
67 | Alerts.insert('success', `添加项目成功 (id:${wordID})`);
68 | Form.elem().hide();
69 | ViewBtn.elem()
70 | .show()
71 | .attr({ href: '/public/word-info.html?id=' + wordID });
72 | EditBtn.elem()
73 | .show()
74 | .attr({ href: '/public/edit-word.html?id=' + wordID });
75 | });
76 | }), m(UpdateBtn)
77 | .on('click', e => {
78 | e.preventDefault();
79 | const body = getFormWord();
80 | util.ajax({
81 | method: 'POST',
82 | url: '/api/update-word',
83 | alerts: SubmitAlerts,
84 | buttonID: UpdateBtn.id,
85 | contentType: 'json',
86 | body: body,
87 | }, () => {
88 | warningIfNoKana(body, SubmitAlerts);
89 | warningIfNoLabel(body, SubmitAlerts);
90 | SubmitAlerts.insert('success', '更新成功');
91 | });
92 | })
93 | .hide(), m(DelBtn)
94 | .on('click', e => {
95 | e.preventDefault();
96 | util.disable(DelBtn);
97 | SubmitAlerts.insert('danger', '当 delete 按钮变红时,再点击一次可删除该词条,不可恢复。');
98 | setTimeout(() => {
99 | util.enable(DelBtn);
100 | DelBtn.elem()
101 | .css('color', 'red')
102 | .off()
103 | .on('click', e => {
104 | e.preventDefault();
105 | util.ajax({
106 | method: 'POST',
107 | url: '/api/delete-word',
108 | alerts: SubmitAlerts,
109 | body: { id: wordID },
110 | }, () => {
111 | Alerts.clear().insert('success', '已彻底删除该词条。');
112 | Form.elem().hide();
113 | ViewBtn.elem().hide();
114 | });
115 | });
116 | }, 2000);
117 | })
118 | .hide()),
119 | ],
120 | });
121 | $('#root').append(m(Title), naviBar, m(Loading), m(Alerts), m(Form).hide());
122 | init();
123 | function init() {
124 | if (!wordID) {
125 | $('title').text('Add item - dictplus');
126 | Loading.hide();
127 | Form.elem().show();
128 | initLabels();
129 | CN_Input.elem().trigger('focus');
130 | return;
131 | }
132 | $('title').text('Edit item - dictplus');
133 | Title.elem().text(`Edit item (id:${wordID})`);
134 | initForm();
135 | }
136 | function initForm() {
137 | util.ajax({
138 | method: 'POST',
139 | url: '/api/get-word',
140 | alerts: Alerts,
141 | body: { id: wordID },
142 | }, resp => {
143 | const word = resp;
144 | Form.elem().show();
145 | ViewBtn.elem()
146 | .show()
147 | .attr({
148 | href: '/public/word-info.html?id=' + wordID,
149 | target: '_blank',
150 | });
151 | UpdateBtn.elem().show();
152 | DelBtn.elem().show();
153 | AddBtn.elem().hide();
154 | CN_Input.elem().val(word.CN);
155 | EN_Input.elem().val(word.EN);
156 | JP_Input.elem().val(word.JP);
157 | Kana_Input.elem().val(word.Kana);
158 | Other_Input.elem().val(word.Other);
159 | Label_Input.elem().val(word.Label);
160 | Notes_Input.elem().val(word.Notes);
161 | Links_Input.elem().val(word.Links);
162 | Images_Input.elem().val(word.Images);
163 | CN_Input.elem().trigger('focus');
164 | initLabels();
165 | }, undefined, () => {
166 | Loading.hide();
167 | });
168 | }
169 | function initLabels() {
170 | util.ajax({ method: 'GET', url: '/api/get-recent-labels', alerts: Alerts }, resp => {
171 | const labels = resp
172 | .filter(x => !!x)
173 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i); // 除重并不打乱位置
174 | if (!resp || labels.length == 0) {
175 | return;
176 | }
177 | // RecentLabels.elem().append(span('Recent Labels:').addClass('text-grey'));
178 | appendToList(RecentLabels, labels.map(LabelItem));
179 | });
180 | }
181 | function LabelItem(label) {
182 | const self = cc('a', {
183 | text: label,
184 | attr: { href: '#' },
185 | classes: 'LabelItem badge-grey',
186 | });
187 | self.init = () => {
188 | self.elem().on('click', e => {
189 | e.preventDefault();
190 | Label_Input.elem()
191 | .val(util.val(Label_Input) + label)
192 | .trigger('focus');
193 | });
194 | };
195 | return self;
196 | }
197 | function getFormWord() {
198 | const links = util
199 | .val(Links_Input, 'trim')
200 | .split(/\s/)
201 | .map(w => w.trim())
202 | .filter(w => !!w)
203 | .join('\n');
204 | const images = util
205 | .val(Images_Input, 'trim')
206 | .split(/[,、,\s]/)
207 | .filter(w => !!w)
208 | .join(', ');
209 | return {
210 | ID: wordID,
211 | CN: util.val(CN_Input, 'trim'),
212 | EN: util.val(EN_Input, 'trim'),
213 | JP: util.val(JP_Input, 'trim'),
214 | Kana: util.val(Kana_Input, 'trim'),
215 | Other: util.val(Other_Input, 'trim'),
216 | Label: util.val(Label_Input, 'trim'),
217 | Notes: util.val(Notes_Input, 'trim'),
218 | Links: links,
219 | Images: images,
220 | CTime: 0,
221 | };
222 | }
223 | function warningIfNoKana(w, alerts) {
224 | if (w.JP && !w.Kana) {
225 | alerts.insert('primary', '提醒:有 JP 但没有 Kana');
226 | }
227 | }
228 | function warningIfNoLabel(w, alerts) {
229 | if (!w.Label) {
230 | alerts.insert('primary', '提醒:没有 Label, 建议填写 Label, 这对知识管理非常重要');
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/public/ts/src/edit-word.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import {mjElement, mjComponent, m, cc, span, appendToList} from './mj.js';
3 | import * as util from './util.js';
4 |
5 | let wordID = util.getUrlParam('id');
6 |
7 | const Loading = util.CreateLoading('center');
8 | const Alerts = util.CreateAlerts();
9 |
10 | const Title = cc('h1', {text: 'Add a new item'});
11 |
12 | const ViewBtn = cc('a', {text: 'View', classes: 'ml-2'});
13 | const EditBtn = cc('a', {text: 'Edit', classes: 'ml-2'});
14 | const naviBar = m('div')
15 | .addClass('text-right')
16 | .append(util.LinkElem('/', {text: 'Home'}), m(EditBtn).hide(), m(ViewBtn).hide());
17 |
18 | const CN_Input = util.create_input();
19 | const EN_Input = util.create_input();
20 | const JP_Input = util.create_input();
21 | const Kana_Input = util.create_input();
22 | const Other_Input = util.create_input();
23 | const Label_Input = util.create_input();
24 | const RecentLabels = cc('div', {classes:'RecentLabels'});
25 | const Notes_Input = util.create_textarea();
26 | const Links_Input = util.create_textarea();
27 | const Images_Input = util.create_textarea(2);
28 |
29 | const SubmitAlerts = util.CreateAlerts();
30 | const SubmitBtn = cc('button', {id: 'submit', text: 'submit'}); // 这个按钮是隐藏不用的,为了防止按回车键提交表单
31 | const AddBtn = cc('button', {text: 'Add', classes: 'btn btn-fat'});
32 | const UpdateBtn = cc('button', {text: 'Update', classes: 'btn btn-fat'});
33 | const DelBtn = cc('a', {
34 | text: 'delete',
35 | classes: 'ml-2',
36 | attr: {href: '#'},
37 | });
38 |
39 | const Form = cc('form', {
40 | attr: {autocomplete: 'off'},
41 | children: [
42 | util.create_item(CN_Input, 'CN', ''),
43 | util.create_item(EN_Input, 'EN', ''),
44 | util.create_item(JP_Input, 'JP', ''),
45 | util.create_item(Kana_Input, 'Kana', '与 JP 对应的平假名,用于辅助搜索'),
46 | util.create_item(Other_Input, 'Other', '其他任何语种 或 其他信息'),
47 | util.create_item(
48 | Label_Input,
49 | 'Label',
50 | '一个标签,建议采用 "大类-小类" 的方式(比如 "编程-算法"),其中分割符可以是 "-" 或 "/" 或空格',
51 | 'mb-0'
52 | ),
53 | m(RecentLabels).addClass('mb-3'),
54 | util.create_item(Notes_Input, 'Notes', '备注/详细描述/补充说明 等等(建议控制字数,尽量简短)'),
55 | util.create_item(Links_Input, 'Links', '参考网址,请以 http 开头,每行一个网址'),
56 | util.create_item(
57 | Images_Input,
58 | 'Images',
59 | '参考图片的 ID, 用逗号或空格分隔 (该功能需要与 localtags 搭配使用)'
60 | ),
61 |
62 | m(SubmitAlerts),
63 | m('div')
64 | .addClass('text-center my-5')
65 | .append(
66 | m(SubmitBtn)
67 | .hide()
68 | .on('click', e => {
69 | e.preventDefault();
70 | return false; // 这个按钮是隐藏不用的,为了防止按回车键提交表单。
71 | }),
72 | m(AddBtn).on('click', e => {
73 | e.preventDefault();
74 | const body = getFormWord();
75 | util.ajax(
76 | {
77 | method: 'POST',
78 | url: '/api/add-word',
79 | alerts: SubmitAlerts,
80 | buttonID: AddBtn.id,
81 | contentType: 'json',
82 | body: body,
83 | },
84 | resp => {
85 | wordID = (resp as util.Text).message;
86 | warningIfNoKana(body, Alerts);
87 | warningIfNoLabel(body, Alerts);
88 | Alerts.insert('success', `添加项目成功 (id:${wordID})`);
89 | Form.elem().hide();
90 | ViewBtn.elem()
91 | .show()
92 | .attr({href: '/public/word-info.html?id=' + wordID});
93 | EditBtn.elem()
94 | .show()
95 | .attr({href: '/public/edit-word.html?id=' + wordID});
96 | }
97 | );
98 | }),
99 | m(UpdateBtn)
100 | .on('click', e => {
101 | e.preventDefault();
102 | const body = getFormWord();
103 | util.ajax(
104 | {
105 | method: 'POST',
106 | url: '/api/update-word',
107 | alerts: SubmitAlerts,
108 | buttonID: UpdateBtn.id,
109 | contentType: 'json',
110 | body: body,
111 | },
112 | () => {
113 | warningIfNoKana(body, SubmitAlerts);
114 | warningIfNoLabel(body, SubmitAlerts);
115 | SubmitAlerts.insert('success', '更新成功');
116 | }
117 | );
118 | })
119 | .hide(),
120 | m(DelBtn)
121 | .on('click', e => {
122 | e.preventDefault();
123 | util.disable(DelBtn);
124 | SubmitAlerts.insert(
125 | 'danger',
126 | '当 delete 按钮变红时,再点击一次可删除该词条,不可恢复。'
127 | );
128 | setTimeout(() => {
129 | util.enable(DelBtn);
130 | DelBtn.elem()
131 | .css('color', 'red')
132 | .off()
133 | .on('click', e => {
134 | e.preventDefault();
135 | util.ajax(
136 | {
137 | method: 'POST',
138 | url: '/api/delete-word',
139 | alerts: SubmitAlerts,
140 | body: {id: wordID},
141 | },
142 | () => {
143 | Alerts.clear().insert('success', '已彻底删除该词条。');
144 | Form.elem().hide();
145 | ViewBtn.elem().hide();
146 | }
147 | );
148 | });
149 | }, 2000);
150 | })
151 | .hide()
152 | ),
153 | ],
154 | });
155 |
156 | $('#root').append(m(Title), naviBar, m(Loading), m(Alerts), m(Form).hide());
157 |
158 | init();
159 |
160 | function init() {
161 | if (!wordID) {
162 | $('title').text('Add item - dictplus');
163 | Loading.hide();
164 | Form.elem().show();
165 | initLabels();
166 | CN_Input.elem().trigger('focus');
167 | return;
168 | }
169 |
170 | $('title').text('Edit item - dictplus');
171 | Title.elem().text(`Edit item (id:${wordID})`);
172 | initForm();
173 | }
174 |
175 | function initForm() {
176 | util.ajax(
177 | {
178 | method: 'POST',
179 | url: '/api/get-word',
180 | alerts: Alerts,
181 | body: {id: wordID},
182 | },
183 | resp => {
184 | const word = resp as util.Word;
185 | Form.elem().show();
186 | ViewBtn.elem()
187 | .show()
188 | .attr({
189 | href: '/public/word-info.html?id=' + wordID,
190 | target: '_blank',
191 | });
192 | UpdateBtn.elem().show();
193 | DelBtn.elem().show();
194 | AddBtn.elem().hide();
195 |
196 | CN_Input.elem().val(word.CN);
197 | EN_Input.elem().val(word.EN);
198 | JP_Input.elem().val(word.JP);
199 | Kana_Input.elem().val(word.Kana);
200 | Other_Input.elem().val(word.Other);
201 | Label_Input.elem().val(word.Label);
202 | Notes_Input.elem().val(word.Notes);
203 | Links_Input.elem().val(word.Links);
204 | Images_Input.elem().val(word.Images);
205 |
206 | CN_Input.elem().trigger('focus');
207 | initLabels();
208 | },
209 | undefined,
210 | () => {
211 | Loading.hide();
212 | }
213 | );
214 | }
215 |
216 | function initLabels() {
217 | util.ajax({method: 'GET', url: '/api/get-recent-labels', alerts: Alerts}, resp => {
218 | const labels = (resp as string[])
219 | .filter(x => !!x)
220 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i); // 除重并不打乱位置
221 | if (!resp || labels.length == 0) {
222 | return;
223 | }
224 | // RecentLabels.elem().append(span('Recent Labels:').addClass('text-grey'));
225 | appendToList(RecentLabels, labels.map(LabelItem));
226 | });
227 | }
228 |
229 | function LabelItem(label: string): mjComponent {
230 | const self = cc('a', {
231 | text: label,
232 | attr: {href: '#'},
233 | classes: 'LabelItem badge-grey',
234 | });
235 | self.init = () => {
236 | self.elem().on('click', e => {
237 | e.preventDefault();
238 | Label_Input.elem()
239 | .val(util.val(Label_Input) + label)
240 | .trigger('focus');
241 | });
242 | };
243 | return self;
244 | }
245 |
246 | function getFormWord(): util.Word {
247 | const links = util
248 | .val(Links_Input, 'trim')
249 | .split(/\s/)
250 | .map(w => w.trim())
251 | .filter(w => !!w)
252 | .join('\n');
253 |
254 | const images = util
255 | .val(Images_Input, 'trim')
256 | .split(/[,、,\s]/)
257 | .filter(w => !!w)
258 | .join(', ');
259 |
260 | return {
261 | ID: wordID,
262 | CN: util.val(CN_Input, 'trim'),
263 | EN: util.val(EN_Input, 'trim'),
264 | JP: util.val(JP_Input, 'trim'),
265 | Kana: util.val(Kana_Input, 'trim'),
266 | Other: util.val(Other_Input, 'trim'),
267 | Label: util.val(Label_Input, 'trim'),
268 | Notes: util.val(Notes_Input, 'trim'),
269 | Links: links,
270 | Images: images,
271 | CTime: 0,
272 | };
273 | }
274 |
275 | function warningIfNoKana(w: util.Word, alerts: util.mjAlerts): void {
276 | if (w.JP && !w.Kana) {
277 | alerts.insert('primary', '提醒:有 JP 但没有 Kana');
278 | }
279 | }
280 |
281 | function warningIfNoLabel(w: util.Word, alerts: util.mjAlerts): void {
282 | if (!w.Label) {
283 | alerts.insert('primary', '提醒:没有 Label, 建议填写 Label, 这对知识管理非常重要');
284 | }
285 | }
286 |
--------------------------------------------------------------------------------
/public/ts/src/util.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import {mjElement, mjComponent, m, cc, span} from './mj.js';
3 |
4 | export interface Text {
5 | message: string;
6 | }
7 | export interface Num {
8 | n: number;
9 | }
10 | export interface Word {
11 | ID: string; // ShortID
12 | CN: string;
13 | EN: string;
14 | JP: string;
15 | Kana: string; // 与 JP 对应的平假名
16 | Other: string; // 其他任何语种
17 | Label: string; // 每个单词只有一个标签,通常用来记录出处(书名或文章名)
18 | Notes: string;
19 | Links: string; // 用换行符分隔的网址
20 | Images: string; // 用逗号分隔的图片 ID, 与 localtags 搭配使用
21 | CTime: number;
22 | }
23 | export interface Settings {
24 | DictplusAddr: string;
25 | LocaltagsAddr: string;
26 | Delay: boolean;
27 | }
28 |
29 | // 获取地址栏的参数。
30 | export function getUrlParam(param: string): string {
31 | const queryString = new URLSearchParams(document.location.search);
32 | return queryString.get(param) ?? '';
33 | }
34 |
35 | /**
36 | * @param name is a mjComponent or the mjComponent's id
37 | */
38 | export function disable(name: string | mjComponent): void {
39 | const id = typeof name == 'string' ? name : name.id;
40 | const nodeName = $(id).prop('nodeName');
41 | if (nodeName == 'BUTTON' || nodeName == 'INPUT') {
42 | $(id).prop('disabled', true);
43 | } else {
44 | $(id).css('pointer-events', 'none');
45 | }
46 | }
47 |
48 | /**
49 | * @param name is a mjComponent or the mjComponent's id
50 | */
51 | export function enable(name: string | mjComponent): void {
52 | const id = typeof name == 'string' ? name : name.id;
53 | const nodeName = $(id).prop('nodeName');
54 | if (nodeName == 'BUTTON' || nodeName == 'INPUT') {
55 | $(id).prop('disabled', false);
56 | } else {
57 | $(id).css('pointer-events', 'auto');
58 | }
59 | }
60 |
61 | export interface mjLoading extends mjComponent {
62 | hide: () => void;
63 | show: () => void;
64 | }
65 |
66 | export function CreateLoading(align?: 'center'): mjLoading {
67 | let classes = 'Loading';
68 | if (align == 'center') {
69 | classes += ' text-center';
70 | }
71 |
72 | const loading = cc('div', {
73 | text: 'Loading...',
74 | classes: classes,
75 | }) as mjLoading;
76 |
77 | loading.hide = () => {
78 | loading.elem().hide();
79 | };
80 | loading.show = () => {
81 | loading.elem().show();
82 | };
83 | return loading;
84 | }
85 |
86 | export interface mjAlerts extends mjComponent {
87 | max: number;
88 | count: number;
89 | insertElem: (elem: mjElement) => void;
90 | insert: (msgType: 'success' | 'danger' | 'info' | 'primary', msg: string) => void;
91 | clear: () => mjAlerts;
92 | }
93 |
94 | /**
95 | * 当 max == undefined 时,给 max 一个默认值 (比如 3)。
96 | * 当 max <= 0 时,不限制数量。
97 | */
98 | export function CreateAlerts(max?: number): mjAlerts {
99 | const alerts = cc('div') as mjAlerts;
100 | alerts.max = max == undefined ? 3 : max;
101 | alerts.count = 0;
102 |
103 | alerts.insertElem = elem => {
104 | $(alerts.id).prepend(elem);
105 | alerts.count++;
106 | if (alerts.max > 0 && alerts.count > alerts.max) {
107 | $(`${alerts.id} div:last-of-type`).remove();
108 | }
109 | };
110 |
111 | alerts.insert = (msgType, msg) => {
112 | const time = dayjs().format('HH:mm:ss');
113 | const time_and_msg = `${time} ${msg}`;
114 | if (msgType == 'danger') {
115 | console.log(time_and_msg);
116 | }
117 | const elem = m('div')
118 | .addClass(`alert alert-${msgType} my-1`)
119 | .append(span(time_and_msg));
120 | alerts.insertElem(elem);
121 | };
122 |
123 | alerts.clear = () => {
124 | $(alerts.id).html('');
125 | return alerts;
126 | };
127 |
128 | return alerts;
129 | }
130 |
131 | export interface AjaxOptions {
132 | method: string;
133 | url: string;
134 | body?: FormData | object;
135 | alerts?: mjAlerts;
136 | buttonID?: string;
137 | responseType?: XMLHttpRequestResponseType;
138 | contentType?: string;
139 | }
140 |
141 | /**
142 | * 注意:当 options.contentType 设为 json 时,options.body 应该是一个未转换为 JSON 的 object,
143 | * 因为在 ajax 里会对 options.body 使用 JSON.stringfy
144 | */
145 | export function ajax(
146 | options: AjaxOptions,
147 | onSuccess?: (resp: any) => void,
148 | onFail?: (that: XMLHttpRequest, errMsg: string) => void,
149 | onAlways?: (that: XMLHttpRequest) => void,
150 | onReady?: (that: XMLHttpRequest) => void
151 | ): void {
152 | const handleErr = (that: XMLHttpRequest, errMsg: string) => {
153 | if (onFail) {
154 | onFail(that, errMsg);
155 | return;
156 | }
157 | if (options.alerts) {
158 | options.alerts.insert('danger', errMsg);
159 | } else {
160 | console.log(errMsg);
161 | }
162 | };
163 |
164 | if (options.buttonID) disable(options.buttonID);
165 |
166 | const xhr = new XMLHttpRequest();
167 |
168 | xhr.timeout = 10 * 1000;
169 | xhr.ontimeout = () => {
170 | handleErr(xhr, 'timeout');
171 | };
172 |
173 | if (options.responseType) {
174 | xhr.responseType = options.responseType;
175 | } else {
176 | xhr.responseType = 'json';
177 | }
178 |
179 | xhr.open(options.method, options.url);
180 |
181 | xhr.onerror = () => {
182 | handleErr(xhr, 'An error occurred during the transaction');
183 | };
184 |
185 | xhr.onreadystatechange = function () {
186 | onReady?.(this);
187 | };
188 |
189 | xhr.onload = function () {
190 | if (this.status == 200) {
191 | onSuccess?.(this.response);
192 | } else {
193 | let errMsg = `${this.status}`;
194 | if (this.responseType == 'text') {
195 | errMsg += ` ${this.responseText}`;
196 | } else {
197 | errMsg += ` ${this.response?.message!}`;
198 | }
199 | handleErr(xhr, errMsg);
200 | }
201 | };
202 |
203 | xhr.onloadend = function () {
204 | if (options.buttonID) enable(options.buttonID);
205 | onAlways?.(this);
206 | };
207 |
208 | if (options.contentType) {
209 | if (options.contentType == 'json') options.contentType = 'application/json';
210 | xhr.setRequestHeader('Content-Type', options.contentType);
211 | }
212 |
213 | if (options.contentType == 'application/json') {
214 | xhr.send(JSON.stringify(options.body));
215 | } else if (options.body && !(options.body instanceof FormData)) {
216 | const body = new FormData();
217 | for (const [k, v] of Object.entries(options.body)) {
218 | body.set(k, v);
219 | }
220 | xhr.send(body);
221 | } else {
222 | xhr.send(options.body);
223 | }
224 | }
225 |
226 | /**
227 | * @param n 超时限制,单位是秒
228 | */
229 | export function ajaxPromise(options: AjaxOptions, n: number = 5): Promise {
230 | const second = 1000;
231 | return new Promise((resolve, reject) => {
232 | const timeout = setTimeout(() => {
233 | reject('timeout');
234 | }, n * second);
235 | ajax(
236 | options,
237 | // onSuccess
238 | result => {
239 | resolve(result);
240 | },
241 | // onError
242 | errMsg => {
243 | reject(errMsg);
244 | },
245 | // onAlways
246 | () => {
247 | clearTimeout(timeout);
248 | }
249 | );
250 | });
251 | }
252 |
253 | export function val(obj: mjElement | mjComponent, trim?: 'trim'): string {
254 | let s = '';
255 | if ('elem' in obj) {
256 | s = obj.elem().val() as string;
257 | } else {
258 | s = obj.val() as string;
259 | }
260 | if (trim) {
261 | return s.trim();
262 | } else {
263 | return s;
264 | }
265 | }
266 |
267 | export function itemID(id: string): string {
268 | return `i${id}`;
269 | }
270 |
271 | interface LinkOptions {
272 | text?: string;
273 | title?: string;
274 | blank?: boolean;
275 | }
276 | export function LinkElem(href: string, options?: LinkOptions): mjElement {
277 | if (!options) {
278 | return m('a').text(href).attr('href', href);
279 | }
280 | if (!options.text) options.text = href;
281 | const link = m('a').text(options.text).attr('href', href);
282 | if (options.title) link.attr('title', options.title);
283 | if (options.blank) link.attr('target', '_blank');
284 | return link;
285 | }
286 |
287 | export function create_textarea(rows: number = 3): mjComponent {
288 | return cc('textarea', {classes: 'form-textarea', attr: {rows: rows}});
289 | }
290 | export function create_input(type: string = 'text'): mjComponent {
291 | return cc('input', {attr: {type: type}});
292 | }
293 | export function create_item(
294 | comp: mjComponent,
295 | name: string,
296 | description: string,
297 | classes = 'mb-3'
298 | ): mjElement {
299 | return m('div')
300 | .addClass(classes)
301 | .append(
302 | m('label').addClass('form-label').attr({for: comp.raw_id}).text(name),
303 | m(comp).addClass('form-textinput form-textinput-fat'),
304 | m('div').addClass('form-text').text(description)
305 | );
306 | }
307 |
308 | export function badge(name: string): mjElement {
309 | return span(name).addClass('badge-grey');
310 | }
311 |
312 | /**
313 | * @param item is a checkbox or a radio button
314 | */
315 | export function create_check(
316 | item: mjComponent,
317 | label: string,
318 | title?: string,
319 | value?: string // 由于 value 通常等于 label,因此 value 不常用,放在最后
320 | ): mjElement {
321 | value = value ? value : label;
322 | return m('div')
323 | .addClass('form-check-inline')
324 | .append(
325 | m(item).attr({value: value, title: title}),
326 | m('label').text(label).attr({for: item.raw_id, title: title})
327 | );
328 | }
329 |
330 | export function create_box(
331 | type: 'checkbox' | 'radio',
332 | name: string,
333 | checked: 'checked' | '' = ''
334 | ): mjComponent {
335 | const c = checked ? true : false;
336 | return cc('input', {attr: {type: type, name: name}, prop: {checked: c}});
337 | }
338 |
339 | export function noCaseIndexOf(arr: string[], s: string): number {
340 | return arr.findIndex(x => x.toLowerCase() === s.toLowerCase());
341 | }
342 |
--------------------------------------------------------------------------------
/public/ts/src/index.ts:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import {mjElement, mjComponent, m, cc, span, appendToList} from './mj.js';
3 | import * as util from './util.js';
4 |
5 | const NotesLimit = 80;
6 | const HistoryLimit = 30;
7 | const PageLimit = 100;
8 | let History: Array = [];
9 | let isAllChecked = false;
10 | let SuccessOnce = false;
11 |
12 | let mode = util.getUrlParam('mode');
13 | let search = util.getUrlParam('search');
14 | const searchLimit = util.getUrlParam('limit');
15 |
16 | const Loading = util.CreateLoading('center');
17 | const Alerts = util.CreateAlerts();
18 |
19 | const SubTitle = cc('div');
20 | const titleArea = m('div')
21 | .addClass('text-center')
22 | .append(
23 | m('h1')
24 | .addClass('cursor-pointer')
25 | .append('dict', span('+').addClass('Plus'))
26 | .on('click', () => {
27 | location.href = '/';
28 | }),
29 | m(SubTitle).text('dictplus, 不只是一个词典程序')
30 | );
31 |
32 | const LimitInput = cc('input', {
33 | classes: 'form-textinput',
34 | attr: {type: 'number', min: 1, max: 9999},
35 | });
36 | const LimitInputArea = cc('div', {
37 | classes: 'text-right',
38 | children: [
39 | m('label').text('Page Limit').attr('for', LimitInput.raw_id),
40 | m(LimitInput).val(PageLimit).addClass('ml-1').css('width', '4em'),
41 | ],
42 | });
43 |
44 | const LimitBtn = cc('a', {
45 | text: 'Limit',
46 | attr: {href: '#', title: '搜索结果条数上限'},
47 | classes: 'ml-2',
48 | });
49 | const NaviBar = cc('div', {
50 | classes: 'text-right',
51 | children: [
52 | util.LinkElem('/public/edit-word.html', {text: 'Add', title: 'Add a new item', blank: true}),
53 | util.LinkElem('/public/settings.html', {text: 'Settings'}).addClass('ml-2'),
54 | m(LimitBtn).on('click', e => {
55 | e.preventDefault();
56 | LimitBtn.elem().css('visibility', 'hidden');
57 | LimitInputArea.elem().show();
58 | }),
59 | ],
60 | });
61 |
62 | const ResultTitle = cc('h3', {text: 'Recently Added (最近添加)'});
63 | const ResultAlerts = util.CreateAlerts(1);
64 | const HR = cc('hr');
65 | const WordList = cc('div');
66 |
67 | const HistoryItems = cc('div', {classes:'HistoryItems'});
68 | const HistoryArea = cc('div', {
69 | children: [m('h3').text('History (检索历史)'), m('hr'), m(HistoryItems)],
70 | });
71 |
72 | const AllLabelsBtn = cc('button', {text: 'all labels', classes: 'btn ml-3'});
73 | AllLabelsBtn.init = () => {
74 | AllLabelsBtn.elem().on('click', e => {
75 | e.preventDefault();
76 | location.href = '/public/labels.html';
77 | });
78 | };
79 | const RecentLabels = cc('div', {classes: 'RecentLabels'});
80 | const RecentLabelsArea = cc('div', {
81 | children: [m('h3').text('Recent Labels (最近标签)'), m('hr'), m(RecentLabels)],
82 | });
83 |
84 | const boxName = 'field';
85 | const CN_Box = util.create_box('checkbox', boxName, 'checked');
86 | const EN_Box = util.create_box('checkbox', boxName, 'checked');
87 | const JP_Box = util.create_box('checkbox', boxName, 'checked');
88 | const Kana_Box = util.create_box('checkbox', boxName, 'checked');
89 | const Other_Box = util.create_box('checkbox', boxName, 'checked');
90 | const Label_Box = util.create_box('checkbox', boxName, 'checked');
91 | const Notes_Box = util.create_box('checkbox', boxName);
92 | const CheckAllBtn = cc('a', {
93 | text: '[all]',
94 | classes: 'ml-3',
95 | attr: {title: 'check all / uncheck all', href: '#'},
96 | });
97 | const SearchInput = cc('input', {attr: {type: 'text'}, prop: {autofocus: true}});
98 | const SearchAlerts = util.CreateAlerts(2);
99 | const SearchBtn = cc('button', {text: 'Search', classes: 'btn btn-fat text-right'});
100 | const SearchForm = cc('form', {
101 | attr: {autocomplete: 'off'},
102 | children: [
103 | util.create_check(CN_Box, 'CN'),
104 | util.create_check(EN_Box, 'EN'),
105 | util.create_check(JP_Box, 'JP'),
106 | util.create_check(Kana_Box, 'Kana'),
107 | util.create_check(Other_Box, 'Other'),
108 | util.create_check(Label_Box, 'Label'),
109 | util.create_check(Notes_Box, 'Notes'),
110 | m(CheckAllBtn).on('click', e => {
111 | e.preventDefault();
112 | $(`input[name=${boxName}]`).prop('checked', !isAllChecked);
113 | isAllChecked = !isAllChecked;
114 | }),
115 | m(SearchInput).addClass('form-textinput form-textinput-fat'),
116 | m(SearchAlerts),
117 | m('div')
118 | .addClass('text-center mt-2')
119 | .append(
120 | m(SearchBtn).on('click', e => {
121 | e.preventDefault();
122 | const pattern = util.val(SearchInput, 'trim');
123 | if (!pattern) {
124 | SearchInput.elem().trigger('focus');
125 | return;
126 | }
127 |
128 | SearchAlerts.insert('primary', 'searching: ' + pattern);
129 | updateHistory(pattern);
130 | let limit = parseInt(util.val(LimitInput), 10);
131 | if (limit < 1) {
132 | limit = 1;
133 | LimitInput.elem().val(1);
134 | }
135 | searchWords(pattern, limit);
136 | })
137 | ),
138 | ],
139 | });
140 |
141 | function searchWords(pattern: string, limit: number): void {
142 | const body = {pattern: pattern, fields: getFields(), limit: limit};
143 | if (search) {
144 | body.fields = ['SearchByLabel', mode];
145 | }
146 | if (mode == 'EmptyLabel') {
147 | body.fields = ['SearchByEmptyLabel'];
148 | }
149 | util.ajax(
150 | {
151 | method: 'POST',
152 | url: '/api/search-words',
153 | alerts: SearchAlerts,
154 | buttonID: SearchBtn.id,
155 | contentType: 'json',
156 | body: body,
157 | },
158 | resp => {
159 | const words = resp as util.Word[];
160 | if (!resp || words.length == 0) {
161 | if (!search) {
162 | SearchAlerts.insert('danger', '找不到 (not found)');
163 | } else {
164 | ResultAlerts.insert('danger', '找不到 (not found)');
165 | if (mode == 'StartsWith') {
166 | Alerts.insert('danger', '"StartsWith" 方式无结果,自动转换为 "Contains" 方式搜索...');
167 | mode = 'Contains';
168 | SearchBtn.elem().trigger('click');
169 | }
170 | }
171 | return;
172 | }
173 | if (!search) {
174 | Alerts.clear();
175 | }
176 | let searchLimitWarning =
177 | '已达到搜索结果条数的上限, 点击右上角的 Limit 按钮可临时更改上限 (刷新页面会变回默认值)';
178 | if (searchLimit) {
179 | searchLimitWarning =
180 | '已达到搜索结果条数的上限,可按后退键退回 Search by Label 页面点击右上角的 Limit 按钮修改上限';
181 | }
182 | if (words.length >= body.limit) {
183 | Alerts.insert('danger', searchLimitWarning);
184 | }
185 | SearchAlerts.insert('success', `找到 ${words.length} 条结果`);
186 | ResultTitle.elem().text('Results (结果)');
187 | let successMsg = '';
188 | if (mode == 'EmptyLabel') {
189 | successMsg = 'Search by EmptyLabel';
190 | } else if (search) {
191 | successMsg = `Search by label ${mode} [${pattern}]`;
192 | } else {
193 | successMsg = `Search [${pattern}] in ${body.fields.join(', ')}`;
194 | }
195 | ResultAlerts.insert('success', successMsg);
196 | clear_list(WordList);
197 | appendToList(WordList, words.map(WordItem));
198 | if (!SuccessOnce) {
199 | SuccessOnce = true;
200 | HistoryArea.elem().insertAfter(WordList.elem());
201 | RecentLabelsArea.elem().insertAfter(HistoryArea.elem());
202 | }
203 | }
204 | );
205 | }
206 |
207 | function clear_list(list: mjComponent): void {
208 | list.elem().html('');
209 | }
210 |
211 | const Footer = cc('div', {
212 | classes: 'text-center',
213 | children: [
214 | // util.LinkElem('https://github.com/ahui2016/dictplus',{blank:true}),
215 | m('br'),
216 | span('version: 2021-12-05').addClass('text-grey'),
217 | ],
218 | });
219 |
220 | $('#root').append(
221 | titleArea,
222 | m(NaviBar),
223 | m(LimitInputArea).hide(),
224 | m(Loading).addClass('my-5'),
225 | m(Alerts).addClass('my-5'),
226 | m(SearchForm).addClass('my-5').hide(),
227 | m(HistoryArea).addClass('my-5').hide(),
228 | m(RecentLabelsArea).addClass('my-5').hide(),
229 | m(ResultTitle).hide(),
230 | m(ResultAlerts),
231 | m(HR).hide(),
232 | m(WordList).addClass('mt-3'),
233 | m(Footer).addClass('my-5')
234 | );
235 |
236 | init();
237 |
238 | function init() {
239 | if (mode || search) {
240 | Loading.hide();
241 | SubTitle.elem().text('Label 高级搜索结果专用页面');
242 | NaviBar.elem().hide();
243 | initSearchByLabel();
244 | } else {
245 | count_words();
246 | initNewWords();
247 | initHistory();
248 | initLabels();
249 | }
250 | }
251 |
252 | function initSearchByLabel(): void {
253 | Alerts.insert('primary', '可按浏览器的后退键回到 Search by Label 页面重新搜索');
254 | if (mode == 'EmptyLabel') {
255 | Alerts.insert('primary', `正在采用 ${mode} 方式列出无标签的词条...`);
256 | } else {
257 | Alerts.insert('primary', `正在采用 ${mode} 方式检索 Label[${search}]...`);
258 | }
259 |
260 | ResultTitle.elem().show().text('Results (结果)');
261 | HR.elem().show();
262 | search = decodeURIComponent(search);
263 | mode = !mode ? 'StartsWith' : mode;
264 | if (searchLimit) {
265 | LimitInput.elem().val(parseInt(searchLimit, 10));
266 | }
267 | SearchInput.elem().val(search);
268 | SearchBtn.elem().trigger('click');
269 | }
270 |
271 | function initNewWords(): void {
272 | const body = {pattern: 'Recently-Added', fields: ['Recently-Added']};
273 | util.ajax(
274 | {method: 'POST', url: '/api/search-words', alerts: Alerts, contentType: 'json', body: body},
275 | resp => {
276 | const words = resp as util.Word[];
277 | if (words && words.length > 0) {
278 | ResultTitle.elem().show();
279 | HR.elem().show();
280 | appendToList(WordList, words.map(WordItem));
281 | }
282 | }
283 | );
284 | }
285 |
286 | function WordItem(w: util.Word): mjComponent {
287 | const self = cc('div', {
288 | id: w.ID,
289 | classes: 'WordItem',
290 | children: [
291 | m('div')
292 | .addClass('WordIDArea')
293 | .append(
294 | span(`[id: ${w.ID}]`).addClass('text-grey'),
295 | util
296 | .LinkElem('/public/edit-word.html?id=' + w.ID, {text: 'edit', blank: true})
297 | .addClass('ml-2'),
298 | util
299 | .LinkElem('/public/word-info.html?id=' + w.ID, {text: 'view', blank: true})
300 | .addClass('ml-2')
301 | ),
302 | m('div').addClass('WordLangs'),
303 | m('div').addClass('WordNotes').hide(),
304 | ],
305 | });
306 | self.init = () => {
307 | const i = w.Links.indexOf('\n');
308 | const linkText = i >= 0 ? 'links' : 'link';
309 | if (w.Links) {
310 | const firstLink = i >= 0 ? w.Links.substring(0, i) : w.Links;
311 | self
312 | .elem()
313 | .find('.WordIDArea')
314 | .append(
315 | util
316 | .LinkElem(firstLink, {text: linkText, blank: true})
317 | .addClass('badge-grey ml-2 cursor-pointer')
318 | );
319 | }
320 | if (w.Images) {
321 | self.elem().find('.WordIDArea').append(util.badge('images').addClass('ml-2'));
322 | }
323 | if (w.Label) {
324 | self
325 | .elem()
326 | .find('.WordIDArea')
327 | .append(
328 | util
329 | .badge(w.Label)
330 | .addClass('ml-2 cursor-pointer')
331 | .on('click', e => {
332 | e.preventDefault();
333 | selectLabelSearch(w.Label);
334 | })
335 | );
336 | }
337 | ['CN', 'EN', 'JP', 'Other'].forEach(lang => {
338 | const word = w as any;
339 | if (word[lang]) {
340 | const theWord = span(word[lang]);
341 | if (lang == 'JP' && !!w.Kana) {
342 | theWord.attr('title', w.Kana);
343 | }
344 | self
345 | .elem()
346 | .find('.WordLangs')
347 | .append(span(lang + ': ').addClass('text-grey'), theWord, ' ');
348 | }
349 | });
350 | if (w.Notes) {
351 | self.elem().find('.WordNotes').show().addClass('text-grey').text(limited_notes(w.Notes));
352 | }
353 | };
354 | return self;
355 | }
356 |
357 | function selectLabelSearch(label: string): void {
358 | SearchInput.elem().val(label);
359 | isAllChecked = true;
360 | CheckAllBtn.elem().trigger('click');
361 | $(`input[name=${boxName}][value=Label]`).prop('checked', true);
362 | SearchBtn.elem().trigger('click');
363 | }
364 |
365 | function getFields(): Array {
366 | const boxes = $(`input[name=${boxName}]:checked`);
367 | if (boxes.length == 0) {
368 | return ['CN', 'EN', 'JP', 'Kana', 'Other'];
369 | }
370 | const fields: Array = [];
371 | boxes.each((_, elem) => {
372 | const v = $(elem).val();
373 | if (typeof v == 'string') {
374 | fields.push(v);
375 | }
376 | });
377 | return fields;
378 | }
379 |
380 | function isEnglish(s: string): boolean {
381 | const size = new Blob([s]).size;
382 | return s.length * 2 >= size;
383 | }
384 |
385 | function limited_notes(notes: string): string {
386 | const limit = isEnglish(notes) ? NotesLimit * 2 : NotesLimit;
387 | if (notes.length <= limit) {
388 | return notes;
389 | }
390 | return notes.substr(0, limit) + '...';
391 | }
392 |
393 | function count_words(): void {
394 | util.ajax(
395 | {method: 'GET', url: '/api/count-words', alerts: Alerts},
396 | resp => {
397 | const n = (resp as util.Num).n;
398 | if (n < 1) {
399 | Alerts.insert('danger', '这是一个全新的数据库,请点击右上角的 Add 按钮添加数据。');
400 | return;
401 | }
402 | const count = n.toLocaleString('en-US');
403 | Alerts.insert('success', `数据库中已有 ${count} 条数据`);
404 | SearchForm.elem().show();
405 | SearchInput.elem().trigger('focus');
406 | },
407 | undefined,
408 | () => {
409 | Loading.hide();
410 | }
411 | );
412 | }
413 |
414 | function HistoryItem(h: string): mjComponent {
415 | const self = cc('a', {text: h, attr: {href: '#'}, classes: 'HistoryItem'});
416 | self.init = () => {
417 | self.elem().on('click', e => {
418 | e.preventDefault();
419 | SearchInput.elem().val(h);
420 | SearchBtn.elem().trigger('click');
421 | });
422 | };
423 | return self;
424 | }
425 |
426 | function initHistory(): void {
427 | util.ajax({method: 'GET', url: '/api/get-history', alerts: Alerts}, resp => {
428 | History = (resp as string[]).filter(x => !!x);
429 | if (!resp || History.length == 0) {
430 | return;
431 | }
432 | HistoryArea.elem().show();
433 | refreshHistory();
434 | });
435 | }
436 |
437 | function initLabels() {
438 | util.ajax({method: 'GET', url: '/api/get-recent-labels', alerts: Alerts}, resp => {
439 | const labels = (resp as string[])
440 | .filter(x => !!x)
441 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i); // 除重并不打乱位置
442 | if (!resp || labels.length == 0) {
443 | return;
444 | }
445 | RecentLabelsArea.elem().show();
446 | const items = labels.map(HistoryItem);
447 | if (items.length >= 10) {
448 | items.push(AllLabelsBtn);
449 | }
450 | appendToList(RecentLabels, items);
451 | });
452 | }
453 |
454 | function refreshHistory(): void {
455 | HistoryItems.elem().html('');
456 | appendToList(HistoryItems, History.map(HistoryItem));
457 | }
458 |
459 | function updateHistory(pattern: string): void {
460 | const i = History.findIndex(x => x.toLowerCase() === pattern.toLowerCase());
461 | if (i == 0) {
462 | return;
463 | }
464 | if (i > 0) {
465 | History.splice(i, 1);
466 | }
467 | History.unshift(pattern);
468 | if (History.length > HistoryLimit) {
469 | History.pop();
470 | }
471 | const body = {history: pattern};
472 | util.ajax({method: 'POST', url: '/api/update-history', alerts: SearchAlerts, body: body}, () => {
473 | refreshHistory();
474 | });
475 | }
476 |
--------------------------------------------------------------------------------
/public/ts/dist/index.js:
--------------------------------------------------------------------------------
1 | // 采用受 Mithril 启发的基于 jQuery 实现的极简框架 https://github.com/ahui2016/mj.js
2 | import { m, cc, span, appendToList } from './mj.js';
3 | import * as util from './util.js';
4 | const NotesLimit = 80;
5 | const HistoryLimit = 30;
6 | const PageLimit = 100;
7 | let History = [];
8 | let isAllChecked = false;
9 | let SuccessOnce = false;
10 | let mode = util.getUrlParam('mode');
11 | let search = util.getUrlParam('search');
12 | const searchLimit = util.getUrlParam('limit');
13 | const Loading = util.CreateLoading('center');
14 | const Alerts = util.CreateAlerts();
15 | const SubTitle = cc('div');
16 | const titleArea = m('div')
17 | .addClass('text-center')
18 | .append(m('h1')
19 | .addClass('cursor-pointer')
20 | .append('dict', span('+').addClass('Plus'))
21 | .on('click', () => {
22 | location.href = '/';
23 | }), m(SubTitle).text('dictplus, 不只是一个词典程序'));
24 | const LimitInput = cc('input', {
25 | classes: 'form-textinput',
26 | attr: { type: 'number', min: 1, max: 9999 },
27 | });
28 | const LimitInputArea = cc('div', {
29 | classes: 'text-right',
30 | children: [
31 | m('label').text('Page Limit').attr('for', LimitInput.raw_id),
32 | m(LimitInput).val(PageLimit).addClass('ml-1').css('width', '4em'),
33 | ],
34 | });
35 | const LimitBtn = cc('a', {
36 | text: 'Limit',
37 | attr: { href: '#', title: '搜索结果条数上限' },
38 | classes: 'ml-2',
39 | });
40 | const NaviBar = cc('div', {
41 | classes: 'text-right',
42 | children: [
43 | util.LinkElem('/public/edit-word.html', { text: 'Add', title: 'Add a new item', blank: true }),
44 | util.LinkElem('/public/settings.html', { text: 'Settings' }).addClass('ml-2'),
45 | m(LimitBtn).on('click', e => {
46 | e.preventDefault();
47 | LimitBtn.elem().css('visibility', 'hidden');
48 | LimitInputArea.elem().show();
49 | }),
50 | ],
51 | });
52 | const ResultTitle = cc('h3', { text: 'Recently Added (最近添加)' });
53 | const ResultAlerts = util.CreateAlerts(1);
54 | const HR = cc('hr');
55 | const WordList = cc('div');
56 | const HistoryItems = cc('div', { classes: 'HistoryItems' });
57 | const HistoryArea = cc('div', {
58 | children: [m('h3').text('History (检索历史)'), m('hr'), m(HistoryItems)],
59 | });
60 | const AllLabelsBtn = cc('button', { text: 'all labels', classes: 'btn ml-3' });
61 | AllLabelsBtn.init = () => {
62 | AllLabelsBtn.elem().on('click', e => {
63 | e.preventDefault();
64 | location.href = '/public/labels.html';
65 | });
66 | };
67 | const RecentLabels = cc('div', { classes: 'RecentLabels' });
68 | const RecentLabelsArea = cc('div', {
69 | children: [m('h3').text('Recent Labels (最近标签)'), m('hr'), m(RecentLabels)],
70 | });
71 | const boxName = 'field';
72 | const CN_Box = util.create_box('checkbox', boxName, 'checked');
73 | const EN_Box = util.create_box('checkbox', boxName, 'checked');
74 | const JP_Box = util.create_box('checkbox', boxName, 'checked');
75 | const Kana_Box = util.create_box('checkbox', boxName, 'checked');
76 | const Other_Box = util.create_box('checkbox', boxName, 'checked');
77 | const Label_Box = util.create_box('checkbox', boxName, 'checked');
78 | const Notes_Box = util.create_box('checkbox', boxName);
79 | const CheckAllBtn = cc('a', {
80 | text: '[all]',
81 | classes: 'ml-3',
82 | attr: { title: 'check all / uncheck all', href: '#' },
83 | });
84 | const SearchInput = cc('input', { attr: { type: 'text' }, prop: { autofocus: true } });
85 | const SearchAlerts = util.CreateAlerts(2);
86 | const SearchBtn = cc('button', { text: 'Search', classes: 'btn btn-fat text-right' });
87 | const SearchForm = cc('form', {
88 | attr: { autocomplete: 'off' },
89 | children: [
90 | util.create_check(CN_Box, 'CN'),
91 | util.create_check(EN_Box, 'EN'),
92 | util.create_check(JP_Box, 'JP'),
93 | util.create_check(Kana_Box, 'Kana'),
94 | util.create_check(Other_Box, 'Other'),
95 | util.create_check(Label_Box, 'Label'),
96 | util.create_check(Notes_Box, 'Notes'),
97 | m(CheckAllBtn).on('click', e => {
98 | e.preventDefault();
99 | $(`input[name=${boxName}]`).prop('checked', !isAllChecked);
100 | isAllChecked = !isAllChecked;
101 | }),
102 | m(SearchInput).addClass('form-textinput form-textinput-fat'),
103 | m(SearchAlerts),
104 | m('div')
105 | .addClass('text-center mt-2')
106 | .append(m(SearchBtn).on('click', e => {
107 | e.preventDefault();
108 | const pattern = util.val(SearchInput, 'trim');
109 | if (!pattern) {
110 | SearchInput.elem().trigger('focus');
111 | return;
112 | }
113 | SearchAlerts.insert('primary', 'searching: ' + pattern);
114 | updateHistory(pattern);
115 | let limit = parseInt(util.val(LimitInput), 10);
116 | if (limit < 1) {
117 | limit = 1;
118 | LimitInput.elem().val(1);
119 | }
120 | searchWords(pattern, limit);
121 | })),
122 | ],
123 | });
124 | function searchWords(pattern, limit) {
125 | const body = { pattern: pattern, fields: getFields(), limit: limit };
126 | if (search) {
127 | body.fields = ['SearchByLabel', mode];
128 | }
129 | if (mode == 'EmptyLabel') {
130 | body.fields = ['SearchByEmptyLabel'];
131 | }
132 | util.ajax({
133 | method: 'POST',
134 | url: '/api/search-words',
135 | alerts: SearchAlerts,
136 | buttonID: SearchBtn.id,
137 | contentType: 'json',
138 | body: body,
139 | }, resp => {
140 | const words = resp;
141 | if (!resp || words.length == 0) {
142 | if (!search) {
143 | SearchAlerts.insert('danger', '找不到 (not found)');
144 | }
145 | else {
146 | ResultAlerts.insert('danger', '找不到 (not found)');
147 | if (mode == 'StartsWith') {
148 | Alerts.insert('danger', '"StartsWith" 方式无结果,自动转换为 "Contains" 方式搜索...');
149 | mode = 'Contains';
150 | SearchBtn.elem().trigger('click');
151 | }
152 | }
153 | return;
154 | }
155 | if (!search) {
156 | Alerts.clear();
157 | }
158 | let searchLimitWarning = '已达到搜索结果条数的上限, 点击右上角的 Limit 按钮可临时更改上限 (刷新页面会变回默认值)';
159 | if (searchLimit) {
160 | searchLimitWarning =
161 | '已达到搜索结果条数的上限,可按后退键退回 Search by Label 页面点击右上角的 Limit 按钮修改上限';
162 | }
163 | if (words.length >= body.limit) {
164 | Alerts.insert('danger', searchLimitWarning);
165 | }
166 | SearchAlerts.insert('success', `找到 ${words.length} 条结果`);
167 | ResultTitle.elem().text('Results (结果)');
168 | let successMsg = '';
169 | if (mode == 'EmptyLabel') {
170 | successMsg = 'Search by EmptyLabel';
171 | }
172 | else if (search) {
173 | successMsg = `Search by label ${mode} [${pattern}]`;
174 | }
175 | else {
176 | successMsg = `Search [${pattern}] in ${body.fields.join(', ')}`;
177 | }
178 | ResultAlerts.insert('success', successMsg);
179 | clear_list(WordList);
180 | appendToList(WordList, words.map(WordItem));
181 | if (!SuccessOnce) {
182 | SuccessOnce = true;
183 | HistoryArea.elem().insertAfter(WordList.elem());
184 | RecentLabelsArea.elem().insertAfter(HistoryArea.elem());
185 | }
186 | });
187 | }
188 | function clear_list(list) {
189 | list.elem().html('');
190 | }
191 | const Footer = cc('div', {
192 | classes: 'text-center',
193 | children: [
194 | // util.LinkElem('https://github.com/ahui2016/dictplus',{blank:true}),
195 | m('br'),
196 | span('version: 2021-12-05').addClass('text-grey'),
197 | ],
198 | });
199 | $('#root').append(titleArea, m(NaviBar), m(LimitInputArea).hide(), m(Loading).addClass('my-5'), m(Alerts).addClass('my-5'), m(SearchForm).addClass('my-5').hide(), m(HistoryArea).addClass('my-5').hide(), m(RecentLabelsArea).addClass('my-5').hide(), m(ResultTitle).hide(), m(ResultAlerts), m(HR).hide(), m(WordList).addClass('mt-3'), m(Footer).addClass('my-5'));
200 | init();
201 | function init() {
202 | if (mode || search) {
203 | Loading.hide();
204 | SubTitle.elem().text('Label 高级搜索结果专用页面');
205 | NaviBar.elem().hide();
206 | initSearchByLabel();
207 | }
208 | else {
209 | count_words();
210 | initNewWords();
211 | initHistory();
212 | initLabels();
213 | }
214 | }
215 | function initSearchByLabel() {
216 | Alerts.insert('primary', '可按浏览器的后退键回到 Search by Label 页面重新搜索');
217 | if (mode == 'EmptyLabel') {
218 | Alerts.insert('primary', `正在采用 ${mode} 方式列出无标签的词条...`);
219 | }
220 | else {
221 | Alerts.insert('primary', `正在采用 ${mode} 方式检索 Label[${search}]...`);
222 | }
223 | ResultTitle.elem().show().text('Results (结果)');
224 | HR.elem().show();
225 | search = decodeURIComponent(search);
226 | mode = !mode ? 'StartsWith' : mode;
227 | if (searchLimit) {
228 | LimitInput.elem().val(parseInt(searchLimit, 10));
229 | }
230 | SearchInput.elem().val(search);
231 | SearchBtn.elem().trigger('click');
232 | }
233 | function initNewWords() {
234 | const body = { pattern: 'Recently-Added', fields: ['Recently-Added'] };
235 | util.ajax({ method: 'POST', url: '/api/search-words', alerts: Alerts, contentType: 'json', body: body }, resp => {
236 | const words = resp;
237 | if (words && words.length > 0) {
238 | ResultTitle.elem().show();
239 | HR.elem().show();
240 | appendToList(WordList, words.map(WordItem));
241 | }
242 | });
243 | }
244 | function WordItem(w) {
245 | const self = cc('div', {
246 | id: w.ID,
247 | classes: 'WordItem',
248 | children: [
249 | m('div')
250 | .addClass('WordIDArea')
251 | .append(span(`[id: ${w.ID}]`).addClass('text-grey'), util
252 | .LinkElem('/public/edit-word.html?id=' + w.ID, { text: 'edit', blank: true })
253 | .addClass('ml-2'), util
254 | .LinkElem('/public/word-info.html?id=' + w.ID, { text: 'view', blank: true })
255 | .addClass('ml-2')),
256 | m('div').addClass('WordLangs'),
257 | m('div').addClass('WordNotes').hide(),
258 | ],
259 | });
260 | self.init = () => {
261 | const i = w.Links.indexOf('\n');
262 | const linkText = i >= 0 ? 'links' : 'link';
263 | if (w.Links) {
264 | const firstLink = i >= 0 ? w.Links.substring(0, i) : w.Links;
265 | self
266 | .elem()
267 | .find('.WordIDArea')
268 | .append(util
269 | .LinkElem(firstLink, { text: linkText, blank: true })
270 | .addClass('badge-grey ml-2 cursor-pointer'));
271 | }
272 | if (w.Images) {
273 | self.elem().find('.WordIDArea').append(util.badge('images').addClass('ml-2'));
274 | }
275 | if (w.Label) {
276 | self
277 | .elem()
278 | .find('.WordIDArea')
279 | .append(util
280 | .badge(w.Label)
281 | .addClass('ml-2 cursor-pointer')
282 | .on('click', e => {
283 | e.preventDefault();
284 | selectLabelSearch(w.Label);
285 | }));
286 | }
287 | ['CN', 'EN', 'JP', 'Other'].forEach(lang => {
288 | const word = w;
289 | if (word[lang]) {
290 | const theWord = span(word[lang]);
291 | if (lang == 'JP' && !!w.Kana) {
292 | theWord.attr('title', w.Kana);
293 | }
294 | self
295 | .elem()
296 | .find('.WordLangs')
297 | .append(span(lang + ': ').addClass('text-grey'), theWord, ' ');
298 | }
299 | });
300 | if (w.Notes) {
301 | self.elem().find('.WordNotes').show().addClass('text-grey').text(limited_notes(w.Notes));
302 | }
303 | };
304 | return self;
305 | }
306 | function selectLabelSearch(label) {
307 | SearchInput.elem().val(label);
308 | isAllChecked = true;
309 | CheckAllBtn.elem().trigger('click');
310 | $(`input[name=${boxName}][value=Label]`).prop('checked', true);
311 | SearchBtn.elem().trigger('click');
312 | }
313 | function getFields() {
314 | const boxes = $(`input[name=${boxName}]:checked`);
315 | if (boxes.length == 0) {
316 | return ['CN', 'EN', 'JP', 'Kana', 'Other'];
317 | }
318 | const fields = [];
319 | boxes.each((_, elem) => {
320 | const v = $(elem).val();
321 | if (typeof v == 'string') {
322 | fields.push(v);
323 | }
324 | });
325 | return fields;
326 | }
327 | function isEnglish(s) {
328 | const size = new Blob([s]).size;
329 | return s.length * 2 >= size;
330 | }
331 | function limited_notes(notes) {
332 | const limit = isEnglish(notes) ? NotesLimit * 2 : NotesLimit;
333 | if (notes.length <= limit) {
334 | return notes;
335 | }
336 | return notes.substr(0, limit) + '...';
337 | }
338 | function count_words() {
339 | util.ajax({ method: 'GET', url: '/api/count-words', alerts: Alerts }, resp => {
340 | const n = resp.n;
341 | if (n < 1) {
342 | Alerts.insert('danger', '这是一个全新的数据库,请点击右上角的 Add 按钮添加数据。');
343 | return;
344 | }
345 | const count = n.toLocaleString('en-US');
346 | Alerts.insert('success', `数据库中已有 ${count} 条数据`);
347 | SearchForm.elem().show();
348 | SearchInput.elem().trigger('focus');
349 | }, undefined, () => {
350 | Loading.hide();
351 | });
352 | }
353 | function HistoryItem(h) {
354 | const self = cc('a', { text: h, attr: { href: '#' }, classes: 'HistoryItem' });
355 | self.init = () => {
356 | self.elem().on('click', e => {
357 | e.preventDefault();
358 | SearchInput.elem().val(h);
359 | SearchBtn.elem().trigger('click');
360 | });
361 | };
362 | return self;
363 | }
364 | function initHistory() {
365 | util.ajax({ method: 'GET', url: '/api/get-history', alerts: Alerts }, resp => {
366 | History = resp.filter(x => !!x);
367 | if (!resp || History.length == 0) {
368 | return;
369 | }
370 | HistoryArea.elem().show();
371 | refreshHistory();
372 | });
373 | }
374 | function initLabels() {
375 | util.ajax({ method: 'GET', url: '/api/get-recent-labels', alerts: Alerts }, resp => {
376 | const labels = resp
377 | .filter(x => !!x)
378 | .filter((v, i, a) => util.noCaseIndexOf(a, v) === i); // 除重并不打乱位置
379 | if (!resp || labels.length == 0) {
380 | return;
381 | }
382 | RecentLabelsArea.elem().show();
383 | const items = labels.map(HistoryItem);
384 | if (items.length >= 10) {
385 | items.push(AllLabelsBtn);
386 | }
387 | appendToList(RecentLabels, items);
388 | });
389 | }
390 | function refreshHistory() {
391 | HistoryItems.elem().html('');
392 | appendToList(HistoryItems, History.map(HistoryItem));
393 | }
394 | function updateHistory(pattern) {
395 | const i = History.findIndex(x => x.toLowerCase() === pattern.toLowerCase());
396 | if (i == 0) {
397 | return;
398 | }
399 | if (i > 0) {
400 | History.splice(i, 1);
401 | }
402 | History.unshift(pattern);
403 | if (History.length > HistoryLimit) {
404 | History.pop();
405 | }
406 | const body = { history: pattern };
407 | util.ajax({ method: 'POST', url: '/api/update-history', alerts: SearchAlerts, body: body }, () => {
408 | refreshHistory();
409 | });
410 | }
411 |
--------------------------------------------------------------------------------