59fab56359
Course metadata: a scan now gives every course a course_meta row from the downloader's sidecar JSON when the folder has one, else from catalog_courses matched by the folder name the downloader would create (folderKeyFor, a port of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export into seeds/ine_catalog.sql, an idempotent patch that creates its own table and upserts by id, so it can be applied to the live server before or after the code and re-run freely. Course pages show the blurb, difficulty, vendor and tags; search matches tags and blurbs; Settings reports how each course got its details. Against the live library 163 of 179 courses match. Course advisor, off by default: api/advisor.php answers "which course should I take to learn X / what comes before Y" with courses from the library, in order, plus vendor courses not yet downloaded. includes/ai_client.php is a small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by default, no composer); the prompt is a byte-stable catalogue of the library so the provider's prefix cache covers it. Every course id the model returns is validated server-side, a bad JSON reply gets one retry then a plain-text fallback, and a per-user daily cap with token counts lives in advisor_requests. With AI_ENABLED false the app makes no outbound requests and the box does not appear; users can also hide it per browser. Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
88 lines
3.6 KiB
PHP
88 lines
3.6 KiB
PHP
<?php
|
|
// Build an SQL seed of the vendor's course catalogue from its JSON export:
|
|
//
|
|
// php tools/build-catalog-seed.php ~/ine-dl/all_courses_with_access.json > seeds/ine_catalog.sql
|
|
//
|
|
// The output is self-contained and idempotent — it creates `catalog_courses`
|
|
// if needed and upserts every course — so it can be applied to any install,
|
|
// before or after a code deploy, as many times as you like:
|
|
//
|
|
// mysql coursehub < seeds/ine_catalog.sql && php scan.php
|
|
//
|
|
// The scan is what matches library folders to the seeded rows (course_meta).
|
|
// seeds/ is git-ignored: the file carries the vendor's course descriptions.
|
|
require_once __DIR__ . '/../includes/functions.php';
|
|
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
$src = $argv[1] ?? '';
|
|
if ($src === '' || !is_file($src)) {
|
|
fwrite(STDERR, "usage: php tools/build-catalog-seed.php <all_courses.json> > seeds/ine_catalog.sql\n");
|
|
exit(1);
|
|
}
|
|
|
|
ini_set('memory_limit', '512M');
|
|
$raw = file_get_contents($src);
|
|
$all = json_decode($raw, true);
|
|
if (!is_array($all)) {
|
|
fwrite(STDERR, "not a JSON array: $src\n");
|
|
exit(1);
|
|
}
|
|
// Some exports wrap the list.
|
|
if (isset($all['results']) && is_array($all['results'])) $all = $all['results'];
|
|
if (isset($all['courses']) && is_array($all['courses'])) $all = $all['courses'];
|
|
|
|
/** MySQL string literal (backslash escaping, the server's default mode). */
|
|
function sqlQuote(?string $v): string
|
|
{
|
|
if ($v === null) return 'NULL';
|
|
return "'" . str_replace(
|
|
["\\", "'", "\0", "\n", "\r", "\x1a"],
|
|
["\\\\", "\\'", "\\0", "\\n", "\\r", "\\Z"],
|
|
$v
|
|
) . "'";
|
|
}
|
|
|
|
$json = fn(array $a) => json_encode($a, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
$rows = [];
|
|
$skipped = 0;
|
|
foreach ($all as $entry) {
|
|
if (!is_array($entry)) { $skipped++; continue; }
|
|
$f = catalogFields($entry);
|
|
if ($f['id'] === null || $f['name'] === null || $f['slug'] === null) { $skipped++; continue; }
|
|
$rows[$f['id']] = [
|
|
sqlQuote($f['id']), sqlQuote($f['name']), sqlQuote($f['slug']),
|
|
sqlQuote(folderKeyFor($f['name'])), sqlQuote($f['vendor']), sqlQuote($f['difficulty']),
|
|
sqlQuote($json($f['tags'])), sqlQuote($f['short_description']), sqlQuote($f['description']),
|
|
sqlQuote($json($f['learning_paths'])), sqlQuote($json($f['categories'])),
|
|
$f['duration_seconds'] === null ? 'NULL' : (string)$f['duration_seconds'],
|
|
sqlQuote($f['url']),
|
|
];
|
|
}
|
|
ksort($rows); // stable output: the same export always produces the same file
|
|
|
|
$cols = 'id, name, slug, folder_key, vendor, difficulty, tags, short_description, description,'
|
|
. ' learning_paths, categories, duration_seconds, url';
|
|
$upd = implode(', ', array_map(
|
|
fn($c) => "$c = VALUES($c)",
|
|
['name', 'slug', 'folder_key', 'vendor', 'difficulty', 'tags', 'short_description',
|
|
'description', 'learning_paths', 'categories', 'duration_seconds', 'url']
|
|
));
|
|
|
|
echo "-- CourseHub catalogue seed — generated by tools/build-catalog-seed.php\n";
|
|
echo '-- source: ' . basename($src) . ' (modified ' . gmdate('Y-m-d H:i:s', filemtime($src) ?: 0) . " UTC)\n";
|
|
echo '-- courses: ' . count($rows) . ($skipped ? " ($skipped entries skipped)" : '') . "\n";
|
|
echo "-- Safe to re-run: creates the table if needed and upserts by id.\n";
|
|
echo "SET NAMES utf8mb4;\n";
|
|
echo catalogCoursesDdl() . ";\n";
|
|
foreach (array_chunk(array_values($rows), 50) as $chunk) {
|
|
echo "INSERT INTO catalog_courses ($cols) VALUES\n";
|
|
echo implode(",\n", array_map(fn($r) => '(' . implode(', ', $r) . ')', $chunk));
|
|
echo "\nON DUPLICATE KEY UPDATE $upd;\n";
|
|
}
|
|
fwrite(STDERR, 'wrote ' . count($rows) . " courses\n");
|