diff --git a/2017/scripts/build-database.js b/2017/scripts/build-database.js
index 767cbf0..bb37ddb 100644
--- a/2017/scripts/build-database.js
+++ b/2017/scripts/build-database.js
@@ -9,21 +9,34 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.join(__dirname, "..", "data", "raw");
const DB_PATH = path.join(__dirname, "..", "public", "thptqg2017.db");
-// Score patterns matching the Java Converter.java regex
+// Score patterns. Note: source data uses both "X.YZ" and "X" forms; allow optional decimal.
+const NUM = "(\\d+(?:\\.\\d+)?)";
const SCORE_PATTERNS = {
- toan: /Toán:\s*(\d*\.\d*)/,
- ngu_van: /Ngữ văn:\s*(\d*\.\d*)/,
- vat_ly: /Vật lí:\s*(\d*\.\d*)/,
- hoa_hoc: /Hóa học:\s*(\d*\.\d*)/,
- sinh_hoc: /Sinh học:\s*(\d*\.\d*)/,
- khtn: /KHTN:\s*(\d*\.\d*)/,
- lich_su: /Lịch sử:\s*(\d*\.\d*)/,
- dia_ly: /Địa lí:\s*(\d*\.\d*)/,
- gdcd: /GDCD:\s*(\d*\.\d*)/,
- khxh: /KHXH:\s*(\d*\.\d*)/,
- tieng_anh: /Tiếng Anh:\s*(\d*\.\d*)/,
+ toan: new RegExp("Toán:\\s*" + NUM),
+ ngu_van: new RegExp("Ngữ văn:\\s*" + NUM),
+ vat_ly: new RegExp("Vật lí:\\s*" + NUM),
+ hoa_hoc: new RegExp("Hóa học:\\s*" + NUM),
+ sinh_hoc: new RegExp("Sinh học:\\s*" + NUM),
+ khtn: new RegExp("KHTN:\\s*" + NUM),
+ lich_su: new RegExp("Lịch sử:\\s*" + NUM),
+ dia_ly: new RegExp("Địa lí:\\s*" + NUM),
+ gdcd: new RegExp("GDCD:\\s*" + NUM),
+ khxh: new RegExp("KHXH:\\s*" + NUM),
+ tieng_anh: new RegExp("Tiếng Anh:\\s*" + NUM),
+ tieng_phap: new RegExp("Tiếng Pháp:\\s*" + NUM),
+ tieng_nga: new RegExp("Tiếng Nga:\\s*" + NUM),
+ tieng_trung: new RegExp("Tiếng Trung:\\s*" + NUM),
};
+// Strip Vietnamese diacritics for ASCII-insensitive search
+function toAscii(str) {
+ return str
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/đ/gi, "d")
+ .toLowerCase();
+}
+
// Collect all .xlsx files: raw/ first, then raw/(update)/ to overwrite
function collectExcelFiles() {
const files = [];
@@ -81,33 +94,43 @@ function main() {
// Create table
db.exec(`
CREATE TABLE student (
- so_bao_danh TEXT PRIMARY KEY,
- ho_ten TEXT NOT NULL,
- ngay_sinh TEXT,
- toan REAL,
- ngu_van REAL,
- vat_ly REAL,
- hoa_hoc REAL,
- sinh_hoc REAL,
- khtn REAL,
- lich_su REAL,
- dia_ly REAL,
- gdcd REAL,
- khxh REAL,
- tieng_anh REAL
+ so_bao_danh TEXT PRIMARY KEY,
+ ho_ten TEXT NOT NULL,
+ ho_ten_ascii TEXT NOT NULL,
+ ngay_sinh TEXT,
+ toan REAL,
+ ngu_van REAL,
+ vat_ly REAL,
+ hoa_hoc REAL,
+ sinh_hoc REAL,
+ khtn REAL,
+ lich_su REAL,
+ dia_ly REAL,
+ gdcd REAL,
+ khxh REAL,
+ tieng_anh REAL,
+ tieng_phap REAL,
+ tieng_nga REAL,
+ tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
+ CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
`);
const insert = db.prepare(`
INSERT OR REPLACE INTO student
- (so_bao_danh, ho_ten, ngay_sinh, toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn, lich_su, dia_ly, gdcd, khxh, tieng_anh)
+ (so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
+ toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
+ lich_su, dia_ly, gdcd, khxh,
+ tieng_anh, tieng_phap, tieng_nga, tieng_trung)
VALUES
- (@so_bao_danh, @ho_ten, @ngay_sinh, @toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @khtn, @lich_su, @dia_ly, @gdcd, @khxh, @tieng_anh)
+ (@so_bao_danh, @ho_ten, @ho_ten_ascii, @ngay_sinh,
+ @toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @khtn,
+ @lich_su, @dia_ly, @gdcd, @khxh,
+ @tieng_anh, @tieng_phap, @tieng_nga, @tieng_trung)
`);
const files = collectExcelFiles();
- let totalRows = 0;
let errorCount = 0;
// Wrap all inserts in a single transaction for speed
@@ -141,6 +164,7 @@ function main() {
insert.run({
so_bao_danh: soBaoDanh,
ho_ten: hoTen,
+ ho_ten_ascii: toAscii(hoTen),
ngay_sinh: ngaySinh || null,
toan: scores.toan ?? null,
ngu_van: scores.ngu_van ?? null,
@@ -153,17 +177,22 @@ function main() {
gdcd: scores.gdcd ?? null,
khxh: scores.khxh ?? null,
tieng_anh: scores.tieng_anh ?? null,
+ tieng_phap: scores.tieng_phap ?? null,
+ tieng_nga: scores.tieng_nga ?? null,
+ tieng_trung: scores.tieng_trung ?? null,
});
fileRows++;
} catch (err) {
errorCount++;
+ if (errorCount <= 5) {
+ console.warn(` [warn] ${basename} row ${i}: ${err.message}`);
+ }
}
}
} catch (err) {
console.error(`Failed to read ${basename}: ${err.message}`);
}
- totalRows += fileRows;
console.log(` ${basename}: ${fileRows} rows`);
}
});
diff --git a/2017/src/App.css b/2017/src/App.css
index aac6ded..33598df 100644
--- a/2017/src/App.css
+++ b/2017/src/App.css
@@ -21,6 +21,132 @@ header h1 {
font-size: 0.95rem;
}
+/* Tabs */
+.tabs {
+ display: flex;
+ gap: 0;
+ max-width: 600px;
+ margin: 0 auto 1.5rem;
+ border-bottom: 2px solid #ddd;
+}
+
+.tab {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ font-size: 1rem;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ color: #666;
+ transition: color 0.2s, border-color 0.2s;
+}
+
+.tab:hover {
+ color: #4361ee;
+}
+
+.tab.active {
+ color: #4361ee;
+ border-bottom-color: #4361ee;
+ font-weight: 600;
+}
+
+/* Custom query */
+.custom-query {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.preset-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ align-items: center;
+}
+
+.preset-label {
+ font-size: 0.85rem;
+ color: #666;
+ white-space: nowrap;
+}
+
+.preset-btn {
+ padding: 0.35rem 0.75rem;
+ font-size: 0.8rem;
+ background: #f0f0f5;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.preset-btn:hover:not(:disabled) {
+ background: #e8e8f0;
+ border-color: #4361ee;
+}
+
+.preset-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.query-form {
+ margin-bottom: 1rem;
+}
+
+.query-form textarea {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ font-size: 0.9rem;
+ font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ outline: none;
+ resize: vertical;
+ min-height: 120px;
+ transition: border-color 0.2s;
+ tab-size: 2;
+}
+
+.query-form textarea:focus {
+ border-color: #4361ee;
+}
+
+.query-actions {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 0.5rem;
+}
+
+.query-actions button {
+ padding: 0.6rem 1.5rem;
+ font-size: 0.95rem;
+ background: #4361ee;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.query-actions button:hover:not(:disabled) {
+ background: #3a56d4;
+}
+
+.query-actions button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.exec-time {
+ font-size: 0.85rem;
+ color: #666;
+}
+
/* Search form */
.search-form {
display: flex;
@@ -181,4 +307,9 @@ footer {
table {
font-size: 0.8rem;
}
+
+ .preset-list {
+ flex-direction: column;
+ align-items: flex-start;
+ }
}
diff --git a/2017/src/App.jsx b/2017/src/App.jsx
index 27d6873..855e90d 100644
--- a/2017/src/App.jsx
+++ b/2017/src/App.jsx
@@ -1,16 +1,35 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
import { useSqlite } from "./hooks/use-sqlite";
import { SearchForm } from "./components/search-form";
import { ScoreTable } from "./components/score-table";
+import { CustomQuery } from "./components/custom-query";
import "./App.css";
const DB_URL = import.meta.env.BASE_URL + "thptqg2017.db.gz";
const MAX_RESULTS = 100;
+// Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc"
+function toAscii(str) {
+ return str
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/đ/gi, "d")
+ .toLowerCase();
+}
+
+function isAsciiOnly(str) {
+ // True if no Vietnamese diacritics or non-ASCII chars present
+ for (let i = 0; i < str.length; i++) {
+ if (str.charCodeAt(i) > 127) return false;
+ }
+ return true;
+}
+
function App() {
const { db, loading, error, progress } = useSqlite(DB_URL);
const [results, setResults] = useState(null);
const [searchError, setSearchError] = useState(null);
+ const [activeTab, setActiveTab] = useState("search");
const handleSearch = useCallback(
(query) => {
@@ -22,17 +41,29 @@ function App() {
let stmt;
if (isExamId) {
- // Exact match on số báo danh
stmt = db.prepare(
- "SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit"
+ "SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
);
stmt.bind({ $q: query, $limit: MAX_RESULTS });
- } else {
- // LIKE search on name (case-insensitive with Vietnamese)
+ } else if (isAsciiOnly(query)) {
+ // ASCII input → match against folded column (handles "nguyen" = "Nguyễn")
stmt = db.prepare(
- "SELECT * FROM student WHERE ho_ten LIKE $q LIMIT $limit"
+ "SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
);
- stmt.bind({ $q: `%${query}%`, $limit: MAX_RESULTS });
+ stmt.bind({ $q: `%${toAscii(query)}%`, $limit: MAX_RESULTS });
+ } else {
+ // Vietnamese input → search both original and folded columns
+ const normalized = toAscii(query);
+ stmt = db.prepare(
+ `SELECT * FROM student
+ WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn
+ LIMIT $limit`,
+ );
+ stmt.bind({
+ $q: `%${query}%`,
+ $qn: `%${normalized}%`,
+ $limit: MAX_RESULTS,
+ });
}
const rows = [];
@@ -40,20 +71,33 @@ function App() {
rows.push(stmt.getAsObject());
}
stmt.free();
-
setResults(rows);
} catch (err) {
setSearchError(err.message);
}
},
- [db]
+ [db],
);
+ // Ctrl+Enter submits the SQL query form when in SQL tab
+ useEffect(() => {
+ function handleKeyDown(e) {
+ if (e.ctrlKey && e.key === "Enter" && activeTab === "sql") {
+ const form = document.querySelector(".query-form");
+ if (form) form.requestSubmit();
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [activeTab]);
+
return (
@@ -68,16 +112,41 @@ function App() {
{error && Lỗi: {error}
}
-
+
+
+
+
- {searchError && Lỗi truy vấn: {searchError}
}
+ {activeTab === "search" && (
+ <>
+
-
+ {searchError && (
+ Lỗi truy vấn: {searchError}
+ )}
- {results && results.length >= MAX_RESULTS && (
-
- Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn.
-
+
+
+ {results && results.length >= MAX_RESULTS && (
+
+ Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn.
+
+ )}
+ >
+ )}
+
+ {activeTab === "sql" && (
+
)}
diff --git a/2017/src/components/custom-query.jsx b/2017/src/components/custom-query.jsx
new file mode 100644
index 0000000..081bd08
--- /dev/null
+++ b/2017/src/components/custom-query.jsx
@@ -0,0 +1,215 @@
+import { useState, useCallback } from "react";
+
+const MAX_ROWS = 1000;
+
+const PRESET_QUERIES = [
+ {
+ label: "Top 10 điểm Toán cao nhất",
+ sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
+FROM student WHERE toan IS NOT NULL
+ORDER BY toan DESC LIMIT 10`,
+ },
+ {
+ label: "Top 10 KHTN (TB Vật lí + Hóa + Sinh)",
+ sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
+FROM student WHERE khtn IS NOT NULL
+ORDER BY khtn DESC LIMIT 10`,
+ },
+ {
+ label: "Top 10 KHXH (TB Sử + Địa + GDCD)",
+ sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
+FROM student WHERE khxh IS NOT NULL
+ORDER BY khxh DESC LIMIT 10`,
+ },
+ {
+ label: "Thí sinh đạt 9+ điểm Toán",
+ sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
+FROM student WHERE toan >= 9
+ORDER BY toan DESC LIMIT 50`,
+ },
+ {
+ label: "Phân bố điểm Toán",
+ sql: `SELECT
+ CASE
+ WHEN toan < 1 THEN '0-1'
+ WHEN toan < 2 THEN '1-2'
+ WHEN toan < 3 THEN '2-3'
+ WHEN toan < 4 THEN '3-4'
+ WHEN toan < 5 THEN '4-5'
+ WHEN toan < 6 THEN '5-6'
+ WHEN toan < 7 THEN '6-7'
+ WHEN toan < 8 THEN '7-8'
+ WHEN toan < 9 THEN '8-9'
+ ELSE '9-10'
+ END AS khoang_diem,
+ COUNT(*) AS so_luong
+FROM student WHERE toan IS NOT NULL
+GROUP BY khoang_diem
+ORDER BY khoang_diem`,
+ },
+ {
+ label: "Số thí sinh theo ngoại ngữ",
+ sql: `SELECT
+ SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
+ SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
+ SUM(CASE WHEN tieng_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
+ SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
+FROM student`,
+ },
+ {
+ label: "Số thí sinh chọn KHTN vs KHXH",
+ sql: `SELECT
+ SUM(CASE WHEN khtn IS NOT NULL THEN 1 ELSE 0 END) AS chon_khtn,
+ SUM(CASE WHEN khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_khxh,
+ SUM(CASE WHEN khtn IS NOT NULL AND khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_ca_hai
+FROM student`,
+ },
+ {
+ label: "Schema bảng student",
+ sql: `PRAGMA table_info(student)`,
+ },
+];
+
+export function CustomQuery({ db, disabled }) {
+ const [sql, setSql] = useState("");
+ const [columns, setColumns] = useState([]);
+ const [rows, setRows] = useState([]);
+ const [queryError, setQueryError] = useState(null);
+ const [execTime, setExecTime] = useState(null);
+
+ const executeQuery = useCallback(
+ (queryStr) => {
+ if (!db) return;
+ setQueryError(null);
+ setColumns([]);
+ setRows([]);
+ setExecTime(null);
+
+ const trimmed = queryStr.trim();
+ if (!trimmed) return;
+
+ // Safety: only allow read-only statements
+ const upper = trimmed.toUpperCase();
+ const allowed = ["SELECT", "PRAGMA", "EXPLAIN", "WITH"];
+ if (!allowed.some((kw) => upper.startsWith(kw))) {
+ setQueryError(
+ "Chỉ hỗ trợ truy vấn đọc (SELECT, PRAGMA, EXPLAIN, WITH).",
+ );
+ return;
+ }
+
+ // Auto-add LIMIT if user forgot
+ let finalSql = trimmed;
+ if (
+ upper.startsWith("SELECT") &&
+ !upper.includes("LIMIT") &&
+ !upper.includes("PRAGMA")
+ ) {
+ finalSql = `${trimmed.replace(/;$/, "")} LIMIT ${MAX_ROWS}`;
+ }
+
+ try {
+ const start = performance.now();
+ const stmt = db.prepare(finalSql);
+ const colNames = stmt.getColumnNames();
+ const resultRows = [];
+
+ let count = 0;
+ while (stmt.step() && count < MAX_ROWS) {
+ resultRows.push(stmt.get());
+ count++;
+ }
+ stmt.free();
+
+ const elapsed = performance.now() - start;
+ setColumns(colNames);
+ setRows(resultRows);
+ setExecTime(elapsed.toFixed(1));
+ } catch (err) {
+ setQueryError(err.message);
+ }
+ },
+ [db],
+ );
+
+ function handleSubmit(e) {
+ e.preventDefault();
+ executeQuery(sql);
+ }
+
+ function handlePreset(presetSql) {
+ setSql(presetSql);
+ executeQuery(presetSql);
+ }
+
+ return (
+
+
+ Mẫu truy vấn:
+ {PRESET_QUERIES.map((p, i) => (
+
+ ))}
+
+
+
+
+ {queryError &&
Lỗi: {queryError}
}
+
+ {columns.length > 0 && (
+
+
+
+
+ {columns.map((col, i) => (
+ | {col} |
+ ))}
+
+
+
+ {rows.map((row, ri) => (
+
+ {row.map((cell, ci) => (
+ |
+ {cell === null ? "NULL" : String(cell)}
+ |
+ ))}
+
+ ))}
+
+
+ {rows.length >= MAX_ROWS && (
+
+ Hiển thị tối đa {MAX_ROWS} kết quả. Thêm LIMIT để giới hạn.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/2017/src/components/score-table.jsx b/2017/src/components/score-table.jsx
index 9563f13..8dcb246 100644
--- a/2017/src/components/score-table.jsx
+++ b/2017/src/components/score-table.jsx
@@ -10,6 +10,9 @@ const SUBJECT_COLUMNS = [
{ key: "gdcd", label: "GDCD" },
{ key: "khxh", label: "KHXH" },
{ key: "tieng_anh", label: "Tiếng Anh" },
+ { key: "tieng_phap", label: "Tiếng Pháp" },
+ { key: "tieng_nga", label: "Tiếng Nga" },
+ { key: "tieng_trung", label: "Tiếng Trung" },
];
function formatScore(val) {
@@ -23,6 +26,11 @@ export function ScoreTable({ results }) {
return
Không tìm thấy kết quả.
;
}
+ // Hide columns where every row in the result set is null (e.g. unused foreign langs)
+ const visibleColumns = SUBJECT_COLUMNS.filter((col) =>
+ results.some((row) => row[col.key] !== null && row[col.key] !== undefined),
+ );
+
return (
Tìm thấy {results.length} kết quả
@@ -32,7 +40,7 @@ export function ScoreTable({ results }) {
SBD |
Họ tên |
Ngày sinh |
- {SUBJECT_COLUMNS.map((col) => (
+ {visibleColumns.map((col) => (
{col.label} |
))}
@@ -43,7 +51,7 @@ export function ScoreTable({ results }) {
{row.so_bao_danh} |
{row.ho_ten} |
{row.ngay_sinh || "—"} |
- {SUBJECT_COLUMNS.map((col) => (
+ {visibleColumns.map((col) => (
{formatScore(row[col.key])}
|