@tostudy-ai/cli 0.18.27 → 0.18.33
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/README.md +98 -0
- package/dist/main.js +400 -25
- package/dist/main.js.map +4 -4
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ToStudy CLI
|
|
2
|
+
|
|
3
|
+
Terminal study client for [ToStudy](https://tostudy.ai) — navigate courses, read lessons, get hints, and validate exercises from the command line. Published to npm as [`@tostudy-ai/cli`](https://www.npmjs.com/package/@tostudy-ai/cli). Since v0.18.0 it also carries the `creator` group, which lets a creator's own AI agent author a course and upload it as a draft.
|
|
4
|
+
|
|
5
|
+
**Requires:** Node.js >= 22 (bundle loads on >= 20.18.1 with a warning)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @tostudy-ai/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
tostudy login # Authenticate (also installs the /tostudy command for your IDE)
|
|
17
|
+
tostudy courses # List enrolled courses
|
|
18
|
+
tostudy select # Select the active course
|
|
19
|
+
tostudy lesson # Read the current lesson
|
|
20
|
+
tostudy next # Advance to the next lesson
|
|
21
|
+
tostudy validate # Validate an exercise solution
|
|
22
|
+
tostudy progress # Show study progress
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
Source of truth: `src/commands/` (one file per command). Every command supports `--json` output designed for LLM agent consumption.
|
|
28
|
+
|
|
29
|
+
| Command | Description |
|
|
30
|
+
| --------------- | -------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `login` | Authenticate with ToStudy (`--creator` requests the creator scope) |
|
|
32
|
+
| `logout` | Revoke the server session and clear local authentication |
|
|
33
|
+
| `doctor` | Diagnostic health check |
|
|
34
|
+
| `init` | Initialize course workspace |
|
|
35
|
+
| `profile` | Show or manage CLI profile |
|
|
36
|
+
| `courses` | List enrolled courses (`--mine`: courses you authored) |
|
|
37
|
+
| `select` | Select active course |
|
|
38
|
+
| `progress` | Show study progress |
|
|
39
|
+
| `grades` | Show grades and exercise scores |
|
|
40
|
+
| `review` | Review past exercise attempts |
|
|
41
|
+
| `start` | Start a module |
|
|
42
|
+
| `start-next` | Start next available module |
|
|
43
|
+
| `next` | Advance to next lesson |
|
|
44
|
+
| `lesson` | Show current lesson |
|
|
45
|
+
| `knowledge` | Show the active course's knowledge base |
|
|
46
|
+
| `hint` | Get hint for current exercise |
|
|
47
|
+
| `theory` | Request additional explanation in current lesson |
|
|
48
|
+
| `validate` | Validate exercise solution |
|
|
49
|
+
| `retry` | Revalidate a completed lesson (grade keeps the best attempt) |
|
|
50
|
+
| `level` | Adjust exercise difficulty in real time |
|
|
51
|
+
| `menu` | Interactive menu |
|
|
52
|
+
| `sync` | Regenerate instruction files (`--all-runtimes` for every supported runtime) |
|
|
53
|
+
| `brief` | Show base learner brief (T1) status |
|
|
54
|
+
| `brief-create` | Create base learner brief via interactive prompts |
|
|
55
|
+
| `brief-open` | Open the learner brief editor in the browser |
|
|
56
|
+
| `export` | Extract exercise to workspace |
|
|
57
|
+
| `export-grades` | Export exercise history in structured format |
|
|
58
|
+
| `open` | Open workspace in IDE |
|
|
59
|
+
| `workspace` | Workspace operations (`workspace setup`) |
|
|
60
|
+
| `vault` | Obsidian vault operations (`vault init`, `vault sync`) |
|
|
61
|
+
| `compact` | Save module study summary from stdin |
|
|
62
|
+
| `context` | Load session context (workspace + summaries) |
|
|
63
|
+
| `memory` | Load accumulated student memory |
|
|
64
|
+
| `insight` | Record a student insight (difficulty/breakthrough/question) |
|
|
65
|
+
| `creator` | Author a course with your AI agent (`init`, `validate`, `push`, `pull`, `audit`, `status`, `open`) |
|
|
66
|
+
|
|
67
|
+
> `tostudy setup` is deprecated — IDE detection and `/tostudy` installation are part of `tostudy login`.
|
|
68
|
+
|
|
69
|
+
## IDE Integration
|
|
70
|
+
|
|
71
|
+
`tostudy login` / `select` / `init` generate instruction files that teach AI assistants how to tutor: Claude Code slash commands, Cursor rules, AGENTS.md blocks, OpenCode/Grok commands, and Antigravity skills. See [AGENTS.md](./AGENTS.md) for the full runtime matrix.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Run from source (no dev server — CLI tool)
|
|
77
|
+
tsx bin/tostudy.ts <command>
|
|
78
|
+
|
|
79
|
+
# Tests / lint / typecheck
|
|
80
|
+
pnpm -C apps/tostudy-cli test
|
|
81
|
+
pnpm -C apps/tostudy-cli lint
|
|
82
|
+
pnpm -C apps/tostudy-cli type-check
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Build & Publish
|
|
86
|
+
|
|
87
|
+
| Target | Command | Output |
|
|
88
|
+
| ----------- | ----------------------------------------------- | --------------------------------------------- |
|
|
89
|
+
| npm package | `pnpm -C apps/tostudy-cli build` | `dist/cli.js` + `dist/main.js` (esbuild, ESM) |
|
|
90
|
+
| Standalone | `pnpm -C apps/tostudy-cli build:standalone` | 4 binaries (macOS arm64/x64, Linux, Windows) |
|
|
91
|
+
| Publish | `pnpm -C apps/tostudy-cli build && npm publish` | `@tostudy-ai/cli` on npm (public) |
|
|
92
|
+
|
|
93
|
+
> Always build by hand right before publishing and verify `dist/main.js` holds the new version string — with `ignore-scripts=true` in `~/.npmrc`, npm silently skips `prepublishOnly` and packs a stale `dist/`.
|
|
94
|
+
|
|
95
|
+
## Related
|
|
96
|
+
|
|
97
|
+
- [AGENTS.md](./AGENTS.md) - Agent instructions (architecture, creator authoring, context pipeline)
|
|
98
|
+
- [@repo/tostudy-core](../../packages/tostudy-core/README.md) - Shared business logic (bundled at build time)
|
package/dist/main.js
CHANGED
|
@@ -657,6 +657,12 @@ function parseOtlpResourceAttributes(raw2) {
|
|
|
657
657
|
}
|
|
658
658
|
return out;
|
|
659
659
|
}
|
|
660
|
+
function resolveOtlpResourceAttributes(env) {
|
|
661
|
+
const attributes = parseOtlpResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES);
|
|
662
|
+
const railwayEnvironment = env.RAILWAY_ENVIRONMENT_NAME?.trim();
|
|
663
|
+
if (!attributes["deployment.environment"] && railwayEnvironment) attributes["deployment.environment"] = railwayEnvironment;
|
|
664
|
+
return attributes;
|
|
665
|
+
}
|
|
660
666
|
function parseOtlpHeaders(raw2) {
|
|
661
667
|
if (!raw2) return {};
|
|
662
668
|
const out = {};
|
|
@@ -975,7 +981,7 @@ var init_dist = __esm({
|
|
|
975
981
|
if (otlpEndpoint) this.transports.push(getOrCreateOtlpLogsTransport({
|
|
976
982
|
endpoint: otlpEndpoint,
|
|
977
983
|
serviceName: process.env.OTEL_SERVICE_NAME || getDefaultService(),
|
|
978
|
-
resourceAttributes:
|
|
984
|
+
resourceAttributes: resolveOtlpResourceAttributes(process.env),
|
|
979
985
|
headers: {
|
|
980
986
|
...parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS),
|
|
981
987
|
...parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS)
|
|
@@ -1092,18 +1098,22 @@ var init_errors_pt_br = __esm({
|
|
|
1092
1098
|
pathOutsideRoot: "caminho fora da pasta do curso: {path}. Use s\xF3 caminhos relativos dentro do workspace.",
|
|
1093
1099
|
payloadTooLarge: "curso grande demais para um push: {bytes} bytes (limite {max}). Divida m\xF3dulos ou encurte li\xE7\xF5es.",
|
|
1094
1100
|
validateOk: "\u2713 Workspace v\xE1lido: {lessons} li\xE7\xF5es, {bytes} bytes.",
|
|
1095
|
-
|
|
1101
|
+
validateFailedOne: "\u2717 {count} erro. Corrija e rode `tostudy creator validate` de novo.",
|
|
1102
|
+
validateFailedOther: "\u2717 {count} erros. Corrija e rode `tostudy creator validate` de novo.",
|
|
1096
1103
|
pushCreated: "\u2713 Curso criado como rascunho. Revise no portal: {url}",
|
|
1097
1104
|
pushUpdated: "\u2713 Curso atualizado. A revis\xE3o no portal foi reiniciada: {url}",
|
|
1098
1105
|
pushUnchanged: "\u2713 Nada mudou desde o \xFAltimo push: {url}",
|
|
1099
|
-
|
|
1106
|
+
pullDoneOne: "\u2713 {count} arquivo atualizado a partir do servidor:",
|
|
1107
|
+
pullDoneOther: "\u2713 {count} arquivos atualizados a partir do servidor:",
|
|
1100
1108
|
pullStructureMismatch: "A estrutura local ({local}) difere da do servidor ({remote}). Nada foi escrito. `tostudy creator push --force` torna o workspace a fonte da verdade.",
|
|
1101
1109
|
pullInvalidLesson: "O servidor devolveu li\xE7\xF5es que n\xE3o passam no contrato ({slots}). Nada foi escrito.",
|
|
1102
|
-
auditResult: "
|
|
1110
|
+
auditResult: "Checagem de formato: {verdict} \xB7 nota {score}/100",
|
|
1111
|
+
formatOnly: "Esta checagem avalia o formato, n\xE3o a qualidade do ensino.",
|
|
1103
1112
|
auditStale: "\u26A0 O conte\xFAdo mudou durante a auditoria: este relat\xF3rio j\xE1 est\xE1 desatualizado. Rode `tostudy creator audit` de novo.",
|
|
1104
1113
|
statusLine: "Status: {status} \xB7 revis\xE3o: {review}",
|
|
1105
|
-
lastAudit: "\xDAltima
|
|
1106
|
-
noAudit: "\xDAltima
|
|
1114
|
+
lastAudit: "\xDAltima checagem de formato: {verdict} \xB7 nota {score}/100",
|
|
1115
|
+
noAudit: "\xDAltima checagem de formato: nenhuma",
|
|
1116
|
+
pedagogicalNotRun: "! A revis\xE3o pedag\xF3gica n\xE3o rodou \u2014 a checagem de formato n\xE3o avalia a qualidade do ensino.",
|
|
1107
1117
|
statusReady: "\u2713 Pronto para enviar para revis\xE3o pelo portal.",
|
|
1108
1118
|
statusNotReady: "Falta para enviar para revis\xE3o:",
|
|
1109
1119
|
portal: "Portal: {url}",
|
|
@@ -1206,18 +1216,22 @@ var init_errors_en_us = __esm({
|
|
|
1206
1216
|
pathOutsideRoot: "path outside the course folder: {path}. Use only relative paths inside the workspace.",
|
|
1207
1217
|
payloadTooLarge: "course too large for one push: {bytes} bytes (limit {max}). Split modules or shorten lessons.",
|
|
1208
1218
|
validateOk: "\u2713 Workspace is valid: {lessons} lessons, {bytes} bytes.",
|
|
1209
|
-
|
|
1219
|
+
validateFailedOne: "\u2717 {count} error. Fix it and run `tostudy creator validate` again.",
|
|
1220
|
+
validateFailedOther: "\u2717 {count} errors. Fix them and run `tostudy creator validate` again.",
|
|
1210
1221
|
pushCreated: "\u2713 Course created as a draft. Review it in the portal: {url}",
|
|
1211
1222
|
pushUpdated: "\u2713 Course updated. The portal review was reset: {url}",
|
|
1212
1223
|
pushUnchanged: "\u2713 Nothing changed since the last push: {url}",
|
|
1213
|
-
|
|
1224
|
+
pullDoneOne: "\u2713 {count} file updated from the server:",
|
|
1225
|
+
pullDoneOther: "\u2713 {count} files updated from the server:",
|
|
1214
1226
|
pullStructureMismatch: "The local structure ({local}) differs from the server's ({remote}). Nothing was written. `tostudy creator push --force` makes the workspace authoritative.",
|
|
1215
1227
|
pullInvalidLesson: "The server returned lessons that do not pass the contract ({slots}). Nothing was written.",
|
|
1216
|
-
auditResult: "
|
|
1228
|
+
auditResult: "Format check: {verdict} \xB7 score {score}/100",
|
|
1229
|
+
formatOnly: "This checks format, not teaching quality.",
|
|
1217
1230
|
auditStale: "\u26A0 The content changed while the audit ran: this report is already stale. Run `tostudy creator audit` again.",
|
|
1218
1231
|
statusLine: "Status: {status} \xB7 review: {review}",
|
|
1219
|
-
lastAudit: "Last
|
|
1220
|
-
noAudit: "Last
|
|
1232
|
+
lastAudit: "Last format check: {verdict} \xB7 score {score}/100",
|
|
1233
|
+
noAudit: "Last format check: none",
|
|
1234
|
+
pedagogicalNotRun: "! Pedagogical review not run \u2014 the format check does not judge teaching quality.",
|
|
1221
1235
|
statusReady: "\u2713 Ready to submit for review from the portal.",
|
|
1222
1236
|
statusNotReady: "Still missing before submitting for review:",
|
|
1223
1237
|
portal: "Portal: {url}",
|
|
@@ -3755,8 +3769,10 @@ var loginCommand = new Command("login").description("Autentica na ToStudy via br
|
|
|
3755
3769
|
const data = createHttpProvider(apiUrl, token);
|
|
3756
3770
|
const courses = await listCourses({ userId }, { data, logger: logger4 });
|
|
3757
3771
|
if (courses.length > 0) {
|
|
3758
|
-
console.log(
|
|
3759
|
-
|
|
3772
|
+
console.log(
|
|
3773
|
+
`
|
|
3774
|
+
\u2713 ${courses.length} ${courses.length === 1 ? "curso matriculado" : "cursos matriculados"}`
|
|
3775
|
+
);
|
|
3760
3776
|
} else {
|
|
3761
3777
|
console.log(`
|
|
3762
3778
|
\u2192 Nenhum curso matriculado. Rode: tostudy suggest`);
|
|
@@ -3828,7 +3844,7 @@ import { spawn } from "node:child_process";
|
|
|
3828
3844
|
import { hostname } from "node:os";
|
|
3829
3845
|
|
|
3830
3846
|
// src/version.ts
|
|
3831
|
-
var CLI_VERSION = true ? "0.18.
|
|
3847
|
+
var CLI_VERSION = true ? "0.18.33" : "0.7.1";
|
|
3832
3848
|
|
|
3833
3849
|
// src/installer/mcp-setup.ts
|
|
3834
3850
|
function safeServerError(text2) {
|
|
@@ -4431,7 +4447,9 @@ function normalizeReadiness(raw2) {
|
|
|
4431
4447
|
const ready = typeof block["isReady"] === "boolean" ? block["isReady"] : missing.length === 0;
|
|
4432
4448
|
const audit = block["lastAudit"];
|
|
4433
4449
|
const report = audit && typeof audit === "object" ? audit : null;
|
|
4434
|
-
|
|
4450
|
+
const review = block["pedagogicalReview"];
|
|
4451
|
+
const pedagogicalReview = review === "not_run" || review === "approved" || review === "needs_revision" ? review : null;
|
|
4452
|
+
return { ready, missing, report, pedagogicalReview };
|
|
4435
4453
|
}
|
|
4436
4454
|
|
|
4437
4455
|
// src/commands/courses.ts
|
|
@@ -5317,7 +5335,9 @@ var selectCommand = new Command8("select").description("Activate a course by ID
|
|
|
5317
5335
|
if (/^\d+$/.test(course)) {
|
|
5318
5336
|
const idx = parseInt(course, 10) - 1;
|
|
5319
5337
|
if (idx < 0 || idx >= courses.length) {
|
|
5320
|
-
error(
|
|
5338
|
+
error(
|
|
5339
|
+
`\xCDndice ${course} inv\xE1lido. Voc\xEA tem ${courses.length} ${courses.length === 1 ? "curso" : "cursos"}.`
|
|
5340
|
+
);
|
|
5321
5341
|
}
|
|
5322
5342
|
const found = courses[idx];
|
|
5323
5343
|
courseId = found.courseId;
|
|
@@ -5398,7 +5418,7 @@ var selectCommand = new Command8("select").description("Activate a course by ID
|
|
|
5398
5418
|
}
|
|
5399
5419
|
const lines = [
|
|
5400
5420
|
`\u2713 Curso ativado: ${detail.courseTitle}`,
|
|
5401
|
-
` Progresso: ${detail.progress}% | ${detail.moduleCount} m\xF3dulos | ${detail.lessonCount} li\xE7\xF5es`,
|
|
5421
|
+
` Progresso: ${detail.progress}% | ${detail.moduleCount} ${detail.moduleCount === 1 ? "m\xF3dulo" : "m\xF3dulos"} | ${detail.lessonCount} ${detail.lessonCount === 1 ? "li\xE7\xE3o" : "li\xE7\xF5es"}`,
|
|
5402
5422
|
wsLine,
|
|
5403
5423
|
"",
|
|
5404
5424
|
pipelineError ? `\u26A0 Arquivos de contexto N\xC3O foram criados (${pipelineError}).
|
|
@@ -5855,7 +5875,10 @@ function formatKnowledge(data) {
|
|
|
5855
5875
|
);
|
|
5856
5876
|
return lines.join("\n");
|
|
5857
5877
|
}
|
|
5858
|
-
lines.push(
|
|
5878
|
+
lines.push(
|
|
5879
|
+
`${data.resources.length} ${data.resources.length === 1 ? "fonte" : "fontes"} de conhecimento:`,
|
|
5880
|
+
""
|
|
5881
|
+
);
|
|
5859
5882
|
data.resources.forEach((resource, idx) => {
|
|
5860
5883
|
lines.push(`${idx + 1}. ${resource.title} [${resource.type}]`, "", resource.content, "");
|
|
5861
5884
|
});
|
|
@@ -19885,6 +19908,316 @@ async function setEnrollmentLevel(input2, deps) {
|
|
|
19885
19908
|
return deps.data.exercises.setEnrollmentLevel(input2.enrollmentId, input2.level);
|
|
19886
19909
|
}
|
|
19887
19910
|
|
|
19911
|
+
// ../../packages/validators/src/file-upload.ts
|
|
19912
|
+
var FILE_SIZE_LIMITS = {
|
|
19913
|
+
/** Images: profile photos, thumbnails, etc. */
|
|
19914
|
+
IMAGE: 5 * 1024 * 1024,
|
|
19915
|
+
// 5MB
|
|
19916
|
+
/** Documents: PDFs, Word docs, text files */
|
|
19917
|
+
DOCUMENT: 10 * 1024 * 1024,
|
|
19918
|
+
// 10MB
|
|
19919
|
+
/** Videos: course content, recordings */
|
|
19920
|
+
VIDEO: 500 * 1024 * 1024,
|
|
19921
|
+
// 500MB
|
|
19922
|
+
/** Audio: podcast, voice recordings */
|
|
19923
|
+
AUDIO: 50 * 1024 * 1024,
|
|
19924
|
+
// 50MB
|
|
19925
|
+
/** Profile photos (smaller limit for optimization) */
|
|
19926
|
+
PROFILE_PHOTO: 2 * 1024 * 1024,
|
|
19927
|
+
// 2MB
|
|
19928
|
+
/** Session room attachments */
|
|
19929
|
+
SESSION_CONTENT: 10 * 1024 * 1024,
|
|
19930
|
+
// 10MB
|
|
19931
|
+
/** Knowledge base files */
|
|
19932
|
+
KNOWLEDGE_BASE: 50 * 1024 * 1024,
|
|
19933
|
+
// 50MB
|
|
19934
|
+
/** R2/S3 general uploads */
|
|
19935
|
+
R2_DEFAULT: 50 * 1024 * 1024,
|
|
19936
|
+
// 50MB
|
|
19937
|
+
/** MCP knowledge base file reading (local project files) */
|
|
19938
|
+
MCP_KNOWLEDGE_BASE: 500 * 1024,
|
|
19939
|
+
// 500KB
|
|
19940
|
+
/** Default fallback */
|
|
19941
|
+
DEFAULT: 10 * 1024 * 1024
|
|
19942
|
+
// 10MB
|
|
19943
|
+
};
|
|
19944
|
+
var ALLOWED_EXTENSIONS = {
|
|
19945
|
+
IMAGE: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
|
19946
|
+
DOCUMENT: [".pdf", ".doc", ".docx", ".xlsx", ".zip", ".txt", ".rtf", ".md", ".csv"],
|
|
19947
|
+
VIDEO: [".mp4", ".mov", ".avi", ".webm", ".flv"],
|
|
19948
|
+
AUDIO: [".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a"],
|
|
19949
|
+
SESSION_CONTENT: [".pdf", ".png", ".jpg", ".jpeg"]
|
|
19950
|
+
};
|
|
19951
|
+
var STUDY_DOCUMENT_EXTENSIONS = [".md", ".csv", ".xlsx"];
|
|
19952
|
+
var STUDY_CHAT_CONTENT_MAX_CHARS = 1e4;
|
|
19953
|
+
var ALL_ALLOWED_EXTENSIONS = [
|
|
19954
|
+
...ALLOWED_EXTENSIONS.IMAGE,
|
|
19955
|
+
...ALLOWED_EXTENSIONS.DOCUMENT,
|
|
19956
|
+
...ALLOWED_EXTENSIONS.VIDEO,
|
|
19957
|
+
...ALLOWED_EXTENSIONS.AUDIO
|
|
19958
|
+
];
|
|
19959
|
+
function isStudyDocumentExtension(filename) {
|
|
19960
|
+
const ext = getFileExtension(filename);
|
|
19961
|
+
if (!ext) return false;
|
|
19962
|
+
return STUDY_DOCUMENT_EXTENSIONS.includes(ext);
|
|
19963
|
+
}
|
|
19964
|
+
function extractStudyDocumentText(filename, buffer, maxChars = STUDY_CHAT_CONTENT_MAX_CHARS) {
|
|
19965
|
+
if (!isStudyDocumentExtension(filename)) {
|
|
19966
|
+
return { ok: false, error: "unsupported_extension" };
|
|
19967
|
+
}
|
|
19968
|
+
const ext = getFileExtension(filename);
|
|
19969
|
+
if (ext === ".xlsx") {
|
|
19970
|
+
return extractXlsxDocumentText(buffer, maxChars);
|
|
19971
|
+
}
|
|
19972
|
+
if (!buffer || buffer.includes(0)) {
|
|
19973
|
+
return { ok: false, error: "invalid_encoding" };
|
|
19974
|
+
}
|
|
19975
|
+
let decoded;
|
|
19976
|
+
try {
|
|
19977
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
19978
|
+
} catch {
|
|
19979
|
+
return { ok: false, error: "invalid_encoding" };
|
|
19980
|
+
}
|
|
19981
|
+
const normalized = decoded.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
19982
|
+
if (!normalized.trim()) {
|
|
19983
|
+
return { ok: false, error: "empty_document" };
|
|
19984
|
+
}
|
|
19985
|
+
return finalizeExtractedStudyText(normalized, maxChars);
|
|
19986
|
+
}
|
|
19987
|
+
function finalizeExtractedStudyText(normalized, maxChars) {
|
|
19988
|
+
const originalLength = normalized.length;
|
|
19989
|
+
const truncated = originalLength > maxChars;
|
|
19990
|
+
const text2 = truncated ? normalized.slice(0, maxChars) : normalized;
|
|
19991
|
+
return {
|
|
19992
|
+
ok: true,
|
|
19993
|
+
text: text2,
|
|
19994
|
+
truncated,
|
|
19995
|
+
originalLength,
|
|
19996
|
+
extractedLength: text2.length
|
|
19997
|
+
};
|
|
19998
|
+
}
|
|
19999
|
+
var MAX_XLSX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024;
|
|
20000
|
+
var MAX_XLSX_ENTRIES = 256;
|
|
20001
|
+
function getInflateRawSync() {
|
|
20002
|
+
const proc = globalThis.process;
|
|
20003
|
+
return proc?.getBuiltinModule?.("node:zlib")?.inflateRawSync;
|
|
20004
|
+
}
|
|
20005
|
+
function readZipXmlEntries(buffer) {
|
|
20006
|
+
if (!buffer || buffer.byteLength < 22) return null;
|
|
20007
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
20008
|
+
if (view.getUint32(0, true) !== 67324752) return null;
|
|
20009
|
+
let eocdOffset = -1;
|
|
20010
|
+
const minEocdOffset = Math.max(0, buffer.byteLength - 65557);
|
|
20011
|
+
for (let i = buffer.byteLength - 22; i >= minEocdOffset; i--) {
|
|
20012
|
+
if (view.getUint32(i, true) === 101010256) {
|
|
20013
|
+
eocdOffset = i;
|
|
20014
|
+
break;
|
|
20015
|
+
}
|
|
20016
|
+
}
|
|
20017
|
+
if (eocdOffset < 0) return null;
|
|
20018
|
+
const totalEntries = view.getUint16(eocdOffset + 10, true);
|
|
20019
|
+
const cdSize = view.getUint32(eocdOffset + 12, true);
|
|
20020
|
+
const cdOffset = view.getUint32(eocdOffset + 16, true);
|
|
20021
|
+
if (totalEntries === 0 || totalEntries > MAX_XLSX_ENTRIES || cdOffset + cdSize > buffer.byteLength) {
|
|
20022
|
+
return null;
|
|
20023
|
+
}
|
|
20024
|
+
const inflateRawSync = getInflateRawSync();
|
|
20025
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
20026
|
+
const entries = /* @__PURE__ */ new Map();
|
|
20027
|
+
let totalUncompressed = 0;
|
|
20028
|
+
let pos = cdOffset;
|
|
20029
|
+
for (let i = 0; i < totalEntries && pos + 46 <= cdOffset + cdSize; i++) {
|
|
20030
|
+
if (view.getUint32(pos, true) !== 33639248) return null;
|
|
20031
|
+
const compressionMethod = view.getUint16(pos + 10, true);
|
|
20032
|
+
const compressedSize = view.getUint32(pos + 20, true);
|
|
20033
|
+
const uncompressedSize = view.getUint32(pos + 24, true);
|
|
20034
|
+
const fileNameLen = view.getUint16(pos + 28, true);
|
|
20035
|
+
const extraLen = view.getUint16(pos + 30, true);
|
|
20036
|
+
const commentLen = view.getUint16(pos + 32, true);
|
|
20037
|
+
const localHeaderOffset = view.getUint32(pos + 42, true);
|
|
20038
|
+
const headerEnd = pos + 46 + fileNameLen + extraLen + commentLen;
|
|
20039
|
+
if (headerEnd > buffer.byteLength) return null;
|
|
20040
|
+
const rawName = decoder.decode(buffer.subarray(pos + 46, pos + 46 + fileNameLen));
|
|
20041
|
+
const normalizedName = rawName.replace(/^\/+/, "");
|
|
20042
|
+
pos = headerEnd;
|
|
20043
|
+
if (!normalizedName.startsWith("xl/") || !normalizedName.endsWith(".xml") && !normalizedName.endsWith(".rels")) {
|
|
20044
|
+
continue;
|
|
20045
|
+
}
|
|
20046
|
+
if (uncompressedSize > MAX_XLSX_UNCOMPRESSED_BYTES || totalUncompressed + uncompressedSize > MAX_XLSX_UNCOMPRESSED_BYTES || localHeaderOffset + 30 > buffer.byteLength) {
|
|
20047
|
+
return null;
|
|
20048
|
+
}
|
|
20049
|
+
if (view.getUint32(localHeaderOffset, true) !== 67324752) return null;
|
|
20050
|
+
const localNameLen = view.getUint16(localHeaderOffset + 26, true);
|
|
20051
|
+
const localExtraLen = view.getUint16(localHeaderOffset + 28, true);
|
|
20052
|
+
const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
20053
|
+
const dataEnd = dataStart + compressedSize;
|
|
20054
|
+
if (dataEnd > buffer.byteLength) return null;
|
|
20055
|
+
const compressedSlice = buffer.subarray(dataStart, dataEnd);
|
|
20056
|
+
let rawBytes;
|
|
20057
|
+
if (compressionMethod === 0) {
|
|
20058
|
+
rawBytes = compressedSlice;
|
|
20059
|
+
} else if (compressionMethod === 8) {
|
|
20060
|
+
if (!inflateRawSync) return null;
|
|
20061
|
+
rawBytes = inflateRawSync(compressedSlice, {
|
|
20062
|
+
maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES - totalUncompressed
|
|
20063
|
+
});
|
|
20064
|
+
} else {
|
|
20065
|
+
return null;
|
|
20066
|
+
}
|
|
20067
|
+
totalUncompressed += rawBytes.byteLength;
|
|
20068
|
+
if (totalUncompressed > MAX_XLSX_UNCOMPRESSED_BYTES) return null;
|
|
20069
|
+
entries.set(normalizedName, decoder.decode(rawBytes));
|
|
20070
|
+
}
|
|
20071
|
+
return entries;
|
|
20072
|
+
}
|
|
20073
|
+
function decodeXmlText(input2) {
|
|
20074
|
+
return input2.replace(/_x([0-9A-Fa-f]{4})_/g, (_, hex3) => String.fromCharCode(parseInt(hex3, 16))).replace(/&#x([0-9A-Fa-f]+);/g, (_, hex3) => String.fromCodePoint(parseInt(hex3, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))).replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
20075
|
+
}
|
|
20076
|
+
function extractXmlAttr(tagAttrs, attrName) {
|
|
20077
|
+
const pattern = new RegExp(`(?:^|\\s)${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`);
|
|
20078
|
+
const match = pattern.exec(tagAttrs);
|
|
20079
|
+
if (!match) return void 0;
|
|
20080
|
+
return decodeXmlText(match[1] ?? match[2] ?? "");
|
|
20081
|
+
}
|
|
20082
|
+
function collectTextNodes(xmlFragment) {
|
|
20083
|
+
const withoutPhonetic = xmlFragment.replace(/<rPh\b[^>]*>[\s\S]*?<\/rPh>/g, "");
|
|
20084
|
+
const parts = [];
|
|
20085
|
+
const tRegex = /<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g;
|
|
20086
|
+
let match;
|
|
20087
|
+
while ((match = tRegex.exec(withoutPhonetic)) !== null) {
|
|
20088
|
+
parts.push(decodeXmlText(match[1] ?? ""));
|
|
20089
|
+
}
|
|
20090
|
+
return parts.join("");
|
|
20091
|
+
}
|
|
20092
|
+
function parseSharedStrings(xml) {
|
|
20093
|
+
if (!xml) return [];
|
|
20094
|
+
const strings = [];
|
|
20095
|
+
const siRegex = /<si\b[^>]*>([\s\S]*?)<\/si>/g;
|
|
20096
|
+
let match;
|
|
20097
|
+
while ((match = siRegex.exec(xml)) !== null) {
|
|
20098
|
+
strings.push(collectTextNodes(match[1] ?? ""));
|
|
20099
|
+
}
|
|
20100
|
+
return strings;
|
|
20101
|
+
}
|
|
20102
|
+
function resolveWorkbookSheets(entries) {
|
|
20103
|
+
const workbookXml = entries.get("xl/workbook.xml");
|
|
20104
|
+
const relsXml = entries.get("xl/_rels/workbook.xml.rels");
|
|
20105
|
+
const relTargets = /* @__PURE__ */ new Map();
|
|
20106
|
+
if (relsXml) {
|
|
20107
|
+
const relRegex = /<Relationship\b([^>]*?)\/?>/g;
|
|
20108
|
+
let match;
|
|
20109
|
+
while ((match = relRegex.exec(relsXml)) !== null) {
|
|
20110
|
+
const attrs = match[1] ?? "";
|
|
20111
|
+
const id = extractXmlAttr(attrs, "Id");
|
|
20112
|
+
const target = extractXmlAttr(attrs, "Target");
|
|
20113
|
+
if (id && target) {
|
|
20114
|
+
const normalizedTarget = target.startsWith("/") ? target.replace(/^\/+/, "") : `xl/${target.replace(/^\.?\//, "")}`;
|
|
20115
|
+
relTargets.set(id, normalizedTarget);
|
|
20116
|
+
}
|
|
20117
|
+
}
|
|
20118
|
+
}
|
|
20119
|
+
const resolved = [];
|
|
20120
|
+
const usedPaths = /* @__PURE__ */ new Set();
|
|
20121
|
+
if (workbookXml) {
|
|
20122
|
+
const sheetRegex = /<sheet\b([^>]*?)\/?>/g;
|
|
20123
|
+
let match;
|
|
20124
|
+
let idx = 1;
|
|
20125
|
+
while ((match = sheetRegex.exec(workbookXml)) !== null) {
|
|
20126
|
+
const attrs = match[1] ?? "";
|
|
20127
|
+
const name = extractXmlAttr(attrs, "name") || `Sheet${idx}`;
|
|
20128
|
+
const rId = extractXmlAttr(attrs, "r:id") ?? extractXmlAttr(attrs, "id");
|
|
20129
|
+
const targetPath = (rId ? relTargets.get(rId) : void 0) ?? `xl/worksheets/sheet${idx}.xml`;
|
|
20130
|
+
const sheetXml = entries.get(targetPath);
|
|
20131
|
+
if (sheetXml) {
|
|
20132
|
+
resolved.push({ name, xml: sheetXml });
|
|
20133
|
+
usedPaths.add(targetPath);
|
|
20134
|
+
}
|
|
20135
|
+
idx++;
|
|
20136
|
+
}
|
|
20137
|
+
}
|
|
20138
|
+
if (resolved.length === 0) {
|
|
20139
|
+
const fallbackPaths = Array.from(entries.keys()).filter((k) => /^xl\/worksheets\/[^/]+\.xml$/i.test(k) && !usedPaths.has(k)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
20140
|
+
fallbackPaths.forEach((p, i) => {
|
|
20141
|
+
const xml = entries.get(p);
|
|
20142
|
+
if (xml) resolved.push({ name: `Sheet${i + 1}`, xml });
|
|
20143
|
+
});
|
|
20144
|
+
}
|
|
20145
|
+
return resolved;
|
|
20146
|
+
}
|
|
20147
|
+
function extractXlsxDocumentText(buffer, maxChars) {
|
|
20148
|
+
let entries;
|
|
20149
|
+
try {
|
|
20150
|
+
entries = readZipXmlEntries(buffer);
|
|
20151
|
+
} catch {
|
|
20152
|
+
return { ok: false, error: "invalid_encoding" };
|
|
20153
|
+
}
|
|
20154
|
+
if (!entries) {
|
|
20155
|
+
return { ok: false, error: "invalid_encoding" };
|
|
20156
|
+
}
|
|
20157
|
+
const sheets = resolveWorkbookSheets(entries);
|
|
20158
|
+
if (sheets.length === 0) {
|
|
20159
|
+
return { ok: false, error: "invalid_encoding" };
|
|
20160
|
+
}
|
|
20161
|
+
const sharedStrings = parseSharedStrings(entries.get("xl/sharedStrings.xml"));
|
|
20162
|
+
const sheetSections = [];
|
|
20163
|
+
for (const sheet of sheets) {
|
|
20164
|
+
const cellLines = [];
|
|
20165
|
+
const cellRegex = /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g;
|
|
20166
|
+
let match;
|
|
20167
|
+
let fallbackIndex = 1;
|
|
20168
|
+
while ((match = cellRegex.exec(sheet.xml)) !== null) {
|
|
20169
|
+
const attrs = match[1] ?? "";
|
|
20170
|
+
const inner = match[2] ?? "";
|
|
20171
|
+
if (!inner) continue;
|
|
20172
|
+
const ref = extractXmlAttr(attrs, "r") || `Cell${fallbackIndex++}`;
|
|
20173
|
+
const cellType = extractXmlAttr(attrs, "t");
|
|
20174
|
+
const fMatch = /<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/.exec(inner);
|
|
20175
|
+
const rawFormula = fMatch ? decodeXmlText(fMatch[1] ?? "").trim() : "";
|
|
20176
|
+
const formula = rawFormula ? rawFormula.startsWith("=") ? rawFormula : `=${rawFormula}` : "";
|
|
20177
|
+
let value = "";
|
|
20178
|
+
if (cellType === "inlineStr") {
|
|
20179
|
+
const isMatch = /<is\b[^>]*>([\s\S]*?)<\/is>/.exec(inner);
|
|
20180
|
+
value = isMatch ? collectTextNodes(isMatch[1] ?? "").trim() : "";
|
|
20181
|
+
} else {
|
|
20182
|
+
const vMatch = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(inner);
|
|
20183
|
+
const rawV = vMatch ? decodeXmlText(vMatch[1] ?? "").trim() : "";
|
|
20184
|
+
if (rawV !== "") {
|
|
20185
|
+
if (cellType === "s") {
|
|
20186
|
+
const sstIdx = Number.parseInt(rawV, 10);
|
|
20187
|
+
value = (Number.isFinite(sstIdx) ? sharedStrings[sstIdx] : void 0) ?? rawV;
|
|
20188
|
+
} else if (cellType === "b") {
|
|
20189
|
+
value = rawV === "1" ? "TRUE" : "FALSE";
|
|
20190
|
+
} else {
|
|
20191
|
+
value = rawV;
|
|
20192
|
+
}
|
|
20193
|
+
}
|
|
20194
|
+
}
|
|
20195
|
+
if (!formula && !value) continue;
|
|
20196
|
+
if (formula && value) {
|
|
20197
|
+
cellLines.push(`- ${ref}: ${formula} (valor: ${value})`);
|
|
20198
|
+
} else if (formula) {
|
|
20199
|
+
cellLines.push(`- ${ref}: ${formula}`);
|
|
20200
|
+
} else {
|
|
20201
|
+
cellLines.push(`- ${ref}: ${value}`);
|
|
20202
|
+
}
|
|
20203
|
+
}
|
|
20204
|
+
if (cellLines.length > 0) {
|
|
20205
|
+
sheetSections.push(`### Aba: ${sheet.name}
|
|
20206
|
+
${cellLines.join("\n")}`);
|
|
20207
|
+
}
|
|
20208
|
+
}
|
|
20209
|
+
const combined = sheetSections.join("\n\n").trim();
|
|
20210
|
+
if (!combined) {
|
|
20211
|
+
return { ok: false, error: "empty_document" };
|
|
20212
|
+
}
|
|
20213
|
+
return finalizeExtractedStudyText(combined, maxChars);
|
|
20214
|
+
}
|
|
20215
|
+
function getFileExtension(filename) {
|
|
20216
|
+
if (!filename || !filename.includes(".")) return null;
|
|
20217
|
+
const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
|
|
20218
|
+
return ext || null;
|
|
20219
|
+
}
|
|
20220
|
+
|
|
19888
20221
|
// src/commands/validate.ts
|
|
19889
20222
|
init_guards();
|
|
19890
20223
|
init_course_state();
|
|
@@ -20171,7 +20504,34 @@ var validateCommand = new Command16("validate").description("Validate your solut
|
|
|
20171
20504
|
jsonError("file_not_found", { message: `Arquivo n\xE3o encontrado: ${file2}` });
|
|
20172
20505
|
error(`Arquivo n\xE3o encontrado: ${file2}`);
|
|
20173
20506
|
}
|
|
20174
|
-
|
|
20507
|
+
if (path15.extname(file2).toLowerCase() === ".xlsx") {
|
|
20508
|
+
const rawBytes = fs12.readFileSync(file2);
|
|
20509
|
+
if (rawBytes.byteLength === 0 || rawBytes.byteLength > FILE_SIZE_LIMITS.DOCUMENT) {
|
|
20510
|
+
const msg = `Arquivo muito grande ou vazio: ${file2} (m\xE1ximo: 10 MB)`;
|
|
20511
|
+
if (opts.json) jsonError("file_too_large", { message: msg });
|
|
20512
|
+
error(msg);
|
|
20513
|
+
}
|
|
20514
|
+
const extracted = extractStudyDocumentText(
|
|
20515
|
+
path15.basename(file2),
|
|
20516
|
+
rawBytes,
|
|
20517
|
+
STUDY_CHAT_CONTENT_MAX_CHARS
|
|
20518
|
+
);
|
|
20519
|
+
if (!extracted.ok) {
|
|
20520
|
+
const msg = extracted.error === "empty_document" ? `Planilha vazia: ${file2}` : `Arquivo .xlsx inv\xE1lido ou corrompido: ${file2}`;
|
|
20521
|
+
if (opts.json) jsonError("invalid_xlsx", { message: msg });
|
|
20522
|
+
error(msg);
|
|
20523
|
+
}
|
|
20524
|
+
if (extracted.truncated) {
|
|
20525
|
+
process.stderr.write(
|
|
20526
|
+
`\u26A0\uFE0F O conte\xFAdo extra\xEDdo de ${path15.basename(file2)} foi truncado para o limite de ${STUDY_CHAT_CONTENT_MAX_CHARS} caracteres.
|
|
20527
|
+
|
|
20528
|
+
`
|
|
20529
|
+
);
|
|
20530
|
+
}
|
|
20531
|
+
solution = extracted.text;
|
|
20532
|
+
} else {
|
|
20533
|
+
solution = fs12.readFileSync(file2, "utf-8");
|
|
20534
|
+
}
|
|
20175
20535
|
} else {
|
|
20176
20536
|
if (opts.json)
|
|
20177
20537
|
jsonError("no_solution_provided", { message: "Forne\xE7a um arquivo ou use --stdin" });
|
|
@@ -22636,7 +22996,10 @@ function renderReport(report) {
|
|
|
22636
22996
|
return [
|
|
22637
22997
|
...report.errors.map((issue2) => `\u2717 ${issue2.path}: ${issue2.message}`),
|
|
22638
22998
|
...report.warnings.map((issue2) => `\u26A0 ${issue2.path}: ${issue2.message}`),
|
|
22639
|
-
report.ok ? fillTemplate(copy.validateOk, { lessons: report.lessonCount, bytes: report.payloadBytes }) : fillTemplate(
|
|
22999
|
+
report.ok ? fillTemplate(copy.validateOk, { lessons: report.lessonCount, bytes: report.payloadBytes }) : fillTemplate(
|
|
23000
|
+
report.errors.length === 1 ? copy.validateFailedOne : copy.validateFailedOther,
|
|
23001
|
+
{ count: report.errors.length }
|
|
23002
|
+
)
|
|
22640
23003
|
].join("\n");
|
|
22641
23004
|
}
|
|
22642
23005
|
function printReport(report, opts) {
|
|
@@ -22750,7 +23113,9 @@ creatorCommand.command("pull").description("Bring lessons edited in the portal i
|
|
|
22750
23113
|
}
|
|
22751
23114
|
output(
|
|
22752
23115
|
[
|
|
22753
|
-
fillTemplate(copy.
|
|
23116
|
+
fillTemplate(outcome.files.length === 1 ? copy.pullDoneOne : copy.pullDoneOther, {
|
|
23117
|
+
count: outcome.files.length
|
|
23118
|
+
}),
|
|
22754
23119
|
...outcome.files.map((file2) => ` ${file2}`)
|
|
22755
23120
|
].join("\n"),
|
|
22756
23121
|
{ json: false }
|
|
@@ -22761,7 +23126,10 @@ creatorCommand.command("pull").description("Bring lessons edited in the portal i
|
|
|
22761
23126
|
});
|
|
22762
23127
|
function renderAudit(result) {
|
|
22763
23128
|
const copy = getErrors().creator;
|
|
22764
|
-
const lines = [
|
|
23129
|
+
const lines = [
|
|
23130
|
+
fillTemplate(copy.auditResult, { verdict: result.verdict, score: result.score }),
|
|
23131
|
+
copy.formatOnly
|
|
23132
|
+
];
|
|
22765
23133
|
if (result.stale) lines.push(copy.auditStale);
|
|
22766
23134
|
for (const raw2 of result.findings) {
|
|
22767
23135
|
const finding = raw2 ?? {};
|
|
@@ -22813,6 +23181,7 @@ function renderStatus(remote, readiness, url2) {
|
|
|
22813
23181
|
score: readiness.report.score ?? "?"
|
|
22814
23182
|
}) : copy.noAudit
|
|
22815
23183
|
);
|
|
23184
|
+
if (readiness.pedagogicalReview === "not_run") lines.push(copy.pedagogicalNotRun);
|
|
22816
23185
|
if (readiness.ready) {
|
|
22817
23186
|
lines.push(copy.statusReady);
|
|
22818
23187
|
} else {
|
|
@@ -23613,12 +23982,17 @@ function formatGradesMarkdown(attempts) {
|
|
|
23613
23982
|
lines.push("", "**Feedback:**", "", a.feedback);
|
|
23614
23983
|
}
|
|
23615
23984
|
if (a.solution) {
|
|
23616
|
-
|
|
23985
|
+
const fence2 = fenceFor(a.solution);
|
|
23986
|
+
lines.push("", "**Solu\xE7\xE3o:**", "", fence2, a.solution, fence2);
|
|
23617
23987
|
}
|
|
23618
23988
|
});
|
|
23619
23989
|
lines.push("");
|
|
23620
23990
|
return lines.join("\n");
|
|
23621
23991
|
}
|
|
23992
|
+
function fenceFor(text2) {
|
|
23993
|
+
const longest = Math.max(0, ...(text2.match(/`+/g) ?? []).map((run) => run.length));
|
|
23994
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
23995
|
+
}
|
|
23622
23996
|
function renderTableRow(cells, widths) {
|
|
23623
23997
|
return cells.map((cell, idx) => {
|
|
23624
23998
|
const w = widths[idx] ?? cell.length;
|
|
@@ -23673,7 +24047,7 @@ function formatGradesTable(attempts, options = {}) {
|
|
|
23673
24047
|
const failed = sorted.length - passed;
|
|
23674
24048
|
return [
|
|
23675
24049
|
`Curso: ${title2}`,
|
|
23676
|
-
`Total: ${sorted.length} tentativa
|
|
24050
|
+
`Total: ${sorted.length} ${sorted.length === 1 ? "tentativa" : "tentativas"} \u2014 ${passed} ${passed === 1 ? "aprovada" : "aprovadas"}, ${failed} ${failed === 1 ? "reprovada" : "reprovadas"}`,
|
|
23677
24051
|
"",
|
|
23678
24052
|
headerLine,
|
|
23679
24053
|
sepLine,
|
|
@@ -23748,7 +24122,8 @@ function formatLessonReview(attempt, options = {}) {
|
|
|
23748
24122
|
lines.push("", "Feedback:", attempt.feedback);
|
|
23749
24123
|
}
|
|
23750
24124
|
if (attempt.solution) {
|
|
23751
|
-
|
|
24125
|
+
const fence2 = fenceFor(attempt.solution);
|
|
24126
|
+
lines.push("", "Solu\xE7\xE3o submetida:", fence2, attempt.solution, fence2);
|
|
23752
24127
|
}
|
|
23753
24128
|
return lines.join("\n");
|
|
23754
24129
|
}
|