testownik-converter 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +3 -0
- package/package.json +9 -6
- package/src/adapters/base-adapter.ts +12 -2
- package/src/adapters/ccna-adapter.ts +39 -26
- package/src/cli/index.ts +26 -14
- package/src/constants/index.ts +7 -3
- package/src/logging/cards.ts +62 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/logger.ts +16 -0
- package/src/types/adapters.ts +1 -1
- package/src/types/index.ts +2 -2
- package/src/types/testownik.ts +1 -1
package/bin/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testownik-converter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "A command-line utility to convert quizzes into Solvro's Testownik format.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Konrad Guzek",
|
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
"url": "git+https://github.com/kguzek/testownik-converter.git"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"testownik-converter": "
|
|
17
|
+
"testownik-converter": "bin/index.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup"
|
|
18
21
|
},
|
|
19
22
|
"dependencies": {
|
|
23
|
+
"boxen": "^8.0.1",
|
|
24
|
+
"chalk": "^5.6.2",
|
|
20
25
|
"commander": "^14.0.3"
|
|
21
26
|
},
|
|
22
27
|
"devDependencies": {
|
|
@@ -24,7 +29,5 @@
|
|
|
24
29
|
"tsup": "^8.5.1",
|
|
25
30
|
"typescript": "^5.9.3"
|
|
26
31
|
},
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
}
|
|
32
|
+
"packageManager": "pnpm@10.28.2"
|
|
33
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { AdapterOptions, TestownikQuestion, TestownikQuiz } from "@/types";
|
|
2
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
2
|
|
|
3
|
+
import type { AdapterOptions, TestownikQuestion, TestownikQuiz } from "@/types";
|
|
4
|
+
import { logger } from "@/logging";
|
|
5
|
+
|
|
4
6
|
export abstract class BaseAdapter {
|
|
5
7
|
protected inputContent!: string;
|
|
6
8
|
protected options!: AdapterOptions;
|
|
@@ -16,6 +18,12 @@ export abstract class BaseAdapter {
|
|
|
16
18
|
|
|
17
19
|
private createQuiz(): TestownikQuiz {
|
|
18
20
|
const questions = this.convertQuestions();
|
|
21
|
+
if (questions.length === 0) {
|
|
22
|
+
throw new Error("Could not find any questions in the input file.");
|
|
23
|
+
}
|
|
24
|
+
logger.info(
|
|
25
|
+
`Successfully imported ${questions.length} question${questions.length === 1 ? "" : "s"}!`,
|
|
26
|
+
);
|
|
19
27
|
const quiz: TestownikQuiz = {
|
|
20
28
|
title: this.options.quizTitle,
|
|
21
29
|
...(this.options.quizDescription == null
|
|
@@ -29,6 +37,8 @@ export abstract class BaseAdapter {
|
|
|
29
37
|
writeOutput(): void {
|
|
30
38
|
const quiz = this.createQuiz();
|
|
31
39
|
const data = JSON.stringify(quiz, null, 2);
|
|
32
|
-
|
|
40
|
+
const filename = this.options.outputFilename;
|
|
41
|
+
writeFileSync(filename, data, "utf8");
|
|
42
|
+
logger.info(`Converted quiz written to ${filename}.`);
|
|
33
43
|
}
|
|
34
44
|
}
|
|
@@ -1,37 +1,50 @@
|
|
|
1
|
-
import { BaseAdapter } from "./base-adapter";
|
|
2
1
|
import type { TestownikQuestion, TestownikAnswer } from "@/types";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { BaseAdapter } from "./base-adapter";
|
|
4
|
+
import { logger } from "@/logging";
|
|
5
|
+
|
|
6
|
+
const QUESTION_REGEX =
|
|
7
|
+
/^(?<number>\d+)\.\s+(?<question>[\s\S]+?)\n^-\s+(?<answers>.+(?:\r?\n.+)+)/gm;
|
|
8
8
|
|
|
9
|
-
const ANSWER_REGEX =
|
|
10
|
-
String.raw`^- (?<correct>\[✓\] )?(?<question>.+)$`,
|
|
11
|
-
"gm",
|
|
12
|
-
);
|
|
9
|
+
const ANSWER_REGEX = /^(?<correct>\[✓\]\s+)?(?<answer>[\s\S]+)/m;
|
|
13
10
|
|
|
14
11
|
export class CcnaAdapter extends BaseAdapter {
|
|
15
12
|
convertQuestions() {
|
|
16
|
-
QUESTION_REGEX.lastIndex = 0;
|
|
17
13
|
const questions: TestownikQuestion[] = [];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
for (const questionMatch of this.inputContent.matchAll(QUESTION_REGEX)) {
|
|
15
|
+
if (questionMatch.groups == null) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const questionText = questionMatch.groups.question?.trim();
|
|
19
|
+
if (!questionText) {
|
|
20
|
+
logger.warn("Skipping question with no text");
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const answersText = questionMatch.groups.answers?.trim();
|
|
25
|
+
if (!answersText) {
|
|
26
|
+
logger.warn(`Skipping question with no answers: '${questionText}'`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(answerMatch
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const answers: TestownikAnswer[] = [];
|
|
31
|
+
for (const fullAnswerText of answersText.split(/\r?\n-\s+/)) {
|
|
32
|
+
const answerMatch = fullAnswerText.match(ANSWER_REGEX);
|
|
33
|
+
if (answerMatch?.groups == null) {
|
|
34
|
+
logger.warn(`Skiping invalid answer: ${fullAnswerText}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const isCorrect = answerMatch?.groups?.correct != null;
|
|
38
|
+
const answerText = answerMatch.groups?.answer?.trim();
|
|
39
|
+
if (!answerText) {
|
|
40
|
+
logger.warn(
|
|
41
|
+
`Skipping answer with no text in question: ${questionText}`,
|
|
42
|
+
);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
32
45
|
const answer: TestownikAnswer = {
|
|
33
|
-
text:
|
|
34
|
-
is_correct:
|
|
46
|
+
text: answerText,
|
|
47
|
+
is_correct: isCorrect,
|
|
35
48
|
};
|
|
36
49
|
answers.push(answer);
|
|
37
50
|
}
|
|
@@ -39,7 +52,7 @@ export class CcnaAdapter extends BaseAdapter {
|
|
|
39
52
|
continue;
|
|
40
53
|
}
|
|
41
54
|
const question: TestownikQuestion = {
|
|
42
|
-
text:
|
|
55
|
+
text: questionText,
|
|
43
56
|
answers,
|
|
44
57
|
multiple: answers.filter((answer) => answer.is_correct).length > 1,
|
|
45
58
|
};
|
package/src/cli/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
|
|
2
3
|
import { CcnaAdapter } from "@/adapters/ccna-adapter";
|
|
3
|
-
import { REPO_NAME, REPO_URL } from "@/constants";
|
|
4
|
+
import { REPO_NAME, REPO_URL, VERSION } from "@/constants";
|
|
5
|
+
import { cardIntro, cardOutro, cardError, logger } from "@/logging";
|
|
4
6
|
|
|
5
7
|
const program = new Command();
|
|
6
8
|
program
|
|
@@ -15,28 +17,38 @@ program
|
|
|
15
17
|
`Generated by ${REPO_NAME}: ${REPO_URL}`,
|
|
16
18
|
)
|
|
17
19
|
.option("-o, --output <filename>", "output JSON filename", "testownik.json")
|
|
18
|
-
.version(
|
|
20
|
+
.version(VERSION);
|
|
19
21
|
|
|
20
22
|
program
|
|
21
23
|
.command("ccna")
|
|
22
24
|
.argument("<filename>", "input text filename")
|
|
23
25
|
.description("Parses questions from Cisco's CCNA exam format.")
|
|
24
26
|
.action((inputFilename: string) => {
|
|
25
|
-
|
|
27
|
+
logger.print(cardIntro);
|
|
28
|
+
try {
|
|
29
|
+
const options = program.opts();
|
|
30
|
+
|
|
31
|
+
const outputFilename: string = options.output;
|
|
32
|
+
const quizTitle: string = options.title;
|
|
33
|
+
const quizDescription: string | undefined = options.description;
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const adapter = new CcnaAdapter({
|
|
36
|
+
inputFilename,
|
|
37
|
+
outputFilename,
|
|
38
|
+
quizTitle,
|
|
39
|
+
quizDescription,
|
|
40
|
+
});
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
inputFilename,
|
|
33
|
-
outputFilename,
|
|
34
|
-
quizTitle,
|
|
35
|
-
quizDescription,
|
|
36
|
-
});
|
|
42
|
+
adapter.writeOutput();
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
logger.print(cardOutro);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.error(
|
|
47
|
+
error instanceof Error ? error.message : `Unknown error: ${error}`,
|
|
48
|
+
);
|
|
49
|
+
logger.print(cardError);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
|
40
52
|
});
|
|
41
53
|
|
|
42
54
|
program.parse(process.argv);
|
package/src/constants/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
1
|
+
export const AUTHOR_EMAIL = "konrad@guzek.uk";
|
|
2
|
+
export const AUTHOR_NAME = "Konrad Guzek";
|
|
3
3
|
export const AUTHOR_URL = "https://github.com/kguzek";
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const REPO_NAME = "Testownik Converter";
|
|
6
|
+
export const REPO_SLUG = "testownik-converter";
|
|
7
|
+
export const REPO_URL = `${AUTHOR_URL}/${REPO_SLUG}`;
|
|
8
|
+
|
|
9
|
+
export const VERSION = process.env.npm_package_version || "1.0.1";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import boxen from "boxen";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AUTHOR_EMAIL,
|
|
6
|
+
AUTHOR_NAME,
|
|
7
|
+
AUTHOR_URL,
|
|
8
|
+
REPO_NAME,
|
|
9
|
+
REPO_URL,
|
|
10
|
+
VERSION,
|
|
11
|
+
} from "@/constants";
|
|
12
|
+
|
|
13
|
+
export const cardIntro = boxen(
|
|
14
|
+
chalk.white(`
|
|
15
|
+
Welcome to ${REPO_NAME} version ${VERSION}!
|
|
16
|
+
|
|
17
|
+
Author: ${AUTHOR_NAME}
|
|
18
|
+
GitHub: ${chalk.underline(AUTHOR_URL)}
|
|
19
|
+
Email: ${AUTHOR_EMAIL}
|
|
20
|
+
`),
|
|
21
|
+
{
|
|
22
|
+
padding: 1,
|
|
23
|
+
margin: 1,
|
|
24
|
+
borderStyle: "round",
|
|
25
|
+
borderColor: "cyan",
|
|
26
|
+
textAlignment: "center",
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const cardOutro = boxen(
|
|
31
|
+
chalk.white(`
|
|
32
|
+
Thank you for using ${REPO_NAME}.
|
|
33
|
+
|
|
34
|
+
⭐ Star me on GitHub! ⭐
|
|
35
|
+
|
|
36
|
+
${chalk.underline(REPO_URL)}
|
|
37
|
+
`),
|
|
38
|
+
{
|
|
39
|
+
padding: 1,
|
|
40
|
+
margin: 1,
|
|
41
|
+
borderStyle: "round",
|
|
42
|
+
borderColor: "yellow",
|
|
43
|
+
textAlignment: "center",
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const generateErrorCard = (text: string) =>
|
|
48
|
+
boxen(chalk.white(text), {
|
|
49
|
+
padding: 1,
|
|
50
|
+
margin: 1,
|
|
51
|
+
borderStyle: "round",
|
|
52
|
+
borderColor: "red",
|
|
53
|
+
textAlignment: "center",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const cardError = generateErrorCard(`
|
|
57
|
+
|
|
58
|
+
An unexpected error occurred during the program's execution.
|
|
59
|
+
If the problem persists, please report it on GitHub:
|
|
60
|
+
|
|
61
|
+
${chalk.underline(REPO_URL + "/issues/new")}
|
|
62
|
+
`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { REPO_NAME } from "@/constants";
|
|
2
|
+
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
const formatMessage = (emoji: string, message: string) =>
|
|
6
|
+
`\n${emoji} ${chalk.dim("[")}${chalk.bgCyan.black(REPO_NAME)}${chalk.reset.dim("]")} ${message}`;
|
|
7
|
+
|
|
8
|
+
export const logger = {
|
|
9
|
+
print: console.log,
|
|
10
|
+
info: (message: string) =>
|
|
11
|
+
console.info(formatMessage("🤖", chalk.cyan(message))),
|
|
12
|
+
warn: (message: string) =>
|
|
13
|
+
console.warn(formatMessage("⚠️", chalk.yellow(message))),
|
|
14
|
+
error: (message: string) =>
|
|
15
|
+
console.error(formatMessage("❌", chalk.red(message))),
|
|
16
|
+
};
|
package/src/types/adapters.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type
|
|
1
|
+
export type * from "./adapters";
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type * from "./testownik";
|