tablewise 1.1.0__py3-none-any.whl

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.
tablewise_cli.py ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """CLI de Tablewise: un documento -> un .md con cabecera de procedencia.
3
+
4
+ Reutiliza exactamente las mismas decisiones que la app de escritorio
5
+ (`tablewise_core.validar_conversion` y `tablewise_core.construir_comando`), para
6
+ que el .md producido por la terminal y el producido por la ventana sean
7
+ idénticos: mismo nombre saneado, mismo motor, mismo destino y misma
8
+ cabecera YAML.
9
+
10
+ Motores: se invocan como subproceso con un intérprete de Python aparte
11
+ (el de los motores, que trae docling/openpyxl). Ese intérprete se elige,
12
+ por orden, con `--python`, la variable `TABLEWISE_PYTHON` o el propio
13
+ intérprete del CLI. MarkItDown se elige con `--markitdown`, la variable
14
+ `TABLEWISE_MARKITDOWN` o el `markitdown` del PATH.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import datetime as dt
20
+ import importlib.metadata
21
+ import importlib.util
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+ import tablewise_core
30
+ import preconversion
31
+
32
+ EXIT_OK = 0
33
+ EXIT_FALLO_CONVERSION = 1
34
+ EXIT_ENTRADA_INVALIDA = 2
35
+
36
+
37
+ def _version() -> str:
38
+ """Versión instalada, o la del pyproject si se ejecuta desde el repo.
39
+
40
+ Una sola fuente de verdad: hardcodearla aquí la dejaba derivar del
41
+ empaquetado en el primer cambio de versión."""
42
+ try:
43
+ return importlib.metadata.version("tablewise")
44
+ except importlib.metadata.PackageNotFoundError:
45
+ return "1.1.0"
46
+
47
+
48
+ def _ruta_modulo(nombre: str) -> Path:
49
+ """Ruta al fichero de un motor, instalado o ejecutado desde el repo."""
50
+ spec = importlib.util.find_spec(nombre)
51
+ if spec is None or not spec.origin:
52
+ raise SystemExit(f"Tablewise: no encuentro el módulo {nombre}.py")
53
+ return Path(spec.origin)
54
+
55
+
56
+ def _python_motores(argumento: str | None) -> Path:
57
+ elegido = argumento or os.environ.get("TABLEWISE_PYTHON")
58
+ if elegido:
59
+ return Path(elegido).expanduser()
60
+ return Path(sys.executable)
61
+
62
+
63
+ def _markitdown(argumento: str | None) -> str:
64
+ elegido = argumento or os.environ.get("TABLEWISE_MARKITDOWN")
65
+ if elegido:
66
+ return str(Path(elegido).expanduser())
67
+ encontrado = shutil.which("markitdown")
68
+ if not encontrado:
69
+ raise SystemExit(
70
+ "Tablewise: no encuentro markitdown. Instálalo (`pip install markitdown[all]`), "
71
+ "pásalo con --markitdown o define TABLEWISE_MARKITDOWN."
72
+ )
73
+ return encontrado
74
+
75
+
76
+ def construir_parser() -> argparse.ArgumentParser:
77
+ p = argparse.ArgumentParser(
78
+ prog="tablewise",
79
+ description="Convierte un documento (PDF, Word, Excel, PowerPoint) a Markdown "
80
+ "con cabecera de procedencia, listo para un RAG.",
81
+ )
82
+ p.add_argument("documento", help="Documento de origen")
83
+ p.add_argument("-o", "--salida", default="~/md-repo",
84
+ help="Carpeta donde guardar el .md (por defecto ~/md-repo)")
85
+ p.add_argument("-n", "--nombre", default="",
86
+ help="Nombre del .md sin extensión (por defecto, el del documento)")
87
+ p.add_argument("--python", default=None,
88
+ help="Intérprete de Python para los motores (por defecto, el del CLI)")
89
+ p.add_argument("--markitdown", default=None,
90
+ help="Ruta al ejecutable markitdown (por defecto, el del PATH)")
91
+ p.add_argument("--force", action="store_true",
92
+ help="Sobrescribir el .md si ya existe (por defecto no se pisa nada)")
93
+ p.add_argument("--dry-run", action="store_true",
94
+ help="Solo imprime el comando que se ejecutaría")
95
+ p.add_argument("-q", "--quiet", action="store_true", help="Sin mensajes de progreso")
96
+ p.add_argument("--version", action="version", version=f"tablewise {_version()}")
97
+ return p
98
+
99
+
100
+ def convertir(documento: str, salida: str, nombre: str = "", *, python: str | None = None,
101
+ markitdown: str | None = None, dry_run: bool = False, force: bool = False,
102
+ quiet: bool = False, fecha: dt.date | None = None) -> Path:
103
+ """Convierte un documento y devuelve la ruta del .md generado.
104
+
105
+ Lanza SystemExit(2) si la entrada no es válida o el .md ya existe y no
106
+ se pidió --force, y RuntimeError si el motor falla: el CLI no adivina
107
+ nada, no pisa trabajo previo y no deja un .md a medias.
108
+ """
109
+ hay_soffice = preconversion.buscar_soffice() is not None
110
+ validacion = tablewise_core.validar_conversion(documento, nombre, salida, hay_soffice=hay_soffice)
111
+ if validacion.error:
112
+ titulo, mensaje = validacion.error
113
+ raise SystemExit(f"Tablewise: {titulo}: {mensaje}")
114
+ origen = validacion.origen
115
+ destino = validacion.destino
116
+ assert origen is not None and destino is not None
117
+
118
+ motor = tablewise_core.elegir_motor(origen)
119
+ repo = destino.parent
120
+ if destino.exists() and not force and not dry_run:
121
+ raise SystemExit(
122
+ f"Tablewise: ya existe {destino}. Usa otro --nombre, otra carpeta o --force para sobrescribirlo.")
123
+
124
+ python_motores = _python_motores(python)
125
+ # Con MarkItDown no se usa repo_motores (el comando es otro programa).
126
+ repo_motores = _ruta_modulo(motor).parent if motor else repo
127
+ markitdown_cmd = _markitdown(markitdown)
128
+
129
+ if dry_run:
130
+ cmd = tablewise_core.construir_comando(
131
+ origen, destino, python_motores=python_motores, repo_dir=repo_motores,
132
+ markitdown=markitdown_cmd,
133
+ )
134
+ print(tablewise_core.comando_shell(cmd))
135
+ return destino
136
+
137
+ repo.mkdir(parents=True, exist_ok=True)
138
+ if not quiet:
139
+ print(f"Convirtiendo {origen.name} con {motor or 'markitdown'}…", file=sys.stderr)
140
+
141
+ with tempfile.TemporaryDirectory(prefix=".convirtiendo-", dir=repo) as carpeta:
142
+ temporal = Path(carpeta) / destino.name
143
+ cmd = tablewise_core.construir_comando(
144
+ origen, temporal, python_motores=python_motores, repo_dir=repo_motores,
145
+ markitdown=markitdown_cmd,
146
+ )
147
+ try:
148
+ resultado = subprocess.run(cmd, capture_output=True, text=True, check=False)
149
+ except OSError as e:
150
+ raise RuntimeError(f"no se pudo lanzar el motor ({python_motores}): {e}") from e
151
+ if resultado.returncode != 0 or not temporal.exists():
152
+ detalle = (resultado.stderr or resultado.stdout or "").strip()[:1500]
153
+ raise RuntimeError(f"el motor {motor or 'markitdown'} no generó {destino.name}.\n{detalle}")
154
+ try:
155
+ tablewise_core.anadir_frontmatter(temporal, origen, motor or "markitdown",
156
+ fecha or dt.date.today())
157
+ except (OSError, UnicodeError) as e:
158
+ if not quiet:
159
+ print(f"Tablewise: aviso: sin cabecera de procedencia ({e})", file=sys.stderr)
160
+ os.replace(temporal, destino)
161
+
162
+ if not quiet:
163
+ print(destino)
164
+ return destino
165
+
166
+
167
+ def main(argv: list[str] | None = None) -> int:
168
+ args = construir_parser().parse_args(argv)
169
+ try:
170
+ convertir(args.documento, args.salida, args.nombre, python=args.python,
171
+ markitdown=args.markitdown, dry_run=args.dry_run, force=args.force,
172
+ quiet=args.quiet)
173
+ except SystemExit as e:
174
+ if not isinstance(e.code, int):
175
+ print(e.code, file=sys.stderr)
176
+ return EXIT_ENTRADA_INVALIDA
177
+ return e.code
178
+ except RuntimeError as e:
179
+ print(f"Tablewise: {e}", file=sys.stderr)
180
+ return EXIT_FALLO_CONVERSION
181
+ return EXIT_OK
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
tablewise_core.py ADDED
@@ -0,0 +1,394 @@
1
+ """
2
+ Lógica pura de Tablewise -- sin Tkinter ni subprocess, para poder probarla
3
+ con pytest en cualquier Python (el de Homebrew puede venir sin _tkinter).
4
+ tablewise_app.py solo pinta la ventana y delega aquí las decisiones.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import datetime as dt
9
+ import os
10
+ import re
11
+ import shlex
12
+ import unicodedata
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ # Enrutamiento por tipo de documento (piloto 23/08/2026, ver README.md
17
+ # "Piloto de alternativas gratis"): un Excel nativo trae la estructura de
18
+ # celdas combinadas accesible directamente (motor_excel, sin IA, exacto);
19
+ # PDF/Word/PowerPoint necesitan que algo reconstruya la tabla visualmente
20
+ # (motor_docling, gratis, local); el resto sigue por MarkItDown, que ya
21
+ # cubre bien lo que no tiene tablas complejas que reconstruir.
22
+ # Las plantillas (.xltx/.xltm) son el mismo formato OOXML: openpyxl las lee
23
+ # igual y MarkItDown no las reconoce.
24
+ EXTENSIONES_EXCEL = {".xlsx", ".xlsm", ".xltx", ".xltm"}
25
+ EXTENSIONES_DOCLING = {".pdf", ".docx", ".pptx"}
26
+ # Word antiguo, RTF y OpenDocument: Docling no los lee y MarkItDown
27
+ # tampoco (solo .docx). motor_docling los pasa antes a HTML con textutil,
28
+ # que viene con macOS (gratis, sin instalar nada).
29
+ EXTENSIONES_TEXTUTIL = {".doc", ".rtf", ".odt"}
30
+
31
+ # Office 97-2003 binario: MarkItDown no tiene conversor para .ppt y para
32
+ # .xls necesita xlrd, que no está instalado -- antes el diálogo los
33
+ # ofrecía y la conversión fallaba (hallazgo de careo, MEDIA, 04/10). Con
34
+ # LibreOffice instalado (gratis), preconversion.py los pasa a .xlsx (para
35
+ # motor_excel: conserva combinadas) y .pptx (para Docling).
36
+ EXTENSIONES_SOFFICE = {".xls": "motor_excel", ".ppt": "motor_docling"}
37
+
38
+ _SIN_LIBREOFFICE = {
39
+ ".ppt": "PowerPoint 97-2003 (.ppt) necesita LibreOffice para convertirse, "
40
+ "y no está instalado. Instálalo (gratis, y reabre esta app) o abre el fichero en "
41
+ "PowerPoint o Keynote y guárdalo como .pptx.",
42
+ ".xls": "Excel 97-2003 (.xls) necesita LibreOffice para convertirse, y "
43
+ "no está instalado. Instálalo (gratis, y reabre esta app) o abre el fichero en Excel "
44
+ "o Numbers y guárdalo como .xlsx.",
45
+ }
46
+
47
+
48
+ def motivo_no_soportado(origen: Path, hay_soffice: bool) -> str | None:
49
+ """Mensaje para el usuario si el formato no se puede convertir con lo
50
+ instalado en el equipo, o None."""
51
+ if hay_soffice:
52
+ return None
53
+ return _SIN_LIBREOFFICE.get(Path(origen).suffix.lower())
54
+
55
+
56
+ def tipos_dialogo(hay_soffice: bool = False) -> list[tuple[str, str]]:
57
+ """filetypes del diálogo "Elegir documento", derivado de las mismas
58
+ extensiones que enrutan: a mano se había quedado sin .xlsm (y sin las
59
+ plantillas), que solo se podían elegir con "Todos los ficheros".
60
+ .xls/.ppt solo si hay LibreOffice para convertirlos."""
61
+ def patrones(*exts):
62
+ return " ".join(f"*{e}" for e in sorted(exts))
63
+
64
+ excel = set(EXTENSIONES_EXCEL)
65
+ powerpoint = {".pptx"}
66
+ if hay_soffice:
67
+ excel.add(".xls")
68
+ powerpoint.add(".ppt")
69
+ word = {".docx"} | EXTENSIONES_TEXTUTIL
70
+ return [
71
+ ("Documentos soportados", patrones(".pdf", *word, *excel, *powerpoint)),
72
+ ("PDF", patrones(".pdf")),
73
+ ("Word", patrones(*word)),
74
+ ("Excel", patrones(*excel)),
75
+ ("PowerPoint", patrones(*powerpoint)),
76
+ ("Todos los ficheros", "*.*"),
77
+ ]
78
+
79
+
80
+ def elegir_motor(origen: Path) -> str | None:
81
+ """Nombre del motor propio que toca según la extensión, o None si va
82
+ por el motor por defecto (MarkItDown)."""
83
+ ext = origen.suffix.lower()
84
+ if ext in EXTENSIONES_SOFFICE:
85
+ return EXTENSIONES_SOFFICE[ext]
86
+ if ext in EXTENSIONES_EXCEL:
87
+ return "motor_excel"
88
+ if ext in EXTENSIONES_DOCLING or ext in EXTENSIONES_TEXTUTIL:
89
+ return "motor_docling"
90
+ return None
91
+
92
+
93
+ # Marcas y controles de dirección (LRM/RLM, embeddings, overrides,
94
+ # isolates): un RLO hace que "informe\u202efdp.exe" se VEA como
95
+ # "informeexe.pdf" en Finder. El resto de caracteres de formato (Cf) se
96
+ # respeta: el ZWJ forma parte de emojis compuestos.
97
+ _BIDI = set("\u200e\u200f\u061c") | {chr(c) for c in range(0x202A, 0x202F)} \
98
+ | {chr(c) for c in range(0x2066, 0x206A)}
99
+
100
+
101
+ def es_bloqueo_office(origen: Path) -> bool:
102
+ """¿Es el fichero de bloqueo "~$nombre.xlsx" que Word/Excel/PowerPoint
103
+ dejan junto al documento mientras está abierto? Pesa ~165 bytes, no es
104
+ un documento: se enrutaba al motor y fallaba con un error críptico."""
105
+ return Path(origen).name.startswith("~$")
106
+
107
+
108
+ # APFS/ext4 admiten 255 bytes por componente de ruta. El nombre tiene que
109
+ # caber con ".md", el "-N" de ruta_libre y el ".<nombre>.tmp" de
110
+ # escribir_atomico: 200 deja margen de sobra. Sin tope, un nombre largo
111
+ # (o con muchos emojis, 4 bytes cada uno) fallaba con "File name too long".
112
+ MAX_BYTES_NOMBRE = 200
113
+
114
+
115
+ # Caracteres que no son Cf ni espacio pero se ven en blanco: rellenos de
116
+ # Hangul y la celda braille vacía.
117
+ _RELLENOS = set("\u3164\u115f\u1160\uffa0\u2800")
118
+
119
+
120
+ def _invisible(ch: str) -> bool:
121
+ """¿Se ve en blanco? Espacios Unicode (NBSP, U+3000...), formato (Cf:
122
+ ZWSP, BOM, guion blando...), rellenos, o una marca combinante suelta."""
123
+ return (ch.isspace() or ch in _RELLENOS
124
+ or unicodedata.category(ch) in ("Cf", "Mn", "Me"))
125
+
126
+
127
+ def _limpia_extremos(limpio: str, quitar_md: bool) -> str:
128
+ """Quita puntos e invisibles de los dos extremos y, si `quitar_md`, una
129
+ extensión ".md" final (la app la añade). En bucle: "informe\u00a0.md"
130
+ deja un NBSP final al quitar ".md", y "\u200b\u3000.informe" esconde un
131
+ punto inicial detrás de dos invisibles (hallazgos de careo, BAJA, 04/10)."""
132
+ def recorta_final(t):
133
+ # Sin tocar marcas combinantes: al final van unidas a su carácter
134
+ # (U+FE0F de "❤️", el keycap de "1️⃣"); solo se quitan al principio,
135
+ # donde van sueltas.
136
+ while t and (t[-1] == "." or (_invisible(t[-1])
137
+ and unicodedata.category(t[-1]) not in ("Mn", "Me"))):
138
+ t = t[:-1]
139
+ return t
140
+
141
+ # El final antes que el principio: ".md" a secas debe quedar en "", no
142
+ # perder su punto inicial y convertirse en un fichero llamado "md".
143
+ limpio = recorta_final(limpio)
144
+ if quitar_md and limpio.lower().endswith(".md"):
145
+ limpio = recorta_final(limpio[:-3])
146
+ while limpio and (limpio[0] == "." or _invisible(limpio[0])):
147
+ limpio = limpio[1:]
148
+ return limpio
149
+
150
+
151
+ def nombre_seguro(nombre: str) -> str | None:
152
+ """Nombre de fichero sin componentes de ruta, o None si no queda nada
153
+ utilizable. .name descarta separadores, ".." intermedios y rutas
154
+ absolutas pegadas por error -- sin esto, "../../otro" escribiría fuera
155
+ del repo (hallazgo de careo, ALTA, 23/08). Path("..").name es ".." --
156
+ se rechaza explícitamente."""
157
+ # Fuera los caracteres de control (Cc), separadores de línea Unicode
158
+ # (Zl/Zp) y controles bidi: un NUL hacía que open() lanzara
159
+ # ValueError, y un salto pegado desde una celda o un correo acababa
160
+ # dentro del nombre del fichero.
161
+ nombre = "".join(ch for ch in nombre
162
+ if unicodedata.category(ch) not in ("Cc", "Zl", "Zp")
163
+ and ch not in _BIDI)
164
+ # NFC después de filtrar (un control entre letra y tilde impedía
165
+ # componerlas): un nombre pegado desde Finder llega en NFD y el recorte
166
+ # por bytes podía separar la letra de su tilde.
167
+ nombre = unicodedata.normalize("NFC", nombre)
168
+ # "\" también separa: una ruta de Windows pegada en el nombre
169
+ # ("C:\docs\informe") se quedaba entera, con barras, en macOS.
170
+ limpio = Path(nombre.strip().replace("\\", "/")).name
171
+ # Sin puntos ni invisibles en los extremos: ".informe.md" queda oculto
172
+ # en Finder ("\u200b.informe" lo parece) y muchos indexadores RAG se
173
+ # saltan los ficheros punto; "." y ".." quedan en "". La app añade
174
+ # ".md": si el usuario ya la tecleó, "informe.md" acababa en
175
+ # "informe.md.md" -- se quita UNA, sin distinguir mayúsculas.
176
+ limpio = _limpia_extremos(limpio, quitar_md=True)
177
+ # Recorte por BYTES UTF-8 (no por caracteres) sin partir un carácter.
178
+ cortado = limpio.encode("utf-8")[:MAX_BYTES_NOMBRE].decode("utf-8", "ignore")
179
+ if cortado != limpio:
180
+ # El corte puede dejar ".md" (acabaría en ".md.md"), un espacio,
181
+ # una tilde o un ZWJ sueltos al final.
182
+ while True:
183
+ antes = cortado
184
+ cortado = _limpia_extremos(cortado, quitar_md=True)
185
+ if cortado == antes:
186
+ break
187
+ limpio = cortado
188
+ if not limpio or limpio in (".", ".."):
189
+ return None
190
+ return limpio
191
+
192
+
193
+ def nombre_salida(tecleado: str, documento: Path) -> str | None:
194
+ """Nombre del .md (sin extensión): el tecleado, o el del documento si
195
+ no se tecleó nada -- saneado en ambos casos. Lo usan convert() y
196
+ copy_cli() para que no calculen el nombre cada uno a su manera (copy_cli
197
+ caía al stem del documento sin sanear: ".x.pdf" daba un ".x.md"
198
+ oculto). Un nombre tecleado inválido da None, no se sustituye en
199
+ silencio por el del documento."""
200
+ if tecleado.strip():
201
+ return nombre_seguro(tecleado)
202
+ return nombre_seguro(Path(documento).stem)
203
+
204
+
205
+ def construir_comando(origen: Path, destino: Path, *, python_motores: Path,
206
+ repo_dir: Path, markitdown: str) -> list[str]:
207
+ """Lista argv para convertir `origen` en `destino`. La usan tanto
208
+ convert() como copy_cli(), para que el comando copiado sea exactamente
209
+ el que ejecuta la app. Expande "~" en todas las rutas: shlex.join la
210
+ entrecomillaría y la shell ya no la expandiría."""
211
+ def ruta(p) -> str:
212
+ try:
213
+ return str(Path(p).expanduser())
214
+ except RuntimeError: # "~usuario" que no existe: se deja tal cual
215
+ return str(p)
216
+
217
+ motor = elegir_motor(Path(origen))
218
+ if motor:
219
+ return [ruta(python_motores), ruta(Path(repo_dir) / f"{motor}.py"),
220
+ ruta(origen), ruta(destino)]
221
+ return [ruta(markitdown), ruta(origen), "-o", ruta(destino)]
222
+
223
+
224
+ def comando_shell(cmd: list[str]) -> str:
225
+ """argv → texto pegable en una shell. shlex.join escapa comillas,
226
+ backticks, $() y espacios de verdad (hallazgo de careo, MEDIA, 23/08)."""
227
+ return shlex.join(cmd)
228
+
229
+
230
+ # Solo NUESTRA cabecera exacta: un documento de origen con su propio
231
+ # frontmatter que empiece por "source:" no se toca (hallazgo de careo,
232
+ # MEDIA, 04/10: una detección por prefijo borraba contenido legítimo).
233
+ _NUESTRO_FRONTMATTER = re.compile(
234
+ r'\A---\nsource: "(?:[^"\\\n]|\\.)*"\nengine: "(?:[^"\\\n]|\\.)*"\n'
235
+ r'converted: \d{4}-\d{2}-\d{2}\n---\n\n?'
236
+ )
237
+
238
+
239
+ def _yaml_str(texto: str) -> str:
240
+ r"""Escalar YAML entre comillas dobles. Escapa como \uXXXX todo lo que
241
+ YAML no admite tal cual o reinterpreta (controles C0/C1, DEL, NEL,
242
+ separadores de línea, BOM, no-caracteres): con json.dumps a secas un
243
+ nombre con \x85 o \x7f rompía o alteraba la cabecera."""
244
+ salida = []
245
+ for ch in texto:
246
+ cp = ord(ch)
247
+ if ch == '"':
248
+ salida.append('\\"')
249
+ elif ch == "\\":
250
+ salida.append("\\\\")
251
+ elif (cp < 0x20 or 0x7F <= cp <= 0x9F or cp in (0x2028, 0x2029, 0xFEFF)
252
+ or 0xD800 <= cp <= 0xDFFF or cp in (0xFFFE, 0xFFFF)):
253
+ salida.append(f"\\u{cp:04x}")
254
+ else:
255
+ salida.append(ch)
256
+ return '"' + "".join(salida) + '"'
257
+
258
+
259
+ def con_frontmatter(md: str, origen: Path, engine: str, fecha: dt.date) -> str:
260
+ """Antepone una cabecera YAML de procedencia (fichero de origen, motor,
261
+ fecha) para que el RAG pueda citar y filtrar por fuente. Solo el nombre
262
+ del fichero, nunca la ruta: la ruta local no aporta al RAG y filtraría
263
+ la estructura de carpetas del equipo. Si el texto ya trae una cabecera
264
+ nuestra, se sustituye, no se apila."""
265
+ # Un BOM inicial (algunos conversores lo escriben) quedaba incrustado
266
+ # detrás de la cabecera, y escondía una cabecera nuestra previa al
267
+ # regex anclado en \A: se apilaban dos.
268
+ md = md.lstrip("\ufeff")
269
+ md = _NUESTRO_FRONTMATTER.sub("", md, count=1)
270
+ cabecera = (
271
+ "---\n"
272
+ f"source: {_yaml_str(Path(origen).name)}\n"
273
+ f"engine: {_yaml_str(engine)}\n"
274
+ f"converted: {fecha.isoformat()}\n"
275
+ "---\n\n"
276
+ )
277
+ return cabecera + md
278
+
279
+
280
+ def escribir_atomico(destino: Path, texto: str) -> None:
281
+ """Escribe a un temporal del mismo directorio y lo renombra: write_text
282
+ directo trunca primero, y un fallo a mitad (disco lleno, surrogate
283
+ suelto que no codifica en UTF-8) dejaba vacío o a medias un .md que
284
+ ya estaba bien (hallazgos de careo, BAJA, 04/10). Crea la carpeta."""
285
+ destino.parent.mkdir(parents=True, exist_ok=True)
286
+ temporal = destino.with_name(f".{destino.name}.tmp")
287
+ try:
288
+ temporal.write_text(texto, encoding="utf-8")
289
+ os.replace(temporal, destino)
290
+ finally:
291
+ temporal.unlink(missing_ok=True)
292
+
293
+
294
+ def anadir_frontmatter(destino: Path, origen: Path, engine: str, fecha: dt.date) -> None:
295
+ """Reescribe `destino` (ya generado por el motor) con la cabecera."""
296
+ md = destino.read_text(encoding="utf-8")
297
+ escribir_atomico(destino, con_frontmatter(md, origen, engine, fecha))
298
+
299
+
300
+ def misma_ruta(a: Path, b: Path) -> bool:
301
+ """¿Apuntan `a` y `b` al mismo fichero (alias, "..", symlink, o
302
+ mayúsculas en un disco que no las distingue)? Si destino == origen, el
303
+ motor machacaba el documento original con el Markdown."""
304
+ try:
305
+ return os.path.samefile(Path(a).expanduser(), Path(b).expanduser())
306
+ except OSError: # alguno no existe: comparar rutas resueltas
307
+ return Path(a).expanduser().resolve() == Path(b).expanduser().resolve()
308
+
309
+
310
+ def ruta_libre(repo: Path, nombre: str) -> Path:
311
+ """repo/nombre.md, o nombre-2.md, nombre-3.md... el primero libre. Para
312
+ ofrecer "guardar como copia" en vez de pisar un .md existente (que
313
+ puede llevar retoques a mano o ya estar indexado en el RAG)."""
314
+ candidata = repo / f"{nombre}.md"
315
+ n = 2
316
+ while candidata.exists():
317
+ candidata = repo / f"{nombre}-{n}.md"
318
+ n += 1
319
+ return candidata
320
+
321
+
322
+ def _ruta_de_campo(valor) -> Path:
323
+ """Ruta escrita o pegada en un campo. Se recortan los espacios de los
324
+ extremos (una ruta pegada suele traerlos) salvo que la ruta tal cual
325
+ exista: macOS admite nombres como "Informe .pdf"."""
326
+ def expandir(texto):
327
+ try:
328
+ return Path(texto).expanduser()
329
+ except RuntimeError: # "~usuario" que no existe: se deja tal cual
330
+ return Path(texto)
331
+
332
+ tal_cual = expandir(str(valor))
333
+ return tal_cual if tal_cual.exists() else expandir(str(valor).strip())
334
+
335
+
336
+ @dataclass
337
+ class Validacion:
338
+ destino: Path | None = None
339
+ origen: Path | None = None # ruta ya recortada y expandida
340
+ nombre: str | None = None
341
+ error: tuple[str, str] | None = None # (título, mensaje) para el diálogo
342
+
343
+
344
+ def validar_conversion(origen: str | Path, tecleado: str, repo: str | Path, *, hay_soffice: bool,
345
+ comprobar_existe: bool = True) -> Validacion:
346
+ """Todas las comprobaciones previas a convertir, en un solo sitio: las
347
+ usan "Convertir" y "Copiar comando CLI". Antes copy_cli no aplicaba
348
+ ninguna y copiaba comandos que escribían encima del origen, aceptaban
349
+ "~$x.xlsx" o cambiaban en silencio un nombre ".." por "salida"
350
+ (hallazgo de careo, BAJA, 04/10). `comprobar_existe=False` para la
351
+ plantilla de comando que se copia sin documento elegido."""
352
+ # Acepta el texto tal cual de los campos: Convertir y Copiar recortaban
353
+ # (o no) los espacios cada uno a su manera, y "~usuario" inexistente
354
+ # hacía reventar expanduser() (hallazgos de careo, BAJA, 04/10).
355
+ origen = _ruta_de_campo(origen)
356
+ repo = _ruta_de_campo(repo)
357
+ if comprobar_existe and origen.is_dir():
358
+ return Validacion(error=(
359
+ "Error",
360
+ f"{origen.name} es una carpeta (o un paquete de macOS, como los "
361
+ "de Pages/Keynote), no un documento.",
362
+ ))
363
+ if comprobar_existe and not origen.is_file():
364
+ return Validacion(error=("Error", "El documento no existe o no lo has seleccionado bien."))
365
+ if repo.exists() and not repo.is_dir():
366
+ return Validacion(error=(
367
+ "Carpeta repo inválida",
368
+ f"{repo} es un fichero, no una carpeta. Elige la carpeta donde guardar el .md.",
369
+ ))
370
+ if es_bloqueo_office(origen):
371
+ return Validacion(error=(
372
+ "No es un documento",
373
+ f"{origen.name} es el fichero de bloqueo que deja Office mientras "
374
+ "el documento está abierto. Elige el documento original, el que "
375
+ "no empieza por \"~$\" (Word puede recortar sus primeras letras "
376
+ "en el nombre del bloqueo).",
377
+ ))
378
+ motivo = motivo_no_soportado(origen, hay_soffice)
379
+ if motivo:
380
+ return Validacion(error=("Formato no soportado", motivo))
381
+ nombre = nombre_salida(tecleado, origen)
382
+ if nombre is None:
383
+ return Validacion(error=(
384
+ "Nombre inválido",
385
+ "El nombre del .md no puede estar vacío ni ser solo una ruta (\".\"/\"..\").",
386
+ ))
387
+ destino = Path(repo) / f"{nombre}.md"
388
+ if misma_ruta(origen, destino):
389
+ return Validacion(error=(
390
+ "Destino igual al origen",
391
+ f"{destino.name} es el propio documento de origen: convertirlo "
392
+ "ahí lo sobrescribiría. Cambia el nombre del .md o la carpeta.",
393
+ ))
394
+ return Validacion(destino=destino, nombre=nombre, origen=origen)