chocolatito-code 1.6.13 → 1.6.14

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.
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ export function rutaDeLaSesion(home = os.homedir()) {
6
+ return path.join(home, ".chocolatito", "sesion-web.json");
7
+ }
8
+ export function guardarSesion(datos, home) {
9
+ const ruta = rutaDeLaSesion(home);
10
+ fs.mkdirSync(path.dirname(ruta), { recursive: true });
11
+ fs.writeFileSync(ruta, JSON.stringify(datos, null, 2), "utf-8");
12
+ }
13
+ export function olvidarSesion(home) {
14
+ try {
15
+ fs.rmSync(rutaDeLaSesion(home), { force: true });
16
+ }
17
+ catch { }
18
+ }
19
+ /**
20
+ * Si un proceso existe.
21
+ *
22
+ * `process.kill(pid, 0)` no mata nada: pregunta. Devuelve sin error si el
23
+ * proceso esta ahi, ESRCH si no existe, y EPERM si existe pero es de otro
24
+ * usuario -que para lo que nos importa tambien es "existe"-.
25
+ */
26
+ export function procesoVivo(pid, matar = process.kill) {
27
+ if (!Number.isInteger(pid) || pid <= 0)
28
+ return false;
29
+ try {
30
+ matar(pid, 0);
31
+ return true;
32
+ }
33
+ catch (err) {
34
+ return err?.code === "EPERM";
35
+ }
36
+ }
37
+ /** La sesion de fondo, solo si de verdad sigue viva. */
38
+ export function sesionViva(home, vivo = (p) => procesoVivo(p)) {
39
+ try {
40
+ const datos = JSON.parse(fs.readFileSync(rutaDeLaSesion(home), "utf-8"));
41
+ if (!datos?.pid || !vivo(datos.pid)) {
42
+ // El archivo sobrevivio al proceso. Se limpia aqui para que el siguiente
43
+ // arranque no tenga que decidir otra vez sobre un fantasma.
44
+ olvidarSesion(home);
45
+ return null;
46
+ }
47
+ return datos;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Se relanza a si mismo, suelto del terminal.
55
+ *
56
+ * `detached` + `stdio: ignore` + `unref` son las tres piezas, y las tres hacen
57
+ * falta: sin detached el hijo muere con el padre en Unix; sin ignorar la salida
58
+ * el hijo se bloquea en cuanto llene el buffer de una tuberia que ya no lee
59
+ * nadie; y sin unref el padre se queda esperando al hijo y el terminal no vuelve.
60
+ */
61
+ export function lanzarEnFondo(argumentos, opciones = {}) {
62
+ const ejecutable = opciones.ejecutable || process.execPath;
63
+ const guion = opciones.guion || process.argv[1];
64
+ const hijo = spawn(ejecutable, [guion, ...argumentos], {
65
+ cwd: opciones.cwd || process.cwd(),
66
+ detached: true,
67
+ stdio: "ignore",
68
+ windowsHide: true,
69
+ env: { ...process.env, CHOCOLATITO_EN_FONDO: "1" },
70
+ });
71
+ hijo.unref();
72
+ return hijo.pid ?? 0;
73
+ }
74
+ /**
75
+ * Para la sesion de fondo. Devuelve que se hizo, en palabras.
76
+ *
77
+ * En Windows se mata el arbol entero con taskkill /T: el proceso de Node puede
78
+ * tener debajo un powershell.exe del control del ordenador o un Chrome del
79
+ * navegador, y matar solo al padre los deja huerfanos comiendo memoria.
80
+ */
81
+ export async function pararSesion(home, opciones = {}) {
82
+ // Quien decide si un PID vive se inyecta ARRIBA y se usa en los dos sitios:
83
+ // al leer la ficha y al comprobar despues. Con la comprobacion de verdad en la
84
+ // primera y la inyectada en la segunda, la funcion contestaba una cosa u otra
85
+ // segun si ese numero de proceso existia por casualidad en la maquina que
86
+ // corriera las pruebas.
87
+ const vivo = opciones.vivo || ((pid) => procesoVivo(pid));
88
+ const esWindows = opciones.esWindows ?? process.platform === "win32";
89
+ const sesion = sesionViva(home, vivo);
90
+ if (!sesion) {
91
+ return { ok: false, mensaje: "No hay ninguna sesion de fondo abierta." };
92
+ }
93
+ try {
94
+ if (esWindows) {
95
+ // spawnSync Y NO spawn. La primera version lanzaba taskkill sin esperarlo
96
+ // y salia con process.exit() en la linea siguiente: el hijo no llegaba a
97
+ // arrancar, el archivo de sesion se borraba, y el programa anunciaba
98
+ // "Sesion detenida" con la sesion contestando en el puerto tan tranquila.
99
+ // Se vio en la prueba de verdad: --parar decia que si, y la pagina seguia
100
+ // dando 200.
101
+ const ejecutar = opciones.ejecutar ||
102
+ ((cmd, args) => {
103
+ const r = spawnSync(cmd, args, { windowsHide: true, encoding: "utf-8" });
104
+ return { ok: r.status === 0, detalle: String(r.stderr || r.stdout || "").trim() };
105
+ });
106
+ // /T mata el arbol: debajo puede haber un powershell del control del
107
+ // ordenador o un Chrome del navegador, y matar solo al padre los deja
108
+ // huerfanos comiendo memoria.
109
+ ejecutar("taskkill", ["/pid", String(sesion.pid), "/T", "/F"]);
110
+ }
111
+ else {
112
+ const matar = opciones.matar || ((pid) => process.kill(-pid, "SIGTERM"));
113
+ matar(sesion.pid);
114
+ }
115
+ }
116
+ catch {
117
+ // Si ya no estaba, el resultado es el que buscabamos. Se comprueba abajo.
118
+ }
119
+ // No se cree lo que diga el comando: se mira si el proceso sigue ahi. Es la
120
+ // unica forma de no volver a mentir.
121
+ for (let intento = 0; intento < 20 && vivo(sesion.pid); intento++) {
122
+ await new Promise((r) => setTimeout(r, 100));
123
+ }
124
+ if (vivo(sesion.pid)) {
125
+ // El archivo se queda: la sesion existe, asi que --estado tiene que seguir
126
+ // encontrandola para poder intentarlo otra vez o matarla a mano.
127
+ return {
128
+ ok: false,
129
+ mensaje: `No se pudo detener la sesion (pid ${sesion.pid}). Sigue abierta en ${sesion.url}`,
130
+ };
131
+ }
132
+ olvidarSesion(home);
133
+ return { ok: true, mensaje: `Sesion de fondo detenida (pid ${sesion.pid}).` };
134
+ }
@@ -47,6 +47,31 @@ const PAGINA = [
47
47
  'header b { color:var(--zorro); font-weight:600; }',
48
48
  '#estado { margin-left:auto; font-size:12px; color:var(--suave); display:flex; align-items:center; gap:6px; }',
49
49
  '#punto { width:8px; height:8px; border-radius:50%; background:#d14; transition:background .2s; }',
50
+ '/* Lo que produce el agente, mirable. max-width en porcentaje y no en pixeles:',
51
+ ' en un movil en vertical caben 300px justos y una pieza de 1080 rompe el ancho. */',
52
+ '.mini { margin:6px 0; }',
53
+ '/* El enlace de guardar, pegado bajo la miniatura y con sitio para el dedo. */',
54
+ '.guardar { display:inline-flex; align-items:center; gap:6px; margin-top:4px; padding:6px 12px;',
55
+ ' background:var(--caja); border:1px solid var(--borde); border-radius:999px;',
56
+ ' color:var(--texto); font-size:12px; text-decoration:none; }',
57
+ '.guardar:active { border-color:var(--zorro); }',
58
+ '.mini img, .mini video { max-width:min(100%, 420px); max-height:320px; border-radius:8px;',
59
+ ' border:1px solid var(--borde); background:#000; display:block; }',
60
+ '/* Lo que esta a punto de subir, en fila sobre la caja de escribir. */',
61
+ '#adjuntos { display:flex; gap:6px; overflow-x:auto; padding:6px 12px 0; }',
62
+ '#adjuntos:empty { display:none; }',
63
+ '.chip { display:flex; align-items:center; gap:6px; flex:0 0 auto; max-width:60%;',
64
+ ' background:var(--caja); border:1px solid var(--borde); border-radius:999px; padding:4px 10px; font-size:12px; }',
65
+ '.chip img { width:22px; height:22px; object-fit:cover; border-radius:4px; }',
66
+ '.chip span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
67
+ '.chip b { color:var(--suave); cursor:pointer; font-weight:400; padding:0 2px; }',
68
+ '#clip { background:none; border:1px solid var(--borde); color:var(--suave); border-radius:10px;',
69
+ ' font-size:18px; line-height:1; padding:0 12px; cursor:pointer; }',
70
+ '/* El velo de arrastrar. pointer-events:none evita el parpadeo: si el velo',
71
+ ' recibe los eventos, al aparecer dispara dragleave y se va solo. */',
72
+ '#suelta { position:fixed; inset:0; display:none; place-items:center; z-index:20;',
73
+ ' background:rgba(11,11,13,.88); color:var(--zorro); font-size:16px; pointer-events:none; }',
74
+ '#suelta.visible { display:grid; }',
50
75
  '#punto.ok { background:#2ea043; }',
51
76
  '#salida { flex:1; overflow-y:auto; overflow-x:auto; padding:12px; white-space:pre; -webkit-overflow-scrolling:touch; }',
52
77
  '#salida .fila { min-height:1.5em; }',
@@ -84,10 +109,14 @@ const PAGINA = [
84
109
  '<header><b>Chocolatito</b><span class="d" style="font-size:12px">Code</span>',
85
110
  '<span id="estado"><span id="punto"></span><span id="estadoTexto">conectando</span></span></header>',
86
111
  '<div id="salida"></div>',
112
+ '<div id="adjuntos"></div>',
87
113
  '<form id="envio">',
114
+ '<input id="fichero" type="file" multiple style="display:none">',
115
+ '<button id="clip" type="button" title="Adjuntar archivos">+</button>',
88
116
  '<textarea id="texto" rows="1" placeholder="Escribele lo que quieres que haga" autocapitalize="sentences" autocomplete="off"></textarea>',
89
117
  '<button id="enviar" type="submit">Ir</button>',
90
118
  '</form>',
119
+ '<div id="suelta">Suelta aqui los archivos</div>',
91
120
  '<div id="velo"><div id="permiso">',
92
121
  '<h2 id="permisoTitulo">Pide permiso</h2>',
93
122
  '<p id="permisoDetalle"></p>',
@@ -144,6 +173,102 @@ const PAGINA = [
144
173
  ' }',
145
174
  ' return html || "&nbsp;";',
146
175
  ' }',
176
+ ' // MIRAR, NO SOLO LEER LA RUTA',
177
+ ' //',
178
+ ' // Sin esto, cuando el agente exporta una imagen contesta con la ruta. Delante',
179
+ ' // del PC se abre la carpeta; en un movil no puedes mirarlo, asi que no puedes',
180
+ ' // opinar, asi que no puedes trabajar. La ruta se detecta aqui y el permiso lo',
181
+ ' // da el servidor: /archivo solo sirve lo que este dentro de la carpeta.',
182
+ ' var EXT_VISIBLE = /^(png|jpe?g|webp|gif|bmp|avif|svg|mp4|webm|mov)$/i;',
183
+ ' // Todo lo que en una linea tiene pinta de ruta: con separador y con punto.',
184
+ ' function rutasDelTexto(crudo) {',
185
+ ' var limpio = String(crudo).replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g, "");',
186
+ ' var encontradas = [];',
187
+ ' var trozos = limpio.split(/[\\s"\\u0027`<>|()\\[\\]]+/);',
188
+ ' for (var i = 0; i < trozos.length; i++) {',
189
+ ' var t = trozos[i].replace(/[.,;:]+$/, "");',
190
+ ' if (t.indexOf("/") < 0 && t.indexOf("\\\\") < 0) continue;',
191
+ ' if (t.lastIndexOf(".") < 0) continue;',
192
+ ' if (encontradas.indexOf(t) < 0) encontradas.push(t);',
193
+ ' if (encontradas.length >= 6) break;',
194
+ ' }',
195
+ ' return encontradas;',
196
+ ' }',
197
+ ' function extensionDe(ruta) {',
198
+ ' var punto = String(ruta).lastIndexOf(".");',
199
+ ' return punto < 0 ? "" : ruta.slice(punto + 1);',
200
+ ' }',
201
+ ' function rutasVisibles(crudo) {',
202
+ ' return rutasDelTexto(crudo).filter(function (r) { return EXT_VISIBLE.test(extensionDe(r)); }).slice(0, 4);',
203
+ ' }',
204
+ ' // Las que no se pueden ensenar pero si llevarse: el ZIP, el PDF, el CSV.',
205
+ ' function rutasGuardables(crudo, yaVistas) {',
206
+ ' return rutasDelTexto(crudo)',
207
+ ' .filter(function (r) { return yaVistas.indexOf(r) < 0 && EXT_GUARDABLE.test(extensionDe(r)); })',
208
+ ' .slice(0, 3);',
209
+ ' }',
210
+ ' // GUARDARLO, QUE ES LA ULTIMA MILLA',
211
+ ' //',
212
+ ' // Ver la pieza en el movil no termina el trabajo: el diseno sale del PC para',
213
+ ' // ir a un cliente, y ese cliente esta en el WhatsApp del telefono. Sin esto,',
214
+ ' // el circuito se queda a un paso: recibes por WhatsApp, procesas en el PC,',
215
+ ' // ves el resultado... y tienes que ir al ordenador a por el archivo.',
216
+ ' //',
217
+ ' // Y no solo imagenes: el ZIP del entregable, el PDF, el CSV y los subtitulos',
218
+ ' // no tienen miniatura que ensenar, asi que sin un enlace no existen para el',
219
+ ' // movil. Para esos se pregunta antes con HEAD si el archivo esta de verdad,',
220
+ ' // para no ofrecer un enlace roto sobre cualquier palabra con un punto.',
221
+ ' var EXT_GUARDABLE = /^(pdf|zip|rar|7z|csv|txt|md|srt|vtt|json|docx|xlsx|pptx|psd|ai|svg|mp3|wav|ogg|mp4|webm|mov|png|jpe?g|webp|gif)$/i;',
222
+ ' function urlDe(ruta, descargar) {',
223
+ ' return "/archivo?t=" + encodeURIComponent(token) + "&ruta=" + encodeURIComponent(ruta) +',
224
+ ' (descargar ? "&descargar=1" : "");',
225
+ ' }',
226
+ ' function nombreDe(ruta) {',
227
+ ' var trozos = String(ruta).split(/[\\\\/]/);',
228
+ ' return trozos[trozos.length - 1] || ruta;',
229
+ ' }',
230
+ ' function enlaceDeGuardar(ruta) {',
231
+ ' var a = document.createElement("a");',
232
+ ' a.className = "guardar";',
233
+ ' a.href = urlDe(ruta, true);',
234
+ ' a.setAttribute("download", nombreDe(ruta));',
235
+ ' a.textContent = "\u2913 Guardar " + nombreDe(ruta);',
236
+ ' return a;',
237
+ ' }',
238
+ ' // Un archivo sin miniatura: se ofrece solo si existe de verdad.',
239
+ ' function quizaDescargable(ruta, donde) {',
240
+ ' var punto = String(ruta).lastIndexOf(".");',
241
+ ' if (punto < 0 || !EXT_GUARDABLE.test(ruta.slice(punto + 1))) return;',
242
+ ' fetch(urlDe(ruta, false), { method: "HEAD" }).then(function (r) {',
243
+ ' if (!r.ok) return;',
244
+ ' donde.appendChild(enlaceDeGuardar(ruta));',
245
+ ' }).catch(function () {});',
246
+ ' }',
247
+ ' function rutasGuardables(crudo, yaVistas) {',
248
+ ' var todas = rutasDelTexto(crudo);',
249
+ ' var fuera = [];',
250
+ ' for (var i = 0; i < todas.length; i++) {',
251
+ ' if (yaVistas.indexOf(todas[i]) < 0) fuera.push(todas[i]);',
252
+ ' }',
253
+ ' return fuera.slice(0, 3);',
254
+ ' }',
255
+ ' function miniatura(ruta) {',
256
+ ' var url = "/archivo?t=" + encodeURIComponent(token) + "&ruta=" + encodeURIComponent(ruta);',
257
+ ' var caja = document.createElement("div");',
258
+ ' caja.className = "mini";',
259
+ ' var esVideo = /(mp4|webm|mov)$/i.test(ruta);',
260
+ ' var vista = document.createElement(esVideo ? "video" : "img");',
261
+ ' vista.src = url;',
262
+ ' if (esVideo) { vista.controls = true; vista.preload = "metadata"; }',
263
+ ' // Si no se puede servir -fuera de la carpeta, borrado, o es una ruta que',
264
+ ' // solo lo parecia- no se deja el icono de imagen rota: se quita la caja.',
265
+ ' vista.onerror = function () { try { caja.parentNode.removeChild(caja); } catch (e) {} };',
266
+ ' // El enlace solo aparece cuando la imagen ha cargado: si el archivo no se',
267
+ ' // puede servir, la caja entera se va y no queda un boton que no guarda nada.',
268
+ ' vista.onload = function () { caja.appendChild(enlaceDeGuardar(ruta)); };',
269
+ ' caja.appendChild(vista);',
270
+ ' return caja;',
271
+ ' }',
147
272
  ' function pintar(lineas) {',
148
273
  ' if (!lineas || !lineas.length) return;',
149
274
  ' var pegado = alFinal();',
@@ -153,12 +278,67 @@ const PAGINA = [
153
278
  ' div.className = "fila";',
154
279
  ' div.innerHTML = ansi(lineas[i].texto);',
155
280
  ' frag.appendChild(div);',
281
+ ' var vistas = rutasVisibles(lineas[i].texto);',
282
+ ' for (var v = 0; v < vistas.length; v++) frag.appendChild(miniatura(vistas[v]));',
283
+ ' var otras = rutasGuardables(lineas[i].texto, vistas);',
284
+ ' if (otras.length) {',
285
+ ' var fila = document.createElement("div");',
286
+ ' fila.className = "mini";',
287
+ ' frag.appendChild(fila);',
288
+ ' for (var g = 0; g < otras.length; g++) quizaDescargable(otras[g], fila);',
289
+ ' }',
156
290
  ' if (lineas[i].id > ultimoId) ultimoId = lineas[i].id;',
157
291
  ' }',
158
292
  ' salida.appendChild(frag);',
159
293
  ' while (salida.childNodes.length > 3000) salida.removeChild(salida.firstChild);',
160
294
  ' if (pegado) salida.scrollTop = salida.scrollHeight;',
161
295
  ' }',
296
+ ' // AVISAR AL MOVIL, SIN SER PESADO',
297
+ ' //',
298
+ ' // Sin esto, la sesion web solo sirve si estas mirandola. Dejas una tarea de',
299
+ ' // cinco minutos, te vas, y acabas sacando el telefono cada treinta segundos',
300
+ ' // por si acaso. Que es exactamente lo que hace inutil trabajar desde el movil.',
301
+ ' //',
302
+ ' // TRES DECISIONES:',
303
+ ' //',
304
+ ' // 1. SOLO SI NO ESTAS MIRANDO. Con la pagina delante, una notificacion de',
305
+ ' // algo que ya ves en pantalla es ruido, y el ruido se acaba silenciando',
306
+ ' // entero, tambien el aviso que si importaba.',
307
+ ' // 2. EL PERMISO SE PIDE AL ENVIAR, NO AL CARGAR. Chrome ignora -y Safari',
308
+ ' // rechaza- una peticion de notificaciones que no venga de un gesto del',
309
+ ' // usuario. Pedirlo al abrir la pagina es gastar la unica oportunidad.',
310
+ ' // 3. EL TITULO TAMBIEN AVISA. Si las notificaciones estan denegadas, el',
311
+ ' // punto en la pestana es lo unico que queda, y se ve igual de bien.',
312
+ ' var tituloNormal = document.title;',
313
+ ' var avisoPuesto = false;',
314
+ ' function puedeAvisar() {',
315
+ ' return typeof Notification !== "undefined" && Notification.permission === "granted";',
316
+ ' }',
317
+ ' function pedirPermisoDeAviso() {',
318
+ ' if (typeof Notification === "undefined" || Notification.permission !== "default") return;',
319
+ ' try { Notification.requestPermission(); } catch (e) {}',
320
+ ' }',
321
+ ' function marcarTitulo(si) {',
322
+ ' avisoPuesto = si;',
323
+ ' document.title = si ? "\u25cf " + tituloNormal : tituloNormal;',
324
+ ' }',
325
+ ' function avisar(titulo, cuerpo, urgente) {',
326
+ ' // Mirando la pagina no se avisa de nada: ya lo estas viendo.',
327
+ ' if (!document.hidden) return;',
328
+ ' marcarTitulo(true);',
329
+ ' if (navigator.vibrate) {',
330
+ ' // Dos pulsos para un permiso -que te esta esperando- y uno para el final.',
331
+ ' try { navigator.vibrate(urgente ? [120, 80, 120] : [90]); } catch (e) {}',
332
+ ' }',
333
+ ' if (!puedeAvisar()) return;',
334
+ ' try {',
335
+ ' var n = new Notification(titulo, { body: cuerpo, tag: "chocolatito", renotify: true });',
336
+ ' n.onclick = function () { try { window.focus(); } catch (e) {} try { n.close(); } catch (e) {} };',
337
+ ' } catch (e) {}',
338
+ ' }',
339
+ ' document.addEventListener("visibilitychange", function () {',
340
+ ' if (!document.hidden && avisoPuesto) marcarTitulo(false);',
341
+ ' });',
162
342
  ' function conectado(si, etiqueta) {',
163
343
  ' punto.className = si ? "ok" : "";',
164
344
  ' estadoTexto.textContent = etiqueta;',
@@ -171,6 +351,7 @@ const PAGINA = [
171
351
  ' peticionActual = null;',
172
352
  ' }',
173
353
  ' function abrirPermiso(p) {',
354
+ ' avisar("Chocolatito te pide permiso", (p.detalle && p.detalle[0]) || p.herramienta, true);',
174
355
  ' peticionActual = p;',
175
356
  ' document.getElementById("permisoTitulo").textContent = "Permiso: " + p.herramienta;',
176
357
  ' document.getElementById("permisoDetalle").textContent = (p.detalle || []).join("\\n");',
@@ -220,6 +401,7 @@ const PAGINA = [
220
401
  ' pintar(m.lineas);',
221
402
  ' }',
222
403
  ' else if (m.t === "permiso") abrirPermiso(m);',
404
+ ' else if (m.t === "turno") avisar("Chocolatito ha terminado", "Te toca a ti.", false);',
223
405
  ' else if (m.t === "cierre") { velo.className = ""; peticionActual = null; }',
224
406
  ' else if (m.t === "fin") { terminada = true; conectado(false, "sesion terminada"); try { ws.close(); } catch (e) {} }',
225
407
  ' };',
@@ -233,11 +415,93 @@ const PAGINA = [
233
415
  ' };',
234
416
  ' ws.onerror = function () { try { ws.close(); } catch (e) {} };',
235
417
  ' }',
418
+ ' // LOS ARCHIVOS QUE ENTRAN',
419
+ ' //',
420
+ ' // Tres gestos, porque son tres sitios distintos: el boton y arrastrar (PC), y',
421
+ ' // PEGAR, que es el mas usado de los tres y el que casi nadie pone: una captura',
422
+ ' // de pantalla se pega, no se guarda antes en una carpeta para luego buscarla.',
423
+ ' var adjuntos = [];',
424
+ ' var tiraAdjuntos = document.getElementById("adjuntos");',
425
+ ' var fichero = document.getElementById("fichero");',
426
+ ' var suelta = document.getElementById("suelta");',
427
+ ' function pintarAdjuntos() {',
428
+ ' tiraAdjuntos.innerHTML = "";',
429
+ ' for (var i = 0; i < adjuntos.length; i++) {',
430
+ ' (function (a) {',
431
+ ' var chip = document.createElement("div");',
432
+ ' chip.className = "chip";',
433
+ ' if (a.vista) { var im = document.createElement("img"); im.src = a.vista; chip.appendChild(im); }',
434
+ ' var nom = document.createElement("span");',
435
+ ' nom.textContent = a.subiendo ? a.nombre + "\u2026" : a.nombre;',
436
+ ' chip.appendChild(nom);',
437
+ ' var x = document.createElement("b");',
438
+ ' x.textContent = "\u00d7";',
439
+ ' x.onclick = function () { adjuntos.splice(adjuntos.indexOf(a), 1); pintarAdjuntos(); };',
440
+ ' chip.appendChild(x);',
441
+ ' tiraAdjuntos.appendChild(chip);',
442
+ ' })(adjuntos[i]);',
443
+ ' }',
444
+ ' }',
445
+ ' function adjuntar(archivos) {',
446
+ ' for (var i = 0; i < archivos.length; i++) {',
447
+ ' (function (archivo) {',
448
+ ' var entrada = {',
449
+ ' nombre: archivo.name || "pegado.png",',
450
+ ' subiendo: true,',
451
+ ' ruta: "",',
452
+ ' vista: /^image\\//.test(archivo.type) ? URL.createObjectURL(archivo) : ""',
453
+ ' };',
454
+ ' adjuntos.push(entrada);',
455
+ ' pintarAdjuntos();',
456
+ ' fetch("/subir?t=" + encodeURIComponent(token) + "&nombre=" + encodeURIComponent(entrada.nombre), {',
457
+ ' method: "POST",',
458
+ ' body: archivo',
459
+ ' }).then(function (r) { return r.json(); }).then(function (j) {',
460
+ ' if (j && j.ok) { entrada.ruta = j.ruta; entrada.subiendo = false; }',
461
+ ' else {',
462
+ ' adjuntos.splice(adjuntos.indexOf(entrada), 1);',
463
+ ' pintar([{ id: 0, texto: "\u001b[33m No se pudo subir " + entrada.nombre + ": " + ((j && j.error) || "error") + "\u001b[39m" }]);',
464
+ ' }',
465
+ ' pintarAdjuntos();',
466
+ ' }).catch(function () {',
467
+ ' adjuntos.splice(adjuntos.indexOf(entrada), 1);',
468
+ ' pintarAdjuntos();',
469
+ ' });',
470
+ ' })(archivos[i]);',
471
+ ' }',
472
+ ' }',
473
+ ' document.getElementById("clip").onclick = function () { fichero.click(); };',
474
+ ' fichero.onchange = function () { adjuntar(fichero.files); fichero.value = ""; };',
475
+ ' document.addEventListener("paste", function (ev) {',
476
+ ' var datos = ev.clipboardData;',
477
+ ' if (!datos || !datos.files || !datos.files.length) return;',
478
+ ' ev.preventDefault();',
479
+ ' adjuntar(datos.files);',
480
+ ' });',
481
+ ' var arrastres = 0;',
482
+ ' document.addEventListener("dragenter", function (ev) { ev.preventDefault(); arrastres++; suelta.className = "visible"; });',
483
+ ' document.addEventListener("dragover", function (ev) { ev.preventDefault(); });',
484
+ ' document.addEventListener("dragleave", function () { if (--arrastres <= 0) { arrastres = 0; suelta.className = ""; } });',
485
+ ' document.addEventListener("drop", function (ev) {',
486
+ ' ev.preventDefault();',
487
+ ' arrastres = 0;',
488
+ ' suelta.className = "";',
489
+ ' if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) adjuntar(ev.dataTransfer.files);',
490
+ ' });',
236
491
  ' document.getElementById("envio").onsubmit = function (ev) {',
237
492
  ' ev.preventDefault();',
238
493
  ' var valor = texto.value.trim();',
239
- ' if (!valor || !ws || ws.readyState !== 1) return;',
240
- ' ws.send(JSON.stringify({ t: "entrada", texto: valor }));',
494
+ ' var listos = [];',
495
+ ' for (var i = 0; i < adjuntos.length; i++) if (adjuntos[i].ruta) listos.push(adjuntos[i].ruta);',
496
+ ' // Se puede mandar SOLO archivos: subir cinco fotos y no escribir nada es una',
497
+ ' // orden perfectamente clara para el que la recibe.',
498
+ ' if ((!valor && !listos.length) || !ws || ws.readyState !== 1) return;',
499
+ ' // Aqui, y no al cargar: Chrome ignora una peticion de notificaciones que no',
500
+ ' // venga de un gesto del usuario, y pedirla al abrir gasta la unica que hay.',
501
+ ' pedirPermisoDeAviso();',
502
+ ' ws.send(JSON.stringify({ t: "entrada", texto: valor, archivos: listos }));',
503
+ ' adjuntos = [];',
504
+ ' pintarAdjuntos();',
241
505
  ' texto.value = "";',
242
506
  ' texto.style.height = "auto";',
243
507
  ' salida.scrollTop = salida.scrollHeight;',
@@ -3,6 +3,19 @@ export declare function servirActivo(): boolean;
3
3
  export declare function marcarServirActivo(valor: boolean): void;
4
4
  /** Llega algo del navegador. */
5
5
  export declare function entregarEntrada(texto: string): void;
6
+ /**
7
+ * QUIEN AVISA AL MOVIL DE QUE YA PUEDE MIRAR
8
+ *
9
+ * El navegador sabia cuando le preguntaban algo -el permiso llega por su
10
+ * canal- pero no sabia cuando el agente TERMINABA. Y ese es justo el momento
11
+ * que importa cuando no estas delante: dejas una tarea de cinco minutos, te vas,
12
+ * y vuelves a mirar el telefono cada treinta segundos por si acaso.
13
+ *
14
+ * El momento no hay que inventarlo ni medirlo: es exactamente cuando el agente
15
+ * se pone a esperar lo que escribas. Ni un evento nuevo ni un temporizador.
16
+ */
17
+ type AvisoDeTurno = () => void;
18
+ export declare function fijarAvisoDeTurno(fn: AvisoDeTurno | null): void;
6
19
  export declare function esperarEntradaWeb(): Promise<string>;
7
20
  export declare function hayEntradaEnCola(): boolean;
8
21
  export interface PeticionDePermiso {
@@ -48,10 +48,20 @@ export function entregarEntrada(texto) {
48
48
  // vez de perderse. Es lo que ya hace el type-ahead del terminal.
49
49
  cola.push(texto);
50
50
  }
51
+ let avisarDelTurno = null;
52
+ export function fijarAvisoDeTurno(fn) {
53
+ avisarDelTurno = fn;
54
+ }
51
55
  export function esperarEntradaWeb() {
52
56
  const pendiente = cola.shift();
57
+ // Con algo en la cola NO se avisa: el usuario ya habia escrito mientras el
58
+ // agente trabajaba, asi que no ha terminado nada para el, sigue la faena.
53
59
  if (pendiente !== undefined)
54
60
  return Promise.resolve(pendiente);
61
+ try {
62
+ avisarDelTurno?.();
63
+ }
64
+ catch { }
55
65
  return new Promise((resolve) => {
56
66
  esperando = resolve;
57
67
  });
@@ -106,6 +116,7 @@ export function permisosAbiertos() {
106
116
  }
107
117
  /** Solo para pruebas. */
108
118
  export function vaciarPuente() {
119
+ avisarDelTurno = null;
109
120
  cola.length = 0;
110
121
  esperando = null;
111
122
  emisor = null;
@@ -36,6 +36,12 @@ export interface OpcionesServidor {
36
36
  enRed?: boolean;
37
37
  /** Para pruebas: token fijo en vez de uno aleatorio. */
38
38
  token?: string;
39
+ /**
40
+ * La carpeta de trabajo, leida en cada peticion y no copiada al arrancar.
41
+ * Con /cd el agente se cambia de carpeta a mitad de sesion, y lo que se sube
42
+ * tiene que caer donde se esta trabajando AHORA, no donde se empezo.
43
+ */
44
+ carpeta?: () => string;
39
45
  }
40
46
  export interface ServidorWeb {
41
47
  puerto: number;