├── 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 | ![screenshot-01](screenshots/screenshot-01.webp#gh-light-mode-only) 16 | ![screenshot-01](screenshots/screenshot-dark-01.webp#gh-dark-mode-only) 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 | ![screenshot-03](screenshots/screenshot-03.webp#gh-light-mode-only) 97 | ![screenshot-03](screenshots/screenshot-dark-03.webp#gh-dark-mode-only) 98 | 99 | 图2: 100 | 101 | ![screenshot-04](screenshots/screenshot-04.webp#gh-light-mode-only) 102 | ![screenshot-04](screenshots/screenshot-dark-04.webp#gh-dark-mode-only) 103 | 104 | 图3: 105 | 106 | ![screenshot-02](screenshots/screenshot-02.webp#gh-light-mode-only) 107 | ![screenshot-02](screenshots/screenshot-dark-02.webp#gh-dark-mode-only) 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 | --------------------------------------------------------------------------------