execonvert 0.5.3 → 0.5.5

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/CHANGELOG.md CHANGED
@@ -9,6 +9,34 @@ Categorías usadas: **Añadido**, **Cambiado**, **Corregido**, **Eliminado**.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.5.5] - 2026-09-05
13
+
14
+ ### Añadido
15
+ - `execonvert inspect` muestra la versión de eXeLearning con la que se creó el
16
+ proyecto, y la incluye en su salida `--json` como `exeVersion`.
17
+ - Aviso al convertir un `.elpx` creado con una versión de eXeLearning más
18
+ reciente que la que incorpora eXeConvert: la conversión sigue adelante, pero
19
+ puede no reconocer los elementos nuevos. Los proyectos de versiones iguales o
20
+ anteriores no muestran nada, porque funcionan.
21
+
22
+ ### Corregido
23
+ - Los `.elpx` generados declaraban `exe_version` 3.0, un valor heredado de la
24
+ plantilla, cuando el runtime incorporado es el 4.0.3. Ahora declaran la
25
+ versión real, que es la que escribe eXeLearning en sus propios archivos. El
26
+ campo es informativo: nada en el runtime lo usa para tomar decisiones.
27
+
28
+ ## [0.5.4] - 2026-09-05
29
+
30
+ ### Corregido
31
+ - Una opción mal escrita con un solo guion (por ejemplo `-o salida.elpx`) se
32
+ tomaba como si fuera un archivo de entrada y se descartaba sin decir nada,
33
+ mientras la conversión escribía en su destino por defecto. Si allí ya había un
34
+ archivo con ese nombre, se sobrescribía sin aviso. Ahora se rechaza como
35
+ opción desconocida. Para elegir la salida siguen estando
36
+ `execonvert <entrada> <salida>` y `--out-dir`.
37
+ - Los archivos de entrada que se descartan por no admitir la conversión pedida
38
+ se anuncian por la salida de errores en lugar de desaparecer en silencio.
39
+
12
40
  ## [0.5.3] - 2026-09-05
13
41
 
14
42
  ### Corregido
@@ -3,16 +3,17 @@ import { globSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { stderr, stdout } from 'node:process';
5
5
  import { createI18n } from '../src/i18n.js';
6
- import { buildPrintableHtmlDocument, buildPdfBlobFromPrintableHtml, convertElpxToDocx, convertElpxToHtml, convertHtmlToDocxResult, inspectElpxPages, } from '../src/converter.js';
6
+ import { buildPrintableHtmlDocument, buildPdfBlobFromPrintableHtml, convertElpxToDocx, convertElpxToHtml, convertHtmlToDocxResult, inspectElpx, inspectElpxPages, } from '../src/converter.js';
7
7
  import { convertDocxToElpx } from '../src/docx-import.js';
8
8
  import { convertElpxToMarkdown } from '../src/elpx-markdown.js';
9
9
  import { convertElpToElpx } from '../src/legacy-elp.js';
10
10
  import { convertMarkdownToElpx } from '../src/markdown-import.js';
11
11
  import { installCliRuntime } from './runtime.js';
12
+ import { EXE_RUNTIME_VERSION, isNewerThanRuntime } from '../src/exe-runtime.js';
12
13
  import { automaticCheck, autoCheckAllowed, runUpdate, updateText } from './updates.js';
13
- const CLI_VERSION = '0.5.3'.startsWith('__')
14
+ const CLI_VERSION = '0.5.5'.startsWith('__')
14
15
  ? String(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version)
15
- : '0.5.3';
16
+ : '0.5.5';
16
17
  const cliMessages = {
17
18
  es: {
18
19
  'help.title': 'CLI de eXeConvert',
@@ -31,6 +32,8 @@ const cliMessages = {
31
32
  'help.output.docx': 'desde .docx: .elpx',
32
33
  'help.output.markdown': 'desde .md/.txt: .elpx',
33
34
  'help.option.to': 'Formato de salida para conversión múltiple (elpx, docx, md, pdf). Los archivos no compatibles se ignoran.',
35
+ 'warn.skippedInput': 'Aviso: se ignora {file}: no se puede convertir a {format}.',
36
+ 'warn.newerProject': 'Aviso: el proyecto se creó con eXeLearning {version} y esta versión incorpora la {runtime}. La conversión puede no reconocer los elementos nuevos.',
34
37
  'help.option.outDir': 'Directorio de salida (por defecto: mismo directorio que cada entrada)',
35
38
  'help.option.json': 'Imprime JSON legible por máquina',
36
39
  'help.option.pages': 'Exporta solo las referencias de página seleccionadas de un .elpx',
@@ -79,6 +82,8 @@ const cliMessages = {
79
82
  'help.output.docx': 'des de .docx: .elpx',
80
83
  'help.output.markdown': 'des de .md/.txt: .elpx',
81
84
  'help.option.to': 'Format de sortida per a conversió múltiple (elpx, docx, md, pdf). Els fitxers no compatibles s’ignoren.',
85
+ 'warn.skippedInput': 'Avís: s’ignora {file}: no es pot convertir a {format}.',
86
+ 'warn.newerProject': 'Avís: el projecte s’ha creat amb eXeLearning {version} i aquesta versió incorpora la {runtime}. La conversió pot no reconèixer els elements nous.',
82
87
  'help.option.outDir': 'Directori de sortida (per defecte: mateix directori que cada entrada)',
83
88
  'help.option.json': 'Imprimeix JSON llegible per màquines',
84
89
  'help.option.pages': 'Exporta només les referències de pàgina seleccionades d’un .elpx',
@@ -127,6 +132,8 @@ const cliMessages = {
127
132
  'help.output.docx': 'from .docx: .elpx',
128
133
  'help.output.markdown': 'from .md/.txt: .elpx',
129
134
  'help.option.to': 'Output format for batch conversion (elpx, docx, md, pdf). Incompatible files are skipped.',
135
+ 'warn.skippedInput': 'Warning: skipping {file}: it cannot be converted to {format}.',
136
+ 'warn.newerProject': 'Warning: this project was created with eXeLearning {version} and this build carries {runtime}. The conversion may not recognise its newer elements.',
130
137
  'help.option.outDir': 'Output directory (default: same directory as each input)',
131
138
  'help.option.json': 'Print machine-readable JSON',
132
139
  'help.option.pages': 'Export only selected page refs from an .elpx input',
@@ -343,6 +350,12 @@ function parseOptionFlags(args, defaults, t) {
343
350
  for (let index = 0; index < args.length; index += 1) {
344
351
  const value = args[index];
345
352
  if (!value.startsWith('--')) {
353
+ // A single dash is a mistyped option, not a file: taking it as one made
354
+ // "-o out.elpx" silently convert to the default destination instead,
355
+ // which can overwrite an existing file next to the input.
356
+ if (value.length > 1 && value.startsWith('-')) {
357
+ throw new Error(t('error.unknownOption', { value }));
358
+ }
346
359
  positionals.push(value);
347
360
  continue;
348
361
  }
@@ -539,11 +552,15 @@ function printInspectText(pages) {
539
552
  async function runInspect(inputPath, json) {
540
553
  installCliRuntime();
541
554
  const inputFile = await readInputFile(inputPath, buildMime('elpx'));
542
- const pages = buildPageRefs(await inspectElpxPages(inputFile));
555
+ const inspection = await inspectElpx(inputFile);
556
+ const pages = buildPageRefs(inspection.pages);
543
557
  if (json) {
544
- stdout.write(`${JSON.stringify({ input: resolve(inputPath), pages }, null, 2)}\n`);
558
+ stdout.write(`${JSON.stringify({ input: resolve(inputPath), exeVersion: inspection.exeVersion, pages }, null, 2)}\n`);
545
559
  return;
546
560
  }
561
+ if (inspection.exeVersion) {
562
+ stdout.write(`eXeLearning: ${inspection.exeVersion}\n`);
563
+ }
547
564
  printInspectText(pages);
548
565
  }
549
566
  async function runConvert(args) {
@@ -554,6 +571,9 @@ async function runConvert(args) {
554
571
  const inputFormat = detectFormat(inputPath, i18n.t);
555
572
  const outputFormat = detectFormat(outputPath, i18n.t);
556
573
  const inputFile = await readInputFile(inputPath, buildMime(inputFormat));
574
+ if (inputFormat === 'elpx' && !args.json) {
575
+ await warnIfNewerThanRuntime(inputFile, i18n.t);
576
+ }
557
577
  const selectedPageIds = inputFormat === 'elpx'
558
578
  ? await loadSelectedPageIds(inputFile, args.pages, args.pageIds)
559
579
  : [];
@@ -704,6 +724,21 @@ async function runConvert(args) {
704
724
  }
705
725
  throw new Error(i18n.t('error.unsupportedConversion', { input: inputFormat, output: outputFormat }));
706
726
  }
727
+ // Only newer projects are worth a warning: older ones convert fine, as every
728
+ // release from 4.0.0 on has shown. A project from a release this build does not
729
+ // know may carry elements the conversion drops without saying anything.
730
+ async function warnIfNewerThanRuntime(inputFile, t) {
731
+ try {
732
+ const { exeVersion } = await inspectElpx(inputFile);
733
+ if (isNewerThanRuntime(exeVersion)) {
734
+ stderr.write(`${t('warn.newerProject', { version: exeVersion, runtime: EXE_RUNTIME_VERSION })}\n`);
735
+ }
736
+ }
737
+ catch {
738
+ // Inspecting is only for the warning: a project that cannot be read here
739
+ // will fail with its own message further down.
740
+ }
741
+ }
707
742
  async function loadSelectedPageIds(inputFile, pageRefs, pageIds) {
708
743
  if (pageRefs.length === 0 && pageIds.length === 0) {
709
744
  return [];
@@ -767,6 +802,16 @@ async function runBatch(args) {
767
802
  return false;
768
803
  }
769
804
  });
805
+ // Skipping is deliberate for globs like "*.docx --to pdf", but doing it in
806
+ // silence hides typos: an unknown option taken as a filename disappeared
807
+ // without a word while the conversion wrote to its default destination.
808
+ if (!args.json) {
809
+ for (const inputPath of inputPaths) {
810
+ if (!eligible.includes(inputPath)) {
811
+ stderr.write(i18n.t('warn.skippedInput', { file: basename(inputPath), format: toRaw }) + '\n');
812
+ }
813
+ }
814
+ }
770
815
  let count = 0;
771
816
  for (const inputPath of eligible) {
772
817
  if (!args.json) {
@@ -1830,15 +1830,26 @@ function ensurePdfMakeFonts() {
1830
1830
  pdfMakeInitialized = true;
1831
1831
  }
1832
1832
  export async function inspectElpxPages(file) {
1833
+ return (await inspectElpx(file)).pages;
1834
+ }
1835
+ /**
1836
+ * Reads the pages and the eXeLearning release the project declares. The version
1837
+ * is what tells a caller whether the project comes from a release newer than the
1838
+ * vendored runtime, in which case the conversion may not know every element.
1839
+ */
1840
+ export async function inspectElpx(file) {
1833
1841
  const input = new Uint8Array(await file.arrayBuffer());
1834
1842
  const entries = unzipSync(input);
1835
1843
  const project = parseProject(entries);
1836
- return project.pages.map(page => ({
1837
- id: page.id,
1838
- parentId: page.parentId,
1839
- title: page.title,
1840
- depth: page.depth,
1841
- }));
1844
+ return {
1845
+ exeVersion: project.exeVersion,
1846
+ pages: project.pages.map(page => ({
1847
+ id: page.id,
1848
+ parentId: page.parentId,
1849
+ title: page.title,
1850
+ depth: page.depth,
1851
+ })),
1852
+ };
1842
1853
  }
1843
1854
  async function buildCompatibleDocx(htmlDocument) {
1844
1855
  const htmlDoc = new DOMParser().parseFromString(htmlDocument, 'text/html');
@@ -1958,6 +1969,7 @@ function parseProject(entries) {
1958
1969
  if (parserError) {
1959
1970
  throw new Error('El content.xml no se ha podido interpretar correctamente.');
1960
1971
  }
1972
+ const exeVersion = findResourceValue(xmlDoc, 'exe_version');
1961
1973
  const title = findPropertyValue(xmlDoc, 'pp_title') || 'eXeLearning';
1962
1974
  const subtitle = findPropertyValue(xmlDoc, 'pp_subtitle') || '';
1963
1975
  const language = findPropertyValue(xmlDoc, 'pp_lang') || 'es';
@@ -1969,6 +1981,7 @@ function parseProject(entries) {
1969
1981
  title,
1970
1982
  subtitle,
1971
1983
  language,
1984
+ exeVersion,
1972
1985
  pages: sortPagesHierarchically(pages),
1973
1986
  };
1974
1987
  }
@@ -4953,6 +4966,17 @@ function toDocxHeadingLevel(level) {
4953
4966
  return HeadingLevel.HEADING_6;
4954
4967
  }
4955
4968
  }
4969
+ // eXeLearning records the release that wrote the project as an odeResource.
4970
+ // It is descriptive only -- nothing in the runtime reads it to decide anything.
4971
+ function findResourceValue(xmlDoc, key) {
4972
+ const nodes = Array.from(xmlDoc.getElementsByTagName('odeResource'));
4973
+ for (const node of nodes) {
4974
+ if (getDirectText(node, 'key') === key) {
4975
+ return getDirectText(node, 'value');
4976
+ }
4977
+ }
4978
+ return null;
4979
+ }
4956
4980
  function findPropertyValue(xmlDoc, key) {
4957
4981
  const nodes = Array.from(xmlDoc.getElementsByTagName('odeProperty'));
4958
4982
  for (const node of nodes) {
@@ -2,6 +2,7 @@ import { unzipSync, zipSync } from 'fflate';
2
2
  import mammoth from 'mammoth';
3
3
  import { MathMLToLaTeX } from 'mathml-to-latex';
4
4
  import { DOMParser as XmldomParser, XMLSerializer as XmldomSerializer } from '@xmldom/xmldom';
5
+ import { EXE_RUNTIME_VERSION } from './exe-runtime.js';
5
6
  // @ts-expect-error the vendored omml2mathml ships no TypeScript declarations.
6
7
  import omml2mathml from './vendor/omml2mathml/index.js';
7
8
  import temml from 'temml';
@@ -636,7 +637,7 @@ function generateContentXml(project) {
636
637
  <odeResources>
637
638
  <odeResource><key>odeId</key><value>${escapeXml(odeId)}</value></odeResource>
638
639
  <odeResource><key>odeVersionId</key><value>${escapeXml(odeVersionId)}</value></odeResource>
639
- <odeResource><key>exe_version</key><value>3.0</value></odeResource>
640
+ <odeResource><key>exe_version</key><value>${EXE_RUNTIME_VERSION}</value></odeResource>
640
641
  </odeResources>
641
642
  <odeProperties>
642
643
  <odeProperty><key>pp_title</key><value>${escapeXml(project.title || 'Documento importado')}</value></odeProperty>
@@ -0,0 +1,29 @@
1
+ // The eXeLearning release whose runtime is vendored in this repository, kept in
2
+ // step with app/public/exelearning/runtime-source.json by
3
+ // tests/runtime-version.test.mjs, which npm run sync:exe updates.
4
+ export const EXE_RUNTIME_VERSION = '4.0.3';
5
+ /**
6
+ * Compares two eXeLearning versions. Returns true when `version` is newer than
7
+ * the vendored runtime, which is the only case worth warning about: a project
8
+ * from an older release converts fine, one from a newer release may carry
9
+ * elements this code does not know about and would drop without saying so.
10
+ */
11
+ export function isNewerThanRuntime(version) {
12
+ if (!version) {
13
+ return false;
14
+ }
15
+ const parse = (value) => value.trim().replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10));
16
+ const candidate = parse(version);
17
+ const runtime = parse(EXE_RUNTIME_VERSION);
18
+ if (candidate.some(Number.isNaN)) {
19
+ return false;
20
+ }
21
+ for (let index = 0; index < Math.max(candidate.length, runtime.length); index += 1) {
22
+ const left = candidate[index] ?? 0;
23
+ const right = runtime[index] ?? 0;
24
+ if (left !== right) {
25
+ return left > right;
26
+ }
27
+ }
28
+ return false;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "execonvert",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,9 @@
39
39
  "test:updates": "node --import tsx --test tests/updates.test.ts",
40
40
  "test:runtime": "node scripts/test-runtime.mjs",
41
41
  "test:cli": "node --test tests/cli-update.test.mjs",
42
- "test:docx": "node --test tests/docx-omml.test.mjs"
42
+ "test:docx": "node --test tests/docx-omml.test.mjs",
43
+ "test:args": "node --test tests/cli-args.test.mjs",
44
+ "test:version": "node --import tsx --test tests/runtime-version.test.mjs"
43
45
  },
44
46
  "dependencies": {
45
47
  "@resvg/resvg-js": "^2.6.2",