'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), ]); } }