#!/usr/bin/env bash # dic — 命令列字典查詢工具(bash 版) # 用法: # dic 香蕉 兩本字典都查 # dic -r 香蕉 只查重編修訂本 # dic -c 香蕉 只查簡編本 # dic -a 香蕉 顯示所有匹配(包含部分匹配) # dic -l 香蕉 只列出匹配的字詞名清單 # dic --no-color 香蕉 不染色 # dic -h 顯示說明 set -u # ======================================== # 設定區:改成你的 CSV 檔案完整路徑 DICT_NAME_1="重編修訂本" DICT_PATH_1="$HOME/.local/share/dic/dict_revised.csv" DICT_NAME_2="簡編本" DICT_PATH_2="$HOME/.local/share/dic/dict_concise.csv" # ======================================== PROG_NAME="dic" show_help() { cat <<'EOF' dic — 命令列字典查詢工具 用法: dic 香蕉 兩本字典都查 dic -r 香蕉 只查重編修訂本 dic -c 香蕉 只查簡編本 dic -a 香蕉 顯示所有匹配(包含部分匹配) dic -l 香蕉 只列出匹配的字詞名清單 dic --no-color 香蕉 不染色 dic -h 顯示說明 EOF } # --- 顏色設定 --- COLOR_ENABLED=1 setup_color() { local force_off="$1" if [[ "$force_off" == "1" ]] || [[ ! -t 1 ]]; then COLOR_ENABLED=0 fi } c_wrap() { # $1 = 顏色 code, $2 = 文字 if [[ "$COLOR_ENABLED" == "0" ]]; then printf '%s' "$2" else printf '\033[%sm%s\033[0m' "$1" "$2" fi } c_bold() { c_wrap "1" "$1"; } c_dim() { c_wrap "2" "$1"; } c_title() { c_wrap "1;36" "$1"; } # 粗體青色 c_zhuyin() { c_wrap "33" "$1"; } # 黃色 c_book() { c_wrap "1;35" "$1"; } # 粗體紫色(字典名) c_hint() { c_wrap "2;37" "$1"; } # 淡灰色提示 # 詞性 [名][動] 等,要在釋義裡 inline 染色,由 awk 處理 # --- CSV 解析 --- # 完整處理 RFC 4180 風格 CSV: # - 雙引號內可以有逗號、換行 # - 連續兩個雙引號 "" 代表一個字面雙引號 # 輸出格式:每筆紀錄一行,欄位之間用 \x1f (US, unit separator) 分隔, # 欄位內的換行保留為字面 \n(兩個字元),方便後面再還原。 # # 第一行(header)也會被輸出,呼叫端負責跳過。 parse_csv() { # $1 = 檔案路徑 awk -v FS="" ' BEGIN { in_quote = 0 field = "" nfields = 0 # 用陣列存當前 record 的所有欄位 delete fields } { # awk 一行一行讀進來;如果上一行還在引號裡,就把換行補回去 if (in_quote) { field = field "\\n" # 字面 \n 兩字元,避免破壞分隔 } line = $0 n = length(line) for (i = 1; i <= n; i++) { ch = substr(line, i, 1) if (in_quote) { if (ch == "\"") { # 看下一個字元,判斷是 escaped quote 還是收尾 next_ch = (i < n) ? substr(line, i+1, 1) : "" if (next_ch == "\"") { field = field "\"" i++ } else { in_quote = 0 } } else { field = field ch } } else { if (ch == "\"") { in_quote = 1 } else if (ch == ",") { fields[nfields++] = field field = "" } else { field = field ch } } } # 行尾:如果不在引號內,代表一筆 record 結束 if (!in_quote) { fields[nfields++] = field # 印出這筆 record,欄位用 \x1f 分隔 out = "" for (k = 0; k < nfields; k++) { if (k > 0) out = out "\x1f" out = out fields[k] } print out # 重置 field = "" nfields = 0 delete fields } } ' "$1" } # 找出 header 中「字詞名」「注音一式」「釋義」的欄位編號 # 輸出三個數字,用空格分隔 find_columns() { local file="$1" parse_csv "$file" | head -n 1 | awk -F$'\x1f' ' { idx_name = -1; idx_zhuyin = -1; idx_def = -1 for (i = 1; i <= NF; i++) { if ($i == "字詞名") idx_name = i if ($i == "注音一式") idx_zhuyin = i if ($i == "釋義") idx_def = i } printf "%d %d %d\n", idx_name, idx_zhuyin, idx_def } ' } # 載入字典並依條件篩選 # $1 = 檔案路徑 # $2 = query # $3 = mode: "exact" 或 "all" # 輸出每筆結果:name \x1f zhuyin \x1f definition(其中 definition 內的換行還是字面 \n) search_dict() { local file="$1" local query="$2" local mode="$3" if [[ ! -f "$file" ]]; then # 警告寫到 stderr if [[ "$COLOR_ENABLED" == "1" ]]; then printf '\033[2m警告:\033[0m找不到字典檔 %s\n' "$file" >&2 else printf '警告:找不到字典檔 %s\n' "$file" >&2 fi return fi local cols cols=$(find_columns "$file") local idx_name idx_zhuyin idx_def read -r idx_name idx_zhuyin idx_def <<< "$cols" if [[ "$idx_name" == "-1" ]]; then return fi parse_csv "$file" | awk -F$'\x1f' \ -v idx_name="$idx_name" \ -v idx_zhuyin="$idx_zhuyin" \ -v idx_def="$idx_def" \ -v query="$query" \ -v mode="$mode" ' NR == 1 { next } # 跳過 header { name = $idx_name if (name == "") next if (mode == "exact") { if (name != query) next sort_key = "0\t0\t" length(name) "\t" name } else { if (index(name, query) == 0) next exact = (name == query) ? 0 : 1 starts = (index(name, query) == 1) ? 0 : 1 sort_key = exact "\t" starts "\t" length(name) "\t" name } zhuyin = (idx_zhuyin > 0) ? $idx_zhuyin : "" def = (idx_def > 0) ? $idx_def : "" # 用 sort_key 開頭,方便外面用 sort 排,後面再砍掉 # 欄位:sort_key \x1f name \x1f zhuyin \x1f definition printf "%s\x1f%s\x1f%s\x1f%s\n", sort_key, name, zhuyin, def } ' | LC_ALL=C sort -t $'\x1f' -k1,1 | awk -F$'\x1f' ' { # 砍掉第一欄 sort_key out = "" for (i = 2; i <= NF; i++) { if (i > 2) out = out "\x1f" out = out $i } print out } ' } # 算字典裡「包含 query 但不完全相符」的筆數 count_partial() { local file="$1" local query="$2" if [[ ! -f "$file" ]]; then echo 0 return fi local cols cols=$(find_columns "$file") local idx_name _z _d read -r idx_name _z _d <<< "$cols" if [[ "$idx_name" == "-1" ]]; then echo 0 return fi parse_csv "$file" | awk -F$'\x1f' \ -v idx_name="$idx_name" \ -v query="$query" ' NR == 1 { next } { name = $idx_name if (name == "") next if (index(name, query) > 0 && name != query) c++ } END { print c+0 } ' } # 把釋義裡的 [名][動][形] 之類染色 # 從 stdin 讀,印到 stdout colorize_definition() { if [[ "$COLOR_ENABLED" == "0" ]]; then cat return fi # 詞性標記:[ 後面 1~4 個非 []\n 字元,接 ] # 用 sed 的 ERE sed -E $'s/(\\[[^][\\n]{1,4}\\])/\033[1;32m\\1\033[0m/g' } # 印一筆字典資料 # $1 = book_name # $2 = name # $3 = zhuyin # $4 = definition(其中換行為字面 \n 兩字元) print_entry() { local book="$1" local name="$2" local zhuyin="$3" local def="$4" printf ' %s\n' "$(c_book "▎$book")" printf ' %s %s\n' "$(c_title "$name")" "$(c_zhuyin "$zhuyin")" if [[ -n "$def" ]]; then # 把字面 \n 還原成真的換行,每行縮排 4 格,然後染色詞性標記 # 並且把行尾的空白砍掉(對應 Python 的 .rstrip()) printf '%s' "$def" \ | awk 'BEGIN{RS="\\\\n"} { sub(/[ \t\r]+$/, ""); print }' \ | sed -E '$ { /^$/d; }' \ | colorize_definition \ | sed 's/^/ /' fi printf '\n' } # --- 主程式 --- # 解析參數 QUERY="" OPT_REVISED=0 OPT_CONCISED=0 OPT_ALL=0 OPT_LIST=0 OPT_NO_COLOR=0 OPT_HELP=0 # 自己處理參數(不用 getopt,因為要支援 --long 且不想引外部依賴) while [[ $# -gt 0 ]]; do case "$1" in -h|--help) OPT_HELP=1; shift ;; -r|--revised) OPT_REVISED=1; shift ;; -c|--concised) OPT_CONCISED=1; shift ;; -a|--all) OPT_ALL=1; shift ;; -l|--list) OPT_LIST=1; shift ;; --no-color) OPT_NO_COLOR=1; shift ;; --) shift if [[ $# -gt 0 ]]; then QUERY="$1"; shift; fi ;; -*) # 支援 -rc 這種合併短參數 arg="${1#-}" if [[ "$arg" =~ ^[rcalh]+$ ]]; then for (( i=0; i<${#arg}; i++ )); do ch="${arg:$i:1}" case "$ch" in r) OPT_REVISED=1 ;; c) OPT_CONCISED=1 ;; a) OPT_ALL=1 ;; l) OPT_LIST=1 ;; h) OPT_HELP=1 ;; esac done shift else printf 'dic: 未知選項 %s\n' "$1" >&2 exit 2 fi ;; *) if [[ -z "$QUERY" ]]; then QUERY="$1" fi shift ;; esac done if [[ "$OPT_HELP" == "1" ]] || [[ -z "$QUERY" ]]; then show_help exit 0 fi setup_color "$OPT_NO_COLOR" # 決定要查哪幾本 declare -a CHOSEN_NAMES CHOSEN_PATHS if [[ "$OPT_REVISED" == "1" && "$OPT_CONCISED" == "1" ]]; then CHOSEN_NAMES=("$DICT_NAME_1" "$DICT_NAME_2") CHOSEN_PATHS=("$DICT_PATH_1" "$DICT_PATH_2") elif [[ "$OPT_REVISED" == "1" ]]; then CHOSEN_NAMES=("$DICT_NAME_1") CHOSEN_PATHS=("$DICT_PATH_1") elif [[ "$OPT_CONCISED" == "1" ]]; then CHOSEN_NAMES=("$DICT_NAME_2") CHOSEN_PATHS=("$DICT_PATH_2") else CHOSEN_NAMES=("$DICT_NAME_1" "$DICT_NAME_2") CHOSEN_PATHS=("$DICT_PATH_1" "$DICT_PATH_2") fi # -l 列表模式 → 自動切到 all if [[ "$OPT_ALL" == "1" || "$OPT_LIST" == "1" ]]; then MODE="all" else MODE="exact" fi # 收集每本字典的結果。 # 因為 bash 沒有結構化資料,把每本字典的結果暫存到一個 tmp 檔。 TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT TOTAL_RESULTS=0 TOTAL_PARTIAL=0 NUM_BOOKS=${#CHOSEN_NAMES[@]} # partial counts per book(給 exact 模式用,提示「另有 N 筆包含」) declare -a BOOK_RESULT_COUNTS BOOK_PARTIAL_COUNTS BOOK_RESULT_FILES for (( bi=0; bi "$out_file" count=$(wc -l < "$out_file" | tr -d ' ') BOOK_RESULT_COUNTS[$bi]="$count" BOOK_RESULT_FILES[$bi]="$out_file" TOTAL_RESULTS=$(( TOTAL_RESULTS + count )) if [[ "$MODE" == "exact" ]]; then partial=$(count_partial "$path" "$QUERY") else partial=0 fi BOOK_PARTIAL_COUNTS[$bi]="$partial" TOTAL_PARTIAL=$(( TOTAL_PARTIAL + partial )) done # --- 列表模式:只印字詞名 --- if [[ "$OPT_LIST" == "1" ]]; then for (( bi=0; bi