Sách Giáo Khoa Việt Nam - sachgiaokhoa.org

CodeIgniter 4 website:
- Catalog 999 SGK (lớp 1-12) từ API, lọc theo lớp/môn/NXB, tìm kiếm
- Trang chủ grouped browse (bậc học -> lớp -> môn, ưu tiên môn quan trọng)
- Chi tiết sách: metadata đầy đủ, cover nghệ thuật /thumb/{W}x{H}/
- Đọc online qua gate captcha + countdown, PDF viewer pdf.js
- Download: captcha + token một lần + auto cache-bust version
- Tự đồng bộ catalog/cover khi API thay đổi (refresh:catalog)
- Legal: DMCA, miễn trừ trách nhiệm, điều khoản, bảo mật, liên hệ
This commit is contained in:
sachgiaokhoaorg
2026-08-23 07:05:49 +00:00
commit e6b265db99
494 changed files with 134829 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\BookApi;
use App\Libraries\Mongo;
class Books extends BaseController
{
private BookApi $api;
/** School level groupings for the browse homepage. */
private const LEVELS = [
['key' => 'th', 'label' => 'Bậc Tiểu học', 'grades' => [1, 2, 3, 4, 5]],
['key' => 'thcs','label' => 'Bậc THCS', 'grades' => [6, 7, 8, 9]],
['key' => 'thpt','label' => 'Bậc THPT', 'grades' => [10, 11, 12]],
];
/**
* Subject display order: important subjects first, the rest alphabetical.
* Values are matched after diacritics/case normalization (e.g. "Vật lí" → "vat li").
*/
private const SUBJECT_PRIORITY = [
'Toán' => 10,
'Đại số' => 10,
'Giải tích' => 10,
'Hình học' => 10,
'Tiếng Việt' => 20,
'Ngữ văn' => 20,
'Tiếng Anh' => 30,
'Vật lí' => 40,
'Hóa học' => 50,
'Sinh học' => 60,
'Lịch sử' => 70,
'Địa lí' => 80,
'Giáo dục công dân' => 90,
'Giáo dục Kinh tế và Pháp luật' => 95,
'Giáo dục Quốc phòng' => 96,
'Giáo dục thể chất' => 97,
'Tin học' => 110,
'Công nghệ' => 120,
'Kĩ thuật' => 125,
'Tự nhiên và Xã hội' => 130,
'Khoa học' => 135,
'Đạo đức' => 140,
'Hoạt động trải nghiệm' => 150,
'Âm nhạc' => 160,
'Mĩ thuật' => 170,
'Atlat Địa lí' => 181,
'Bài giải' => 199,
];
/** Cache of normalized subject → rank. */
private array $subjectRankCache = [];
public function __construct()
{
$this->api = new BookApi();
}
/**
* Catalog: browse view when no filters; grouped, paginated list when filtered.
*/
public function index()
{
$grade = $this->request->getGet('lop');
$publisher = $this->request->getGet('nxb');
$subject = $this->request->getGet('mon');
$q = trim((string) $this->request->getGet('q'));
$page = max(1, (int) ($this->request->getGet('trang') ?: 1));
$perPage = 48;
try {
$books = $this->api->catalog();
} catch (\Throwable $e) {
return view('pages/error_api', ['message' => $e->getMessage()]);
}
$isFiltered = ($grade !== null && $grade !== '')
|| ($publisher !== null && $publisher !== '')
|| ($subject !== null && $subject !== '')
|| $q !== '';
// ------- Browse homepage (no filters): level → grade → subjects -------
if (! $isFiltered) {
return $this->browseView($books);
}
// ------- Filtered list view -------
$filtered = array_values(array_filter($books, function ($b) use ($grade, $publisher, $subject, $q) {
if ($grade !== null && $grade !== '' && (string) $b['grade'] !== (string) $grade) return false;
if ($publisher !== null && $publisher !== '' && $b['publisher'] !== $publisher) return false;
if ($subject !== null && $subject !== '' && $b['subject'] !== $subject) return false;
if ($q !== '' && mb_strpos($b['search'] ?? '', mb_strtolower($q)) === false) return false;
return true;
}));
// Group key: by subject when a grade is chosen, else by grade
$hasGrade = $grade !== null && $grade !== '';
$groupFn = $hasGrade
? fn($b) => $b['subject'] !== '' ? $b['subject'] : 'Khác'
: fn($b) => $b['grade'] !== '' ? 'Lớp ' . $b['grade'] : 'Không phân lớp';
// sort: group label, then title (within grade groups keep subject order too)
if ($hasGrade) {
usort($filtered, function ($a, $b2) use ($groupFn) {
$cmp = $this->compareSubjects($groupFn($a), $groupFn($b2));
return $cmp ?: strcmp((string) $a['title'], (string) $b2['title']);
});
} else {
usort($filtered, function ($a, $b2) {
$ga = (int) ($a['grade'] ?: 99);
$gb = (int) ($b2['grade'] ?: 99);
return $ga <=> $gb
?: $this->compareSubjects((string) $a['subject'], (string) $b2['subject'])
?: strcmp((string) $a['title'], (string) $b2['title']);
});
}
$total = count($filtered);
$totalPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $totalPage);
$slice = array_slice($filtered, ($page - 1) * $perPage, $perPage);
// group the page slice for rendering
$grouped = [];
foreach ($slice as $b) {
$grouped[$groupFn($b)][] = $b;
}
if ($hasGrade) {
// group by subject, ordered by subject priority
uksort($grouped, fn($a, $b2) => $this->compareSubjects($a, $b2));
} else {
ksort($grouped, SORT_NATURAL);
}
return view('books/index', [
'grouped' => $grouped,
'total' => $total,
'page' => $page,
'totalPage' => $totalPage,
'grades' => $this->gradeList($books),
'publishers' => $this->facet($books, 'publisher'),
'subjects' => $this->facet($books, 'subject'),
'filters' => ['lop' => $grade, 'nxb' => $publisher, 'mon' => $subject, 'q' => $q],
]);
}
/**
* Build the grouped browse homepage.
*/
private function browseView(array $books): string
{
$publishers = $this->facet($books, 'publisher');
// grade => [subject => count], grade => total
$byGrade = [];
foreach ($books as $b) {
$g = (string) $b['grade'];
if ($g === '') {
$g = '0';
}
$byGrade[$g]['total'] = ($byGrade[$g]['total'] ?? 0) + 1;
$s = $b['subject'] !== '' ? $b['subject'] : 'Khác';
$byGrade[$g]['subjects'][$s] = ($byGrade[$g]['subjects'][$s] ?? 0) + 1;
// keep up to 5 cover previews per grade (spread across subjects)
$used = array_column($byGrade[$g]['covers'] ?? [], 'subject');
if (count($byGrade[$g]['covers'] ?? []) < 5 && ! in_array($s, $used, true)) {
$byGrade[$g]['covers'][] = ['id' => $b['id'], 'title' => $b['title'], 'subject' => $s];
}
}
$levels = [];
foreach (self::LEVELS as $lv) {
$grades = [];
foreach ($lv['grades'] as $g) {
if (isset($byGrade[(string) $g])) {
$subs = $byGrade[(string) $g]['subjects'] ?? [];
uksort($subs, fn($a, $b2) => $this->compareSubjects($a, $b2));
$grades[] = [
'grade' => $g,
'total' => $byGrade[(string) $g]['total'],
'subjects'=> $subs,
'covers' => $byGrade[(string) $g]['covers'] ?? [],
];
}
}
if ($grades) {
$levels[] = ['label' => $lv['label'], 'grades' => $grades];
}
}
return view('books/browse', [
'total' => count($books),
'levels' => $levels,
'publishers' => $publishers,
]);
}
private function gradeList(array $books): array
{
$grades = [];
foreach ($books as $b) {
if ($b['grade'] !== '') {
$grades[(int) $b['grade']] = true;
}
}
$grades = array_keys($grades);
sort($grades);
return $grades;
}
/**
* Comparator for subjects: priority rank first, then Vietnamese-awarded
* alphabetical (normalized to strip diacritics). "Khác" / empty last.
*/
private function compareSubjects(string $a, string $b): int
{
$ra = $this->subjectRank($a);
$rb = $this->subjectRank($b);
if ($ra !== $rb) {
return $ra <=> $rb;
}
// equal rank → compare stripped-of-diacritics names, then raw names
$na = $this->normalizeSubject($a);
$nb = $this->normalizeSubject($b);
if ($na !== $nb) {
return strcmp($na, $nb);
}
return strcmp($a, $b);
}
/** Rank for a subject name: priority index, 500 for unknown, 999 for "Khác"/empty. */
private function subjectRank(string $name): int
{
$key = $this->normalizeSubject($name);
if ($key === '' || $key === 'khac') {
return 999;
}
if (isset($this->subjectRankCache[$key])) {
return $this->subjectRankCache[$key];
}
$rank = 500;
foreach (self::SUBJECT_PRIORITY as $subject => $r) {
if ($this->normalizeSubject($subject) === $key) {
$rank = $r;
break;
}
}
$this->subjectRankCache[$key] = $rank;
return $rank;
}
/** Lowercase + strip diacritics + collapse whitespace for matching. */
private function normalizeSubject(string $s): string
{
static $map = null;
if ($map === null) {
$map = [
'à'=>'a','á'=>'a','ạ'=>'a','ả'=>'a','ã'=>'a','â'=>'a','ầ'=>'a','ấ'=>'a','ậ'=>'a','ẩ'=>'a','ẫ'=>'a','ă'=>'a','ằ'=>'a','ắ'=>'a','ặ'=>'a','ẳ'=>'a','ẵ'=>'a',
'è'=>'e','é'=>'e','ẹ'=>'e','ẻ'=>'e','ẽ'=>'e','ê'=>'e','ề'=>'e','ế'=>'e','ệ'=>'e','ể'=>'e','ễ'=>'e',
'ì'=>'i','í'=>'i','ị'=>'i','ỉ'=>'i','ĩ'=>'i',
'ò'=>'o','ó'=>'o','ọ'=>'o','ỏ'=>'o','õ'=>'o','ô'=>'o','ồ'=>'o','ố'=>'o','ộ'=>'o','ổ'=>'o','ỗ'=>'o','ơ'=>'o','ờ'=>'o','ớ'=>'o','ợ'=>'o','ở'=>'o','ỡ'=>'o',
'ù'=>'u','ú'=>'u','ụ'=>'u','ủ'=>'u','ũ'=>'u','ư'=>'u','ừ'=>'u','ứ'=>'u','ự'=>'u','ử'=>'u','ữ'=>'u',
'ỳ'=>'y','ý'=>'y','ỵ'=>'y','ỷ'=>'y','ỹ'=>'y',
'đ'=>'d','ĩ'=>'i','ũ'=>'u',
];
}
$s = mb_strtolower($s);
$s = strtr($s, $map);
return (string) preg_replace('/\s+/', ' ', trim($s));
}
private function facet(array $books, string $key): array
{
$out = [];
foreach ($books as $b) {
$out[$b[$key]] = ($out[$b[$key]] ?? 0) + 1;
}
if ($key === 'subject') {
uksort($out, fn($a, $b2) => $this->compareSubjects($a, $b2));
} else {
uksort($out, fn($a, $b2) => strcoll($a, $b2));
}
return $out;
}
/**
* Book detail + PDF viewer.
*/
public function show(string $id)
{
$book = $this->api->book($id);
if (! $book) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$stats = Mongo::col('stats')->findOne(['_id' => 'book:' . $id]);
$downloads = $stats['downloads'] ?? 0;
return view('books/show', [
'book' => $book,
'downloads' => $downloads,
'countdown' => (int) env('download.countdown', 15),
]);
}
}