[--password | --plain]
* or: npx tsx scripts/generate-language.ts --all [--password | --plain]
@@ -11,6 +14,7 @@ import { join } from "path";
import type { SqlJsStatic } from "sql.js";
import type { OracleData } from "../data/oracle-types";
import type { LanguageDefinition, CanonicalTable } from "../languages/types";
+import { normalizePgValue } from "../src/database/pgNormalize";
// ── CLI argument parsing ───────────────────────────────────────
const args = process.argv.slice(2);
@@ -88,12 +92,11 @@ function getPersonAddress(lang: LanguageDefinition, personIndex: number): string
return `${lang.streets[person.streetIndex]} ${person.streetSuffix}`;
}
-// ── SQL generation ─────────────────────────────────────────────
+// ── SQL generation (SQLite) ─────────────────────────────────────
function generateCreateTableSQL(lang: LanguageDefinition): string {
const t = lang.tables;
const c = lang.columns;
- // We need to reproduce the exact same schema structure with translated names
const statements: string[] = [];
// Person
@@ -122,6 +125,41 @@ function generateCreateTableSQL(lang: LanguageDefinition): string {
return statements.join("\n");
}
+// ── SQL generation (PostgreSQL) ─────────────────────────────────
+function generateCreateTablePgSQL(lang: LanguageDefinition): string {
+ const t = lang.tables;
+ const c = lang.columns;
+
+ // No double-quoting: PG lowercases unquoted identifiers, which matches how
+ // oracle queries reference tables/columns (also unquoted).
+ const statements: string[] = [];
+
+ // Person
+ statements.push(`CREATE TABLE ${t.Person} (${c.Person.id} TEXT NOT NULL, ${c.Person.name} TEXT NOT NULL, ${c.Person.address} TEXT NOT NULL, ${c.Person.postalCode} TEXT NOT NULL, ${c.Person.city} TEXT NOT NULL, ${c.Person.phone} TEXT NOT NULL, PRIMARY KEY (${c.Person.id}));`);
+
+ // Room
+ statements.push(`CREATE TABLE ${t.Room} (${c.Room.id} INTEGER NOT NULL, ${c.Room.name} TEXT NOT NULL, ${c.Room.capacity} INTEGER NOT NULL, PRIMARY KEY (${c.Room.id}));`);
+ statements.push(`CREATE UNIQUE INDEX rum_namn ON ${t.Room} (${c.Room.name});`);
+
+ // Student — hasDisability uses BOOLEAN
+ statements.push(`CREATE TABLE ${t.Student} (${c.Student.id} TEXT NOT NULL, ${c.Student.hasDisability} BOOLEAN NOT NULL, CONSTRAINT STUDENT_PK PRIMARY KEY (${c.Student.id}), CONSTRAINT Student_Person_FK FOREIGN KEY (${c.Student.id}) REFERENCES ${t.Person} (${c.Person.id}) ON DELETE RESTRICT ON UPDATE CASCADE);`);
+
+ // Teacher
+ statements.push(`CREATE TABLE ${t.Teacher} (${c.Teacher.id} TEXT NOT NULL, ${c.Teacher.officeRoom} INTEGER NOT NULL, CONSTRAINT teacher_pk PRIMARY KEY (${c.Teacher.id}), CONSTRAINT fk_teacher_person FOREIGN KEY (${c.Teacher.id}) REFERENCES ${t.Person} (${c.Person.id}) ON DELETE RESTRICT ON UPDATE CASCADE);`);
+
+ // Course
+ statements.push(`CREATE TABLE ${t.Course} (${c.Course.code} TEXT NOT NULL, ${c.Course.name} TEXT NOT NULL, ${c.Course.duration} INTEGER NOT NULL, ${c.Course.price} INTEGER NOT NULL, ${c.Course.description} TEXT NOT NULL, PRIMARY KEY (${c.Course.code}));`);
+ statements.push(`CREATE UNIQUE INDEX kurs_ben ON ${t.Course} (${c.Course.name});`);
+
+ // CourseInstance — startDate uses DATE
+ statements.push(`CREATE TABLE ${t.CourseInstance} (${c.CourseInstance.course} TEXT NOT NULL, ${c.CourseInstance.startDate} DATE NOT NULL, ${c.CourseInstance.teacher} TEXT NOT NULL, ${c.CourseInstance.room} INTEGER NOT NULL, CONSTRAINT courseinstance_pk PRIMARY KEY (${c.CourseInstance.course}, ${c.CourseInstance.startDate}), CONSTRAINT fk_courseinstance_course FOREIGN KEY (${c.CourseInstance.course}) REFERENCES ${t.Course} (${c.Course.code}) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT fk_courseinstance_teacher FOREIGN KEY (${c.CourseInstance.teacher}) REFERENCES ${t.Teacher} (${c.Teacher.id}) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT fk_courseinstance_room FOREIGN KEY (${c.CourseInstance.room}) REFERENCES ${t.Room} (${c.Room.id}) ON DELETE RESTRICT ON UPDATE CASCADE);`);
+
+ // Participation — startDate uses DATE
+ statements.push(`CREATE TABLE ${t.Participation} (${c.Participation.student} TEXT NOT NULL, ${c.Participation.course} TEXT NOT NULL, ${c.Participation.startDate} DATE NOT NULL, CONSTRAINT participation_pk PRIMARY KEY (${c.Participation.course}, ${c.Participation.startDate}, ${c.Participation.student}), CONSTRAINT fk_participation_courseinstance FOREIGN KEY (${c.Participation.course}, ${c.Participation.startDate}) REFERENCES ${t.CourseInstance} (${c.CourseInstance.course}, ${c.CourseInstance.startDate}) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT fk_participation_student FOREIGN KEY (${c.Participation.student}) REFERENCES ${t.Student} (${c.Student.id}) ON DELETE RESTRICT ON UPDATE CASCADE);`);
+
+ return statements.join("\n");
+}
+
function generateInsertSQL(lang: LanguageDefinition): string {
const t = lang.tables;
const esc = (s: string) => s.replace(/'/g, "''");
@@ -181,6 +219,65 @@ function generateInsertSQL(lang: LanguageDefinition): string {
return statements.join("\n");
}
+function generateInsertPgSQL(lang: LanguageDefinition): string {
+ const t = lang.tables;
+ const esc = (s: string) => s.replace(/'/g, "''");
+ const statements: string[] = [];
+
+ // Persons
+ for (let i = 0; i < oracle.persons.length; i++) {
+ const p = oracle.persons[i];
+ const name = getPersonName(lang, i);
+ const address = getPersonAddress(lang, i);
+ statements.push(`INSERT INTO ${t.Person} VALUES ('${esc(lang.personIds[i])}', '${esc(name)}', '${esc(address)}', '${esc(lang.postalCodes[i])}', '${esc(lang.cities[p.cityIndex])}', '${esc(lang.phones[i])}');`);
+ }
+
+ // Rooms
+ for (let i = 0; i < oracle.rooms.length; i++) {
+ const r = oracle.rooms[i];
+ statements.push(`INSERT INTO ${t.Room} VALUES (${r.id}, '${esc(lang.roomNames[i])}', ${r.capacity});`);
+ }
+
+ // Students — BOOLEAN values
+ for (let i = 0; i < oracle.persons.length; i++) {
+ const p = oracle.persons[i];
+ if (p.isStudent) {
+ statements.push(`INSERT INTO ${t.Student} VALUES ('${esc(lang.personIds[i])}', ${p.disability ? "TRUE" : "FALSE"});`);
+ }
+ }
+
+ // Teachers
+ for (let i = 0; i < oracle.persons.length; i++) {
+ const p = oracle.persons[i];
+ if (p.isTeacher) {
+ statements.push(`INSERT INTO ${t.Teacher} VALUES ('${esc(lang.personIds[i])}', ${p.officeRoom});`);
+ }
+ }
+
+ // Courses
+ for (let i = 0; i < oracle.courses.length; i++) {
+ const cr = oracle.courses[i];
+ const key = cr.key as keyof typeof lang.courseCodes;
+ statements.push(`INSERT INTO ${t.Course} VALUES ('${esc(lang.courseCodes[key])}', '${esc(lang.courseNames[key])}', ${cr.duration}, ${cr.price}, '${esc(lang.courseDescriptions[key])}');`);
+ }
+
+ // Course Instances — DATE values
+ for (const ci of oracle.courseInstances) {
+ const courseKey = oracle.courses[ci.courseIndex].key as keyof typeof lang.courseCodes;
+ const teacherId = lang.personIds[ci.teacherPersonIndex];
+ statements.push(`INSERT INTO ${t.CourseInstance} VALUES ('${esc(lang.courseCodes[courseKey])}', '${esc(ci.startDate)}', '${esc(teacherId)}', ${oracle.rooms[ci.roomIndex].id});`);
+ }
+
+ // Participations — DATE values
+ for (const p of oracle.participations) {
+ const courseKey = oracle.courses[p.courseIndex].key as keyof typeof lang.courseCodes;
+ const studentId = lang.personIds[p.studentPersonIndex];
+ statements.push(`INSERT INTO ${t.Participation} VALUES ('${esc(studentId)}', '${esc(lang.courseCodes[courseKey])}', '${esc(p.startDate)}');`);
+ }
+
+ return statements.join("\n");
+}
+
// ── Query template resolution ──────────────────────────────────
function resolveQuery(template: string, lang: LanguageDefinition): string {
return template.replace(/\{\{(\w+):([^}]+)\}\}/g, (_, type: string, ref: string) => {
@@ -219,20 +316,18 @@ function resolveQuery(template: string, lang: LanguageDefinition): string {
});
}
-// ── Generate a single language ────────────────────────────────
+// ── Generate: SQLite ────────────────────────────────────────────
type ResultData = { columns: string[]; values: (string | number | null)[][] };
-async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise {
- const lang = await loadLanguage(code);
- console.log(`Generating language data for: ${lang.displayName} (${lang.code})`);
+async function generateSqliteLanguage(lang: LanguageDefinition, SQL: SqlJsStatic): Promise {
+ console.log(`Generating SQLite data for: ${lang.displayName} (${lang.code})`);
const db = new SQL.Database();
- // Create schema and insert data
const createSQL = generateCreateTableSQL(lang);
const insertSQL = generateInsertSQL(lang);
- db.run("PRAGMA foreign_keys = OFF;"); // OFF during bulk insert for performance
+ db.run("PRAGMA foreign_keys = OFF;");
for (const stmt of createSQL.split(";\n").filter(s => s.trim())) {
db.run(stmt + ";");
}
@@ -241,31 +336,14 @@ async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise new Date(date).getFullYear());
db.create_function("MONTH", (date: string) => new Date(date).getMonth() + 1);
db.create_function("DAY", (date: string) => new Date(date).getDate());
- // Build question pool by running reference queries
- const categoryMap = new Map;
- }>();
-
- let errorCount = 0;
-
function runQuery(queryTemplate: string): ResultData {
const resolvedQuery = resolveQuery(queryTemplate, lang);
const res = db.exec(resolvedQuery);
if (res.length > 0) {
- // Replace expression column names (e.g. "COUNT(student)") with the language's aggregate label
const columns = res[0].columns.map(col =>
/[()]/.test(col) ? lang.aggregateLabel : col
);
@@ -274,9 +352,90 @@ async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise {
+ console.log(`Generating PostgreSQL data for: ${lang.displayName} (${outputCode})`);
+
+ const { PGlite } = await import("@electric-sql/pglite");
+ const db = new PGlite();
+
+ const createSQL = generateCreateTablePgSQL(lang);
+ const insertSQL = generateInsertPgSQL(lang);
+
+ const fullSQL = createSQL + "\n" + insertSQL;
+ await db.exec(fullSQL);
+
+ async function runQueryAsync(queryTemplate: string): Promise {
+ const resolvedQuery = resolveQuery(queryTemplate, lang);
+ // rowMode "array" preserves duplicate column names; normalization is shared with
+ // the live engine (src/database/pgNormalize) so the oracle and student results
+ // are produced identically.
+ const res = await db.query(resolvedQuery, [], { rowMode: "array" });
+ if (res.rows.length > 0 || res.fields.length > 0) {
+ const columns = res.fields.map(f =>
+ /[()]/.test(f.name) ? lang.aggregateLabel : f.name
+ );
+ const values = (res.rows as unknown[][]).map(row =>
+ row.map((val, i) => normalizePgValue(val, res.fields[i].dataTypeID) as string | number | null)
+ );
+ return { columns, values };
+ }
+ return { columns: [], values: [] };
+ }
+
+ const { categories, errorCount } = await buildQuestionPoolAsync(lang, runQueryAsync);
+
+ const outputDir = join(__dirname, "..", "public", "languages", outputCode);
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
+
+ writeQuestionPool(outputDir, lang, categories, errorCount, "postgresql");
+
+ // SQL dump file
+ writeFileSync(join(outputDir, "data.sql"), fullSQL, "utf-8");
+ console.log(`Wrote data.sql (${fullSQL.length} bytes)`);
+
+ await db.close();
+ return errorCount === 0;
+}
+
+// ── Shared question pool building ──────────────────────────────
+interface CategoryEntry {
+ category_id: number;
+ display_number: number;
+ questions: Array<{
+ id: number;
+ description: string;
+ display_sequence: string;
+ result: ResultData;
+ alternative_results?: ResultData[];
+ }>;
+}
+
+function buildQuestionPool(
+ lang: LanguageDefinition,
+ runQuery: (template: string) => ResultData,
+): { categories: CategoryEntry[]; errorCount: number } {
+ const categoryMap = new Map();
+ let errorCount = 0;
+
for (const q of oracle.questions) {
const description = lang.questionDescriptions[q.id];
-
if (!description) {
console.error(`Missing question description for Q${q.id} in language ${lang.code}`);
errorCount++;
@@ -293,7 +452,6 @@ async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise 0) {
alternative_results = [];
@@ -302,13 +460,10 @@ async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise 0) {
- console.error(`\n${errorCount} error(s) occurred. Generated data may be incomplete.`);
+ const categories = Array.from(categoryMap.values()).sort((a, b) => a.category_id - b.category_id);
+ for (const cat of categories) {
+ cat.questions.sort((a, b) => a.display_sequence.localeCompare(b.display_sequence));
+ }
+
+ return { categories, errorCount };
+}
+
+async function buildQuestionPoolAsync(
+ lang: LanguageDefinition,
+ runQuery: (template: string) => Promise,
+ isPg = true,
+): Promise<{ categories: CategoryEntry[]; errorCount: number }> {
+ const categoryMap = new Map();
+ let errorCount = 0;
+
+ for (const q of oracle.questions) {
+ const description = lang.questionDescriptions[q.id];
+ if (!description) {
+ console.error(`Missing question description for Q${q.id} in language ${lang.code}`);
+ errorCount++;
+ continue;
+ }
+
+ const queryTemplate = (isPg && q.pgQuery) ? q.pgQuery : q.query;
+ let result: ResultData;
+ try {
+ result = await runQuery(queryTemplate);
+ } catch (e) {
+ console.error(`Error running query for Q${q.id}: ${(e as Error).message}`);
+ console.error(` Resolved query: ${resolveQuery(queryTemplate, lang)}`);
+ errorCount++;
+ continue;
+ }
+
+ const altQueries = (isPg && q.pgAlternativeQueries) ? q.pgAlternativeQueries : q.alternativeQueries;
+ let alternative_results: ResultData[] | undefined;
+ if (altQueries && altQueries.length > 0) {
+ alternative_results = [];
+ for (let i = 0; i < altQueries.length; i++) {
+ try {
+ alternative_results.push(await runQuery(altQueries[i]));
+ } catch (e) {
+ console.error(`Error running alternative query ${i + 1} for Q${q.id}: ${(e as Error).message}`);
+ errorCount++;
+ }
+ }
+ if (alternative_results.length === 0) alternative_results = undefined;
+ }
+
+ if (!categoryMap.has(q.categoryId)) {
+ categoryMap.set(q.categoryId, {
+ category_id: q.categoryId,
+ display_number: q.displayNumber,
+ questions: [],
+ });
+ }
+
+ categoryMap.get(q.categoryId)!.questions.push({
+ id: q.id,
+ description,
+ display_sequence: q.displaySequence,
+ result,
+ ...(alternative_results ? { alternative_results } : {}),
+ });
}
- // Sort categories and questions
- const categories = Array.from(categoryMap.values())
- .sort((a, b) => a.category_id - b.category_id);
+ const categories = Array.from(categoryMap.values()).sort((a, b) => a.category_id - b.category_id);
for (const cat of categories) {
cat.questions.sort((a, b) => a.display_sequence.localeCompare(b.display_sequence));
}
- // Write output
- const outputDir = join(__dirname, "..", "public", "languages", code);
- if (!existsSync(outputDir)) {
- mkdirSync(outputDir, { recursive: true });
- }
+ return { categories, errorCount };
+}
- // Question pool JSON
+function writeQuestionPool(
+ outputDir: string,
+ lang: LanguageDefinition,
+ categories: CategoryEntry[],
+ errorCount: number,
+ engine: "sqlite" | "postgresql" = "sqlite",
+): void {
+ const totalQuestions = categories.reduce((sum, c) => sum + c.questions.length, 0);
const questionPool = {
language: lang.code,
+ engine,
defaultQuery: `SELECT * FROM ${lang.tables.Student};`,
questions: categories,
};
@@ -356,16 +577,7 @@ async function generateForLanguage(code: string, SQL: SqlJsStatic): Promise 0 ? `, ${errorCount} errors` : ""})`);
}
// ── Main ───────────────────────────────────────────────────────
@@ -377,7 +589,16 @@ async function main() {
let allOk = true;
for (const code of codes) {
- const ok = await generateForLanguage(code, SQL);
+ const lang = await loadLanguage(code);
+ // Skip engine-variant packs (e.g. sv-pg) — we generate those automatically
+ if (lang.engine === "postgresql") continue;
+
+ // Generate SQLite data
+ let ok = await generateSqliteLanguage(lang, SQL);
+ if (!ok) allOk = false;
+
+ // Generate PostgreSQL data (into {code}-pg/ directory)
+ ok = await generatePgLanguage(lang, `${code}-pg`);
if (!ok) allOk = false;
}
diff --git a/src/App.tsx b/src/App.tsx
index 0f38f39..6b59cc0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,24 +16,19 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import Editor from "react-simple-code-editor";
import "./App.css";
import ResultTable from "./ResultTable";
import ExportRenderer from "./ExportRenderer";
import QuestionSelector, { Question } from "./QuestionSelector";
-// @ts-expect-error - No types available
-import { highlight, languages } from "prismjs/components/prism-core";
-import "prismjs/components/prism-sql";
-import "prismjs/themes/prism.css";
import { format } from "sql-formatter";
import initSqlJs from "sql.js";
import ViewsTable, { View } from "./ViewsTable";
import { raToSQL, RAError } from "./ra-engine/relationalAlgebra";
import RAReference from "./ra-engine/RAReference";
-import { highlightRA } from "./ra-engine/raHighlight";
import RAPreview from "./ra-engine/RAPreview";
+import SqlEditor, { type SqlEditorHandle } from "./SqlEditor";
import { Info, Settings, XCircle, CheckCircle2, Eye, EyeOff } from "lucide-react";
import ChangelogDialog from "./ChangelogDialog";
@@ -58,28 +53,42 @@ import ImportDialog, { ImportDialogHandle } from "./ImportDialog";
import { ParsedSaveData, parseImportFile, getLocalData, detectConflicts } from "./mergeUtils";
import { useLanguage, langKey, getUrlParam, setUrlParam } from "./i18n/context";
import LanguageSelector from "./LanguageSelector";
+import EngineSelector from "./EngineSelector";
import { getQuestion } from "./QuestionSelector";
+import type { DatabaseEngine } from "./database/types";
+import { SqliteEngine } from "./database/sqliteEngine";
+import { PgliteEngine } from "./database/pgliteEngine";
+import { useEditorSettings } from "./useEditorSettings";
+import EditorSettingsDialog from "./EditorSettingsDialog";
+
/** Storage key namespaced by editor mode */
function modeKey(base: string, mode: "sql" | "ra"): string {
return mode === "ra" ? `ra-${base}` : base;
}
-/** Get a JSON-parsed list from localStorage, with language and mode namespacing */
-function getStoredList(lang: string, key: string): number[] {
- const raw = localStorage.getItem(langKey(lang, key));
+/** Build a localStorage key namespaced by language and engine */
+function engineKey(lang: string, engine: string, key: string): string {
+ return langKey(lang, `${engine}:${key}`);
+}
+
+/** Get a JSON-parsed list from localStorage, with language+engine+mode namespacing */
+function getStoredList(lang: string, engine: string, key: string): number[] {
+ const raw = localStorage.getItem(engineKey(lang, engine, key));
return raw ? JSON.parse(raw) : [];
}
-/** Set a JSON list in localStorage, with language namespacing */
-function setStoredList(lang: string, key: string, value: number[]): void {
- localStorage.setItem(langKey(lang, key), JSON.stringify(value));
+/** Set a JSON list in localStorage, with language+engine namespacing */
+function setStoredList(lang: string, engine: string, key: string, value: number[]): void {
+ localStorage.setItem(engineKey(lang, engine, key), JSON.stringify(value));
}
function App() {
- const { lang, t, questions, dbArrayBuffer, defaultQuery } = useLanguage();
+ const { lang, t, questions, dbData, defaultQuery, engine, setEngine } = useLanguage();
+ const { settings: editorSettings, setSettings: setEditorSettings } = useEditorSettings();
const [question, setQuestion] = useState();
- const [database, setDatabase] = useState();
+ const [database, setDatabase] = useState();
+ const [dbSchema, setDbSchema] = useState>({});
const [error, setError] = useState(null);
// flag for correct result query being present but current (which might not be evaluated) is not the same
const [correctQueryMismatch, setCorrectQueryMismatch] = useState(false);
@@ -106,19 +115,22 @@ function App() {
const exportRendererRef = useRef(null);
- const editorRef = useRef(null);
+ const sqlEditorRef = useRef(null);
const exportModalRef = useRef(null);
const importDialogRef = useRef(null);
const [pendingImportData, setPendingImportData] = useState(null);
// QuestionSelector needs writtenQuestions and correctQuestions to be able to display the correct state
const [writtenQuestions, setWrittenQuestions] = useState(() =>
- getStoredList(lang, modeKey("writtenQuestions", editorMode))
+ getStoredList(lang, engine, modeKey("writtenQuestions", editorMode))
);
const [correctQuestions, setCorrectQuestions] = useState(() =>
- getStoredList(lang, modeKey("correctQuestions", editorMode))
+ getStoredList(lang, engine, modeKey("correctQuestions", editorMode))
);
+ /** sql-formatter language based on engine */
+ const formatterLang = engine === "postgresql" ? "postgresql" : "sqlite";
+
// One-time migration: copy old unnamespaced keys to sv: prefix
useEffect(() => {
if (localStorage.getItem("i18n-migrated")) return;
@@ -149,8 +161,8 @@ function App() {
// Reload written/correct questions when language or mode changes
useEffect(() => {
- setWrittenQuestions(getStoredList(lang, modeKey("writtenQuestions", editorMode)));
- setCorrectQuestions(getStoredList(lang, modeKey("correctQuestions", editorMode)));
+ setWrittenQuestions(getStoredList(lang, engine, modeKey("writtenQuestions", editorMode)));
+ setCorrectQuestions(getStoredList(lang, engine, modeKey("correctQuestions", editorMode)));
}, [lang, editorMode]);
const resetResult = useCallback(() => {
@@ -167,9 +179,9 @@ function App() {
if (question && query !== undefined) {
const currentKey = modeKey(`questionId-${question.id}`, editorMode);
if (query === defaultQuery || query === "") {
- localStorage.removeItem(langKey(lang, currentKey));
+ localStorage.removeItem(engineKey(lang, engine, currentKey));
} else {
- localStorage.setItem(langKey(lang, currentKey), query);
+ localStorage.setItem(engineKey(lang, engine, currentKey), query);
}
}
setEditorMode(mode);
@@ -179,26 +191,50 @@ function App() {
// Load query for the new mode
if (question) {
const newKey = modeKey(`questionId-${question.id}`, mode);
- setQuery(localStorage.getItem(langKey(lang, newKey)) || (mode === "ra" ? "" : defaultQuery));
+ setQuery(localStorage.getItem(engineKey(lang, engine, newKey)) || (mode === "ra" ? "" : defaultQuery));
}
// Reload progress for the new mode
- setWrittenQuestions(getStoredList(lang, modeKey("writtenQuestions", mode)));
- setCorrectQuestions(getStoredList(lang, modeKey("correctQuestions", mode)));
+ setWrittenQuestions(getStoredList(lang, engine, modeKey("writtenQuestions", mode)));
+ setCorrectQuestions(getStoredList(lang, engine, modeKey("correctQuestions", mode)));
}, [resetResult, question, query, editorMode, lang, defaultQuery]);
const initDb = useCallback(async () => {
- if (!dbArrayBuffer) return;
+ if (!dbData) return;
resetResult();
- const SQL = await initSqlJs({
- locateFile: (file) => `/dist/sql.js/${file}`,
- });
- const db = new SQL.Database(new Uint8Array(dbArrayBuffer));
- db.create_function("YEAR", (date: string) => new Date(date).getFullYear());
- db.create_function("MONTH", (date: string) => new Date(date).getMonth() + 1);
- db.create_function("DAY", (date: string) => new Date(date).getDate());
- db.exec("PRAGMA foreign_keys = ON;");
+
+ let db: DatabaseEngine;
+ if (engine === "postgresql") {
+ try {
+ const { PGlite } = await import("@electric-sql/pglite");
+ const pg = new PGlite();
+ await pg.exec(dbData as string);
+ db = new PgliteEngine(pg);
+ } catch (e) {
+ // PGlite is a multi-MB WASM bundle loaded on demand; a failed fetch/init must
+ // not leave the app stuck with no usable database. Surface it and fall back to
+ // the always-available SQLite engine.
+ console.error("Failed to initialize the PostgreSQL engine:", e);
+ setError(t("engineLoadFailed"));
+ setEngine("sqlite");
+ return;
+ }
+ } else {
+ const SQL = await initSqlJs({
+ locateFile: (file) => `/dist/sql.js/${file}`,
+ });
+ const sqliteDb = new SQL.Database(new Uint8Array(dbData as ArrayBuffer));
+ sqliteDb.create_function("YEAR", (date: string) => new Date(date).getFullYear());
+ sqliteDb.create_function("MONTH", (date: string) => new Date(date).getMonth() + 1);
+ sqliteDb.create_function("DAY", (date: string) => new Date(date).getDate());
+ sqliteDb.exec("PRAGMA foreign_keys = ON;");
+ db = new SqliteEngine(sqliteDb);
+ }
+
+ // Extract schema for autocomplete
+ const schema = await db.getSchema();
+ setDbSchema(schema);
setDatabase(db);
- }, [resetResult, dbArrayBuffer]);
+ }, [resetResult, dbData, engine, t, setEngine]);
useEffect(() => {
initDb();
@@ -217,6 +253,21 @@ function App() {
setUrlParam("q", null);
}, [lang, resetResult]);
+ // On engine change, keep the question but reload query and reset results
+ const prevEngineRef = useRef(engine);
+ useEffect(() => {
+ if (prevEngineRef.current === engine) return;
+ prevEngineRef.current = engine;
+ // Reload the query for this question under the new engine context
+ if (question) {
+ const key = modeKey(`questionId-${question.id}`, editorMode);
+ setQuery(localStorage.getItem(engineKey(lang, engine, key)) || (editorMode === "ra" ? "" : defaultQuery));
+ }
+ setViews([]);
+ setDisplayViewsTable(false);
+ resetResult();
+ }, [engine, question, editorMode, lang, defaultQuery, resetResult]);
+
// Restore question from URL param when questions become available
const urlRestoredRef = useRef(false);
useEffect(() => {
@@ -232,7 +283,7 @@ function App() {
const resolved = getQuestion(q.id, questions);
if (resolved) {
setQuestion(resolved);
- setQuery(localStorage.getItem(langKey(lang, modeKey(`questionId-${resolved.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery));
+ setQuery(localStorage.getItem(engineKey(lang, engine, modeKey(`questionId-${resolved.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery));
}
}
}
@@ -243,7 +294,6 @@ function App() {
if (question) {
setUrlParam("q", `${question.category.display_number}${question.display_sequence}`);
} else if (urlRestoredRef.current) {
- // Only clear ?q= after initial restore, not during it
setUrlParam("q", null);
}
}, [question]);
@@ -259,90 +309,85 @@ function App() {
if (question) questionLangRef.current = lang;
}, [question, lang]);
+ // Validate query on change
useEffect(() => {
if (!database || !question || query === undefined) {
return;
}
- // Don't write to localStorage if lang changed but question hasn't been cleared yet
if (questionLangRef.current !== lang) {
return;
}
const wqStorageKey = modeKey("writtenQuestions", editorMode);
- let wq = getStoredList(lang, wqStorageKey);
+ let wq = getStoredList(lang, engine, wqStorageKey);
const initialLength = wq.length;
const storageKey = modeKey(`questionId-${question.id}`, editorMode);
if (query === defaultQuery || query === "") {
- localStorage.removeItem(langKey(lang, storageKey));
+ localStorage.removeItem(engineKey(lang, engine, storageKey));
wq = wq.filter((id: number) => id !== question.id);
} else {
- localStorage.setItem(langKey(lang, storageKey), query);
+ localStorage.setItem(engineKey(lang, engine, storageKey), query);
if (!wq.includes(question.id)) {
wq.push(question.id);
}
}
if (wq.length !== initialLength) {
- setStoredList(lang, wqStorageKey, wq);
+ setStoredList(lang, engine, wqStorageKey, wq);
setWrittenQuestions(wq);
}
- if (editorMode === "sql") {
- try {
- // Check for multiple statements
- let stmtCount = 0;
- for (const stmt of database.iterateStatements(query)) {
- stmtCount++;
- stmt.free();
- if (stmtCount > 1) {
- setError(t("multipleStatements"));
- return;
- }
- }
- setError(null);
- } catch (e) {
- // @ts-expect-error - Error.message is a string
- setError(e.message);
- }
- } else {
- // In RA mode, try to parse and validate against database
- try {
- raToSQL(query, database);
- setError(null);
- } catch (e) {
- if (e instanceof RAError) {
- setError(t("raParseError", { message: e.message }));
+ // Stale check for async validation
+ let stale = false;
+ const validate = async () => {
+ if (editorMode === "sql") {
+ const errorMsg = await database.validateStatements(query);
+ if (stale) return;
+ if (errorMsg === "multiple_statements") {
+ setError(t("multipleStatements"));
+ } else if (errorMsg) {
+ setError(errorMsg);
} else {
- // @ts-expect-error - Error.message is a string
- setError(e.message);
+ setError(null);
+ }
+ } else {
+ // In RA mode, try to parse and validate against database
+ try {
+ await raToSQL(query, database);
+ if (stale) return;
+ setError(null);
+ } catch (e) {
+ if (stale) return;
+ if (e instanceof RAError) {
+ setError(t("raParseError", { message: e.message }));
+ } else {
+ setError((e as Error).message);
+ }
}
}
- }
+ };
+ validate();
+ return () => { stale = true; };
}, [database, query, question, lang, defaultQuery, t, editorMode]);
- const refreshViews = useCallback((upsert: boolean) => {
+ const refreshViews = useCallback(async (upsert: boolean) => {
if (!database) {
return;
}
- const res = database.exec('SELECT name, sql FROM sqlite_master WHERE type="view"');
- let fetchedViews: View[] = [];
- if (res.length !== 0) {
- const fetched = res[0].values as string[][];
- fetchedViews = fetched.map(([name, query]) => ({ name, query }));
- }
+ const fetchedViews = await database.getViews();
if (fetchedViews.length - views.length !== 0) {
setDisplayViewsTable(true);
}
if (upsert) {
- localStorage.setItem(langKey(lang, "views"), JSON.stringify(fetchedViews));
+ localStorage.setItem(engineKey(lang, engine, "views"), JSON.stringify(fetchedViews));
}
setViews(fetchedViews);
// Recreate missing views from localStorage
- const storedViews = localStorage.getItem(langKey(lang, "views"));
+ const storedViews = localStorage.getItem(engineKey(lang, engine, "views"));
if (storedViews) {
const savedViews: View[] = JSON.parse(storedViews);
const missingViews = savedViews.filter(
@@ -351,7 +396,7 @@ function App() {
let recreated = 0;
for (const view of missingViews) {
try {
- database.exec(view.query);
+ await database.exec(view.query);
recreated++;
} catch (e) {
console.warn(`Failed to recreate view "${view.name}":`, (e as Error).message);
@@ -363,7 +408,7 @@ function App() {
}
}, [database, views.length, lang]);
- const runQuery = useCallback(() => {
+ const runQuery = useCallback(async () => {
if (!database || query === undefined) {
return;
}
@@ -371,78 +416,71 @@ function App() {
let sqlToRun = query;
if (editorMode === "ra") {
try {
- sqlToRun = raToSQL(query, database);
+ sqlToRun = await raToSQL(query, database);
} catch (e) {
if (e instanceof RAError) {
setError(t("raParseError", { message: e.message }));
} else {
- // @ts-expect-error - Error.message is a string
- setError(e.message);
+ setError((e as Error).message);
}
return;
}
}
- const res = database.exec(sqlToRun);
+ const res = await database.exec(sqlToRun);
setIsViewResult(false);
setQueryedView(null);
setEvaluatedQuery(query);
if (res.length !== 0) {
const { columns, values } = res[0];
- setResult({ columns, data: values });
+ setResult({ columns, data: values as (string | number | Uint8Array | null)[][] });
} else {
setResult({columns: [], data: []});
}
- refreshViews(true);
+ await refreshViews(true);
} catch (e) {
- // @ts-expect-error - Error.message is a string
- setError(e.message);
+ setError((e as Error).message);
}
}, [database, query, refreshViews, editorMode, t]);
- const evalSql = useCallback((sql: string): Result => {
+ const evalSql = useCallback(async (sql: string): Promise => {
if (!database) {
return { columns: [], data: [] };
}
try {
- const res = database.exec(sql);
- let result: Result;
+ const res = await database.exec(sql);
if (res.length !== 0) {
const { columns, values } = res[0];
- result = { columns, data: values };
- } else {
- result = {columns: [], data: []};
+ return { columns, data: values as (string | number | Uint8Array | null)[][] };
}
- return result;
+ return { columns: [], data: [] };
} catch (e) {
- // @ts-expect-error - Error.message is a string
- alert("Error occurred while evaluating SQL Query internally: " + e.message);
+ alert("Error occurred while evaluating SQL Query internally: " + (e as Error).message);
return { columns: [], data: [] };
}
}, [database]);
- const getViewResult = useCallback((name: string) => {
+ const getViewResult = useCallback(async (name: string) => {
if (!database) {
return;
}
try {
const viewQuery = `SELECT * FROM ${name}`;
- const res = database.exec(viewQuery);
+ const res = await database.exec(viewQuery);
setIsViewResult(true);
setQueryedView(name);
setEvaluatedQuery(viewQuery);
if (res.length !== 0) {
const { columns, values } = res[0];
- setResult({ columns, data: values });
+ setResult({ columns, data: values as (string | number | Uint8Array | null)[][] });
} else {
setResult({columns: [], data: []});
}
} catch (e) {
- // @ts-expect-error - Error.message is a string
- setError(e.message);
+ setError((e as Error).message);
}
}, [database]);
- const deleteView = useCallback((name: string) => {
+ const deleteView = useCallback(async (name: string) => {
if (!database) {
return;
}
@@ -451,8 +489,8 @@ function App() {
return;
}
- database.exec(`DROP VIEW ${name}`);
- refreshViews(true);
+ await database.exec(`DROP VIEW ${name}`);
+ await refreshViews(true);
if (isViewResult && queryedView === name) {
resetResult();
@@ -482,15 +520,15 @@ function App() {
setMatchedResult(matchedAlt ?? question.evaluable_result);
const cKey = modeKey(`correctQuestionId-${question.id}`, editorMode);
- localStorage.setItem(langKey(lang, cKey), query);
+ localStorage.setItem(engineKey(lang, engine, cKey), query);
setCorrectQueryMismatch(false);
setLoadedQuestionCorrect(true);
const cqStorageKey = modeKey("correctQuestions", editorMode);
- const cq = getStoredList(lang, cqStorageKey);
+ const cq = getStoredList(lang, engine, cqStorageKey);
if (!cq.includes(question.id)) {
cq.push(question.id);
- setStoredList(lang, cqStorageKey, cq);
+ setStoredList(lang, engine, cqStorageKey, cq);
setCorrectQuestions(cq);
}
}, [result, question, query, evaluatedQuery, exportingStatus, lang, editorMode]);
@@ -498,10 +536,9 @@ function App() {
// Save query based on question
const loadQuery = useCallback((_oldQuestion: Question | undefined, newQuestion: Question) => {
const key = modeKey(`questionId-${newQuestion.id}`, editorMode);
- setQuery(localStorage.getItem(langKey(lang, key)) || (editorMode === "ra" ? "" : defaultQuery));
- // This prevents user from ctrl-z'ing to a different question
- if (editorRef.current) {
- editorRef.current!.session = {history: { stack: [], offset: 0 }};
+ setQuery(localStorage.getItem(engineKey(lang, engine, key)) || (editorMode === "ra" ? "" : defaultQuery));
+ if (sqlEditorRef.current) {
+ sqlEditorRef.current.clearHistory();
}
}, [setQuery, lang, defaultQuery, editorMode]);
@@ -512,7 +549,7 @@ function App() {
}
const cKey = modeKey(`correctQuestionId-${question.id}`, editorMode);
- const correctQuery = localStorage.getItem(langKey(lang, cKey));
+ const correctQuery = localStorage.getItem(engineKey(lang, engine, cKey));
if (!correctQuery) {
setCorrectQueryMismatch(false);
setLoadedQuestionCorrect(false);
@@ -522,13 +559,12 @@ function App() {
setLoadedQuestionCorrect(true);
if (editorMode === "ra") {
- // For RA, compare raw text (trimmed)
setCorrectQueryMismatch(query.trim() !== correctQuery.trim());
} else {
let currentQuery = "";
try {
currentQuery = format(query + (query.endsWith(";") ? "" : ";"), {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
@@ -540,7 +576,7 @@ function App() {
return;
}
const correctQueryFormatted = format(correctQuery + (correctQuery?.endsWith(";") ? "" : ";"), {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
@@ -549,7 +585,7 @@ function App() {
});
setCorrectQueryMismatch(currentQuery !== correctQueryFormatted);
}
- }, [database, question, query, lang, editorMode]);
+ }, [database, question, query, lang, editorMode, formatterLang]);
const exportData = useCallback((options?: { include?: number[]}) => {
if (!database) {
@@ -567,18 +603,20 @@ function App() {
output += "/* --- BEGIN Metadata --- */\n";
output += "/* --- BEGIN Save Format Version --- */\n";
- output += "-- 3\n";
+ output += "-- 4\n";
output += "/* --- END Save Format Version --- */\n";
output += "/* --- BEGIN Save Language --- */\n";
output += `-- ${lang}\n`;
output += "/* --- END Save Language --- */\n";
+ output += "/* --- BEGIN Save Engine --- */\n";
+ output += `-- ${engine}\n`;
+ output += "/* --- END Save Engine --- */\n";
output += "/* --- END Metadata --- */\n";
output += "/* --- BEGIN Validation --- */\n";
- // Cache localStorage reads used multiple times below
- const storedCorrectIds = getStoredList(lang, "correctQuestions");
- const storedWrittenIds = getStoredList(lang, "writtenQuestions");
+ const storedCorrectIds = getStoredList(lang, engine, "correctQuestions");
+ const storedWrittenIds = getStoredList(lang, engine, "writtenQuestions");
const formatQuestionIds = (ids: number[]) => ids.map((id) => {
const category = questions.find(c => c.questions.some(q => q.id === id))!;
@@ -595,7 +633,7 @@ function App() {
output += "/* --- BEGIN Views --- */\n";
const viewsString = views.map(view => {
let out = format(view.query, {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
@@ -621,13 +659,13 @@ function App() {
const questionQueries = sorted.map((id: number) => {
const category = questions.find(c => c.questions.some(q => q.id === id))!;
const q = category.questions.find(q => q.id === id)!;
- const activeQuery = localStorage.getItem(langKey(lang, "correctQuestionId-" + id));
+ const activeQuery = localStorage.getItem(engineKey(lang, engine, "correctQuestionId-" + id));
if (!activeQuery) {
return "";
}
let formatted = `/* --- BEGIN Question ${category.display_number}${q.display_sequence} (REFERENCE: ${q.id}) --- */\n`;
formatted += format(activeQuery + (activeQuery.endsWith(";") ? "" : ";"), {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
@@ -648,7 +686,7 @@ function App() {
const collectQueries = (ids: number[], keyPrefix: string) => {
const queries: { [key: number]: string } = {};
for (const id of ids) {
- const q = localStorage.getItem(langKey(lang, keyPrefix + id));
+ const q = localStorage.getItem(engineKey(lang, engine, keyPrefix + id));
if (q) queries[id] = q;
}
return queries;
@@ -670,7 +708,6 @@ function App() {
output += "/* --- END Raw List Dumps --- */\n";
output += "/* --- END Validation --- */\n";
- // Calculate hash of everything within the validation block
const hashValue = sha256(output.slice(output.indexOf("/* --- BEGIN Validation Block --- */"), output.indexOf("/* --- END Validation Block --- */")));
output += `/* --- BEGIN Hash --- */\n-- ${hashValue}\n/* --- END Hash --- */\n`;
output += "/* --- END DO NOT EDIT --- */\n";
@@ -681,45 +718,47 @@ function App() {
const a = document.createElement("a");
const formattedTimestamp = formatFns(new Date(), "yyyyMMdd_HHmm");
a.href = url;
- a.download = `validator_${lang}_${formattedTimestamp}.sql`;;
+ a.download = `validator_${lang}_${engine}_${formattedTimestamp}.sql`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
- }, [database, views, lang, questions]);
+ }, [database, views, lang, questions, formatterLang]);
- const applyMergedData = useCallback((merged: ParsedSaveData) => {
+ const applyMergedData = useCallback(async (merged: ParsedSaveData) => {
// Clear current data
- getStoredList(lang, "writtenQuestions").forEach(id => localStorage.removeItem(langKey(lang, `questionId-${id}`)));
- getStoredList(lang, "correctQuestions").forEach(id => localStorage.removeItem(langKey(lang, `correctQuestionId-${id}`)));
- localStorage.removeItem(langKey(lang, "writtenQuestions"));
- localStorage.removeItem(langKey(lang, "correctQuestions"));
+ getStoredList(lang, engine, "writtenQuestions").forEach(id => localStorage.removeItem(engineKey(lang, engine, `questionId-${id}`)));
+ getStoredList(lang, engine, "correctQuestions").forEach(id => localStorage.removeItem(engineKey(lang, engine, `correctQuestionId-${id}`)));
+ localStorage.removeItem(engineKey(lang, engine, "writtenQuestions"));
+ localStorage.removeItem(engineKey(lang, engine, "correctQuestions"));
// Write merged data
for (const [key, value] of Object.entries(merged.rawQueries)) {
- localStorage.setItem(langKey(lang, `questionId-${key}`), value);
+ localStorage.setItem(engineKey(lang, engine, `questionId-${key}`), value);
if (question !== undefined && Number(key) === question.id) {
setQuery(value);
}
}
for (const [key, value] of Object.entries(merged.correctQueries)) {
- localStorage.setItem(langKey(lang, `correctQuestionId-${key}`), value);
+ localStorage.setItem(engineKey(lang, engine, `correctQuestionId-${key}`), value);
}
setWrittenQuestions(merged.writtenQuestionIds);
setCorrectQuestions(merged.correctQuestionIds);
- setStoredList(lang, "writtenQuestions", merged.writtenQuestionIds);
- setStoredList(lang, "correctQuestions", merged.correctQuestionIds);
+ setStoredList(lang, engine, "writtenQuestions", merged.writtenQuestionIds);
+ setStoredList(lang, engine, "correctQuestions", merged.correctQuestionIds);
// Update views in database
- for (const view of views) {
- database!.exec(`DROP VIEW ${view.name}`);
- }
- for (const view of merged.views) {
- database!.exec(view.query);
+ if (database) {
+ for (const view of views) {
+ await database.exec(`DROP VIEW ${view.name}`);
+ }
+ for (const view of merged.views) {
+ await database.exec(view.query);
+ }
+ await refreshViews(true);
}
- refreshViews(true);
}, [database, question, refreshViews, views, lang]);
const importData = useCallback(() => {
@@ -743,7 +782,7 @@ function App() {
}
const parsed = parseImportFile(data);
setPendingImportData(parsed);
- const local = getLocalData(lang);
+ const local = getLocalData(lang, engine);
const analysis = detectConflicts(local, parsed);
importDialogRef.current?.open(analysis);
};
@@ -771,7 +810,7 @@ function App() {
return;
}
- const toExportQuery = localStorage.getItem(langKey(lang, modeKey(`correctQuestionId-${question.id}`, editorMode)));
+ const toExportQuery = localStorage.getItem(engineKey(lang, engine, modeKey(`correctQuestionId-${question.id}`, editorMode)));
if (!toExportQuery) {
return;
}
@@ -798,90 +837,122 @@ function App() {
setExportingStatus(1);
- const triggerDownload = (dataUrl: string, filename: string) => {
- const byteString = atob(dataUrl.split(",")[1]);
- const mimeType = dataUrl.split(",")[0].split(":")[1].split(";")[0];
- const ab = new ArrayBuffer(byteString.length);
- const ia = new Uint8Array(ab);
- for (let i = 0; i < byteString.length; i++) {
- ia[i] = byteString.charCodeAt(i);
+ // Wait for CodeMirror to render before capturing
+ const capture = () => { requestAnimationFrame(() => {
+ if (!exportRendererRef.current) { setExportingStatus(0); return; }
+
+ const triggerDownload = (dataUrl: string, filename: string) => {
+ const byteString = atob(dataUrl.split(",")[1]);
+ const mimeType = dataUrl.split(",")[0].split(":")[1].split(";")[0];
+ const ab = new ArrayBuffer(byteString.length);
+ const ia = new Uint8Array(ab);
+ for (let i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i);
+ }
+ const blob = new Blob([ab], { type: mimeType });
+ const blobUrl = URL.createObjectURL(blob);
+
+ const link = document.createElement("a");
+ link.download = filename;
+ link.href = blobUrl;
+ link.style.display = "none";
+ document.body.appendChild(link);
+ link.click();
+
+ setTimeout(() => {
+ document.body.removeChild(link);
+ URL.revokeObjectURL(blobUrl);
+ }, 100);
+ };
+
+ // View
+ if (exportView) {
+ toPng(exportRendererRef.current, {
+ canvasWidth: exportRendererRef.current.scrollWidth,
+ width: exportRendererRef.current.scrollWidth,
+ canvasHeight: exportRendererRef.current.scrollHeight,
+ height: exportRendererRef.current.scrollHeight,
+ pixelRatio: 1
+ }).then((dataUrl) => {
+ triggerDownload(dataUrl, `validator_${exportView.name}.png`);
+ setExportView(undefined);
+ setExportingStatus(0);
+ });
+ return;
+ }
+
+ // Question
+ if (!question || !exportQuery || !exportQuestion) {
+ return;
}
- const blob = new Blob([ab], { type: mimeType });
- const blobUrl = URL.createObjectURL(blob);
-
- const link = document.createElement("a");
- link.download = filename;
- link.href = blobUrl;
- link.style.display = "none";
- document.body.appendChild(link);
- link.click();
-
- setTimeout(() => {
- document.body.removeChild(link);
- URL.revokeObjectURL(blobUrl);
- }, 100);
- };
- // View
- if (exportView) {
- toPng(exportRendererRef.current, {
- canvasWidth: exportRendererRef.current.scrollWidth,
- width: exportRendererRef.current.scrollWidth,
- canvasHeight: exportRendererRef.current.scrollHeight,
- height: exportRendererRef.current.scrollHeight,
+ const exportRenderer = exportRendererRef.current;
+ toPng(exportRenderer, {
+ canvasWidth: exportRenderer.scrollWidth,
+ width: exportRenderer.scrollWidth,
+ canvasHeight: exportRenderer.scrollHeight,
+ height: exportRenderer.scrollHeight,
pixelRatio: 1
}).then((dataUrl) => {
- triggerDownload(dataUrl, `validator_${exportView.name}.png`);
- setExportView(undefined);
+ triggerDownload(dataUrl, `validator_${editorMode === "ra" ? "ra_" : ""}${question.id}_${question.category.display_number}${question.display_sequence}.png`);
+ setExportQuestion(undefined);
+ setExportQuery(undefined);
setExportingStatus(0);
});
+
+ }); }; // end capture + rAF
+ capture();
+ }, [evaluatedQuery, exportQuery, exportRendererRef, getTheme, isDarkMode, exportingStatus, question, resetResult, setTheme, exportQuestion, exportView]);
+
+ // Export renderer needs sync evalSql — we compute it eagerly when export is triggered
+ const [exportResult, setExportResult] = useState<{ result: Result; isCorrect: boolean } | null>(null);
+ const [exportViewResult, setExportViewResult] = useState(null);
+
+ useEffect(() => {
+ if (!exportQuestion || !exportQuery || !database) {
+ setExportResult(null);
return;
}
+ const compute = async () => {
+ let sqlForExport = exportQuery;
+ if (editorMode === "ra") {
+ try { sqlForExport = await raToSQL(exportQuery, database); } catch { /* use raw */ }
+ }
+ const result = await evalSql(sqlForExport);
+ const correct = isCorrectResult(exportQuestion.evaluable_result, result) || (exportQuestion.alternative_evaluable_results?.some(alt => isCorrectResult(alt, result)) ?? false);
+ setExportResult({ result, isCorrect: correct });
+ };
+ compute();
+ }, [exportQuestion, exportQuery, editorMode, database, evalSql]);
- // Question
- if (!question || !exportQuery || !exportQuestion) {
+ useEffect(() => {
+ if (!exportView || !database) {
+ setExportViewResult(null);
return;
}
-
- const exportRenderer = exportRendererRef.current;
- toPng(exportRenderer, {
- canvasWidth: exportRenderer.scrollWidth,
- width: exportRenderer.scrollWidth,
- canvasHeight: exportRenderer.scrollHeight,
- height: exportRenderer.scrollHeight,
- pixelRatio: 1
- }).then((dataUrl) => {
- triggerDownload(dataUrl, `validator_${editorMode === "ra" ? "ra_" : ""}${question.id}_${question.category.display_number}${question.display_sequence}.png`);
- setExportQuestion(undefined);
- setExportQuery(undefined);
- setExportingStatus(0);
- });
- }, [evaluatedQuery, exportQuery, exportRendererRef, getTheme, isDarkMode, exportingStatus, question, resetResult, setTheme, exportQuestion, exportView]);
+ evalSql(`SELECT * FROM ${exportView.name}`).then(setExportViewResult);
+ }, [exportView, database, evalSql]);
const exportRendererProps = useMemo(() => {
- if (!exportQuestion || !exportQuery) return null;
- let sqlForExport = exportQuery;
- if (editorMode === "ra" && database) {
- try { sqlForExport = raToSQL(exportQuery, database); } catch { /* use raw */ }
- }
- const exportResult = evalSql(sqlForExport);
+ if (!exportQuestion || !exportQuery || !exportResult) return null;
return {
- isCorrect: isCorrectResult(exportQuestion.evaluable_result, exportResult) || (exportQuestion.alternative_evaluable_results?.some(alt => isCorrectResult(alt, exportResult)) ?? false),
+ isCorrect: exportResult.isCorrect,
question: exportQuestion,
code: exportQuery,
- result: exportResult,
+ result: exportResult.result,
mode: editorMode,
};
- }, [exportQuestion, exportQuery, editorMode, database, evalSql]);
+ }, [exportQuestion, exportQuery, exportResult, editorMode]);
return (
{exportRendererProps && }
- {exportView && }
+ {exportView && exportViewResult && }
+
SQL Validator
@@ -911,54 +982,60 @@ function App() {
-
-
-
-
-
- exportImageQuery()}
- disabled={!loadedQuestionCorrect}
- >
- {t("exportPng")}
-
- importData()}>
- {t("importData")}
-
- exportModalRef.current?.openDialog()}>
- {t("exportData")}
-
-
-
+
+
+
+
+
+
+
+ exportImageQuery()}
+ disabled={!loadedQuestionCorrect}
+ >
+ {t("exportPng")}
+
+ importData()}>
+ {t("importData")}
+
+ exportModalRef.current?.openDialog()}>
+ {t("exportData")}
+
+
+
+
- {query === undefined ?
- null}
- highlight={code => editorMode === "ra" ? highlightRA(code, isDarkMode()) : highlight(code, languages.sql)}
- padding={10}
- tabSize={2}
- className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 min-h-32 rounded-md"
- ref={editorRef}
- />
- :
- setQuery(code)}
- highlight={code => editorMode === "ra" ? highlightRA(code, isDarkMode()) : highlight(code, languages.sql)}
- padding={10}
- tabSize={2}
- className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 border dark:border-slate-600 border-gray-300 min-h-32 rounded-md"
- ref={editorRef}
+ ref={sqlEditorRef}
+ value={query ?? (t("selectQuestionComment"))}
+ onChange={query !== undefined ? setQuery : undefined}
+ schema={dbSchema}
+ engine={engine}
+ isDarkMode={isDarkMode()}
+ disabled={query === undefined}
+ readOnly={query === undefined}
+ editorSettings={editorSettings}
+ className="font-mono w-full dark:bg-slate-800 bg-slate-100 border dark:border-slate-600 border-gray-300 min-h-32 rounded-md overflow-hidden"
/>
- }
+ ) : (
+
+ )}
{editorMode === "ra" && query !== undefined && }
@@ -980,7 +1057,9 @@ function App() {
)}
{editorMode === "ra"
?
- : {t("sqlReference")}
+ : engine === "postgresql"
+ ? {t("sqlReferencePostgresql")}
+ : {t("sqlReference")}
}
@@ -991,7 +1070,7 @@ function App() {
variant="outline"
onClick={() => {
if (!question) return;
- setQuery(localStorage.getItem(langKey(lang, modeKey(`correctQuestionId-${question.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery));
+ setQuery(localStorage.getItem(engineKey(lang, engine, modeKey(`correctQuestionId-${question.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery));
}}
className="border-yellow-500 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
>
@@ -1004,7 +1083,7 @@ function App() {
onClick={() => {
if (!query) return;
setQuery(format(query, {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
@@ -1041,7 +1120,6 @@ function App() {
{/* Results Section */}
{result && <>
{!isViewResult ? question && <>
- {/* if correct result else display wrong result */}
{isCorrect ? (
@@ -1058,7 +1136,6 @@ function App() {
{t("correctButVerify")}
)}
- {/* Two different result tables next to each other, actual and expected */}
{t("actual")}
@@ -1076,22 +1153,20 @@ function App() {
> : <>
{t("viewLabel")} {queryedView}
{t("viewQueryLabel", { name: queryedView || "" })}
- view.name === queryedView) ? views.find(view => view.name === queryedView)!.query : "-- View Deleted", {
- language: "sqlite",
+ language: formatterLang,
tabWidth: 2,
useTabs: false,
keywordCase: "upper",
dataTypeCase: "upper",
functionCase: "upper",
})}
- onValueChange={() => null}
- highlight={code => highlight(code, languages.sql)}
- padding={10}
- tabSize={4}
- className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 max-w-4xl min-h-32 rounded-md my-2"
+ readOnly={true}
+ engine={engine}
+ isDarkMode={isDarkMode()}
+ className="font-mono w-full dark:bg-slate-800 bg-slate-100 max-w-4xl min-h-32 rounded-md my-2 overflow-hidden"
/>
{t("viewResultLabel", { name: queryedView || "" })}
diff --git a/src/DatabaseLayoutDialog.tsx b/src/DatabaseLayoutDialog.tsx
index e30ca61..e33daf9 100644
--- a/src/DatabaseLayoutDialog.tsx
+++ b/src/DatabaseLayoutDialog.tsx
@@ -9,11 +9,11 @@ import dbLayoutLightFallback from "./db_layout_light.svg";
import dbLayoutLightPngFallback from "./db_layout_light_bg.png";
const DatabaseLayoutDialog = ({ isDarkMode }: { isDarkMode: () => boolean }) => {
- const { lang, t } = useLanguage();
+ const { lang, engine, t } = useLanguage();
const [open, setOpen] = useState(false);
- // Try language-specific ERD, fallback to static imports
- const basePath = `/languages/${lang}`;
+ // Use engine-specific ERD path (PostgreSQL ERDs are in {lang}-pg/)
+ const basePath = engine === "postgresql" ? `/languages/${lang}-pg` : `/languages/${lang}`;
const darkSrc = `${basePath}/db_layout_dark.svg`;
const lightSrc = `${basePath}/db_layout_light.svg`;
const lightPngSrc = `${basePath}/db_layout_light_bg.png`;
diff --git a/src/EditorSettingsDialog.tsx b/src/EditorSettingsDialog.tsx
new file mode 100644
index 0000000..a74efd4
--- /dev/null
+++ b/src/EditorSettingsDialog.tsx
@@ -0,0 +1,68 @@
+import React from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Checkbox } from "@radix-ui/react-checkbox";
+import { Label } from "@radix-ui/react-label";
+import { Settings2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { EditorSettings } from "./useEditorSettings";
+import { useLanguage } from "./i18n/context";
+
+interface EditorSettingsDialogProps {
+ settings: EditorSettings;
+ onSettingsChange: (partial: Partial) => void;
+}
+
+const EditorSettingsDialog: React.FC = ({ settings, onSettingsChange }) => {
+ const { t } = useLanguage();
+
+ return (
+
+ );
+};
+
+function SettingToggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
+ const id = label.toLowerCase().replace(/\s+/g, "-");
+ return (
+
+
+ onChange(v === true)}
+ className="h-5 w-5 rounded border border-gray-300 dark:border-slate-600 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 flex items-center justify-center"
+ >
+ {checked && ✓}
+
+
+ );
+}
+
+export default EditorSettingsDialog;
diff --git a/src/EngineSelector.tsx b/src/EngineSelector.tsx
new file mode 100644
index 0000000..a50513a
--- /dev/null
+++ b/src/EngineSelector.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useLanguage } from "./i18n/context";
+import { AVAILABLE_ENGINES } from "./i18n/languages";
+
+const EngineSelector: React.FC = () => {
+ const { engine, setEngine } = useLanguage();
+
+ return (
+
+ );
+};
+
+export default EngineSelector;
diff --git a/src/ExportRenderer.tsx b/src/ExportRenderer.tsx
index 3dde3f2..cd61022 100644
--- a/src/ExportRenderer.tsx
+++ b/src/ExportRenderer.tsx
@@ -1,14 +1,12 @@
import React from "react";
-import Editor from "react-simple-code-editor";
import { format } from "sql-formatter";
-// @ts-expect-error - No types available
-import { highlight, languages } from "prismjs/components/prism-core";
import { Result } from "./utils";
import ResultTable from "./ResultTable";
import { Question } from "./QuestionSelector";
import { View } from "./ViewsTable";
import { useLanguage } from "./i18n/context";
import { renderRAPreview } from "./ra-engine/RAPreview";
+import SqlEditor from "./SqlEditor";
interface ExportRendererProps {
query?: {
@@ -25,7 +23,8 @@ interface ExportRendererProps {
}
const ExportRenderer = React.forwardRef(({ query, view }, ref) => {
- const { t } = useLanguage();
+ const { t, engine } = useLanguage();
+ const formatterLang = engine === "postgresql" ? "postgresql" : "sqlite";
if (!query && !view) {
return null;
@@ -63,23 +62,23 @@ const ExportRenderer = React.forwardRef(({
dangerouslySetInnerHTML={{ __html: renderRAPreview(query.code, true) }}
/>
) : (
- null}
- highlight={code => highlight(code, languages.sql)}
- padding={10}
- tabSize={4}
- className="font-mono text-xl w-full bg-slate-200 border-2 max-w-4xl min-h-40 border-none my-2"
- />
+
+
+
)}
{query &&
{t("exportResultLabel")}
diff --git a/src/ImportDialog.tsx b/src/ImportDialog.tsx
index af181c6..1a7792d 100644
--- a/src/ImportDialog.tsx
+++ b/src/ImportDialog.tsx
@@ -80,7 +80,7 @@ function SqlBlock({ code, changedLines, variant }: { code: string; changedLines?
const ImportDialog = forwardRef(
({ importedData, onOverwrite, onMergeApply }, ref) => {
- const { t, lang, questions } = useLanguage();
+ const { t, lang, engine, questions } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState<"choose" | "resolve">("choose");
const [analysis, setAnalysis] = useState(null);
@@ -169,6 +169,11 @@ const ImportDialog = forwardRef(
{t("languageMismatchWarning", { fileLang: importedData.language })}
)}
+ {importedData && importedData.engine !== engine && (
+
+ {t("engineMismatchWarning", { fileEngine: importedData.engine })}
+
+ )}