diff options
| author | Kyle Javier [kj_sh604] | 2026-02-13 23:52:44 -0500 |
|---|---|---|
| committer | GitHub | 2026-02-13 23:52:44 -0500 |
| commit | 8e3f2cf9efab60629d920da96a9d49c1fc20cbe3 (patch) | |
| tree | aa20fcf16f9ebf2e231e5a3864164e527c5ca61e | |
| parent | bf3c2e1c9b5f2fd22de39c55769af239277a819a (diff) | |
| parent | eaf6b4a594c9a42076caf5766d08b40019a77b78 (diff) | |
[merge] pull request #1 from kj-sh604/feat/demoware
feat: demoware, docker
| -rw-r--r-- | Dockerfile | 40 | ||||
| -rw-r--r-- | demoware/.gitignore | 2 | ||||
| -rw-r--r-- | demoware/index.html | 297 | ||||
| -rw-r--r-- | demoware/server.py | 210 |
4 files changed, 549 insertions, 0 deletions
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8fe61b3 --- /dev/null +++ b/Dockerfile | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | FROM ubuntu:24.04 | ||
| 2 | |||
| 3 | ENV PYTHONUNBUFFERED=1 | ||
| 4 | |||
| 5 | # system deps + libreoffice fresh ppa | ||
| 6 | RUN apt-get update && \ | ||
| 7 | apt-get install -y --no-install-recommends \ | ||
| 8 | software-properties-common \ | ||
| 9 | ca-certificates && \ | ||
| 10 | add-apt-repository -y ppa:libreoffice/ppa && \ | ||
| 11 | apt-get update && \ | ||
| 12 | apt-get install -y --no-install-recommends \ | ||
| 13 | python3 \ | ||
| 14 | python3-pip \ | ||
| 15 | python3-venv \ | ||
| 16 | libreoffice \ | ||
| 17 | poppler-utils \ | ||
| 18 | fonts-liberation \ | ||
| 19 | fonts-dejavu-core && \ | ||
| 20 | apt-get clean && \ | ||
| 21 | rm -rf /var/lib/apt/lists/* | ||
| 22 | |||
| 23 | # python deps | ||
| 24 | COPY src/requirements.txt /tmp/requirements.txt | ||
| 25 | RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt | ||
| 26 | |||
| 27 | # kjandoc binary -> /usr/local/bin | ||
| 28 | COPY src/kjandoc /usr/local/bin/kjandoc | ||
| 29 | RUN chmod +x /usr/local/bin/kjandoc | ||
| 30 | |||
| 31 | # demoware | ||
| 32 | WORKDIR /app | ||
| 33 | COPY demoware/ /app/ | ||
| 34 | |||
| 35 | # storage dirs | ||
| 36 | RUN mkdir -p /app/uploads /app/output | ||
| 37 | |||
| 38 | EXPOSE 8080 | ||
| 39 | |||
| 40 | CMD ["python3", "server.py"] \ No newline at end of file | ||
diff --git a/demoware/.gitignore b/demoware/.gitignore new file mode 100644 index 0000000..390f45d --- /dev/null +++ b/demoware/.gitignore | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | uploads/ | ||
| 2 | output/ | ||
diff --git a/demoware/index.html b/demoware/index.html new file mode 100644 index 0000000..95c6847 --- /dev/null +++ b/demoware/index.html | |||
| @@ -0,0 +1,297 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>slide merging demoware</title> | ||
| 7 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.min.css"> | ||
| 8 | <style> | ||
| 9 | .file-item { | ||
| 10 | display: flex; | ||
| 11 | align-items: center; | ||
| 12 | gap: 0.5rem; | ||
| 13 | padding: 0.3rem 0; | ||
| 14 | } | ||
| 15 | .file-item button { | ||
| 16 | padding: 0.15rem 0.5rem; | ||
| 17 | margin: 0; | ||
| 18 | font-size: 0.85rem; | ||
| 19 | min-width: 2rem; | ||
| 20 | } | ||
| 21 | .file-item .fname { | ||
| 22 | flex: 1; | ||
| 23 | font-family: var(--font-family, monospace); | ||
| 24 | } | ||
| 25 | #drop-zone { | ||
| 26 | border: 2px dashed #555; | ||
| 27 | padding: 2rem; | ||
| 28 | text-align: center; | ||
| 29 | border-radius: 6px; | ||
| 30 | margin: 1rem 0; | ||
| 31 | cursor: pointer; | ||
| 32 | transition: border-color 0.15s, background 0.15s; | ||
| 33 | } | ||
| 34 | #drop-zone.over { | ||
| 35 | border-color: #7ec8e3; | ||
| 36 | background: rgba(126, 200, 227, 0.06); | ||
| 37 | } | ||
| 38 | #command-display { | ||
| 39 | white-space: pre-wrap; | ||
| 40 | word-break: break-all; | ||
| 41 | } | ||
| 42 | .hidden { display: none !important; } | ||
| 43 | .running { color: #ff9800; } | ||
| 44 | .done { color: #4caf50; } | ||
| 45 | .error { color: #f44336; } | ||
| 46 | #log-output { | ||
| 47 | max-height: 300px; | ||
| 48 | overflow-y: auto; | ||
| 49 | font-size: 0.85rem; | ||
| 50 | } | ||
| 51 | footer { margin-top: 2rem; } | ||
| 52 | </style> | ||
| 53 | </head> | ||
| 54 | <body> | ||
| 55 | <h1>an attempt at a .pptx merging tool</h1> | ||
| 56 | <p>merge multiple <code>.pptx</code> files into a <em>single</em> <code>.pptx</code> file.</p> | ||
| 57 | <hr> | ||
| 58 | |||
| 59 | <h2>1. add files</h2> | ||
| 60 | <div id="drop-zone"> | ||
| 61 | drop <code>.pptx</code> files here — or click to browse | ||
| 62 | </div> | ||
| 63 | <input type="file" id="file-input" multiple accept=".pptx" style="margin-top:0.5rem;"> | ||
| 64 | |||
| 65 | <h2>2. merge order</h2> | ||
| 66 | <p id="empty-msg"><em>no files added yet.</em></p> | ||
| 67 | <div id="file-list"></div> | ||
| 68 | |||
| 69 | <h2>3. merge</h2> | ||
| 70 | <button id="merge-btn" disabled>merge presentations</button> | ||
| 71 | |||
| 72 | <div id="cmd-section" class="hidden"> | ||
| 73 | <h3><code>kjandoc</code> command being used:</h3> | ||
| 74 | <pre id="command-display"></pre> | ||
| 75 | </div> | ||
| 76 | |||
| 77 | <div id="status-section" class="hidden"> | ||
| 78 | <p id="status-msg"></p> | ||
| 79 | <pre id="log-output" class="hidden"></pre> | ||
| 80 | </div> | ||
| 81 | |||
| 82 | <div id="dl-section" class="hidden"> | ||
| 83 | <a id="dl-link"><button>⬇ download merged presentation</button></a> | ||
| 84 | </div> | ||
| 85 | |||
| 86 | <footer> | ||
| 87 | <small>powered with <a href="https://github.com/kj-sh604/kjandoc" target="_blank" rel="noopener">kjandoc</a> by <a href="https://github.com/kj-sh604" target="_blank" rel="noopener">kj_sh604</a></small> | ||
| 88 | </footer> | ||
| 89 | |||
| 90 | <script> | ||
| 91 | (() => { | ||
| 92 | 'use strict'; | ||
| 93 | |||
| 94 | // -- state -- | ||
| 95 | let files = []; // {id, name, file} | ||
| 96 | let nextId = 1; | ||
| 97 | let busy = false; | ||
| 98 | |||
| 99 | // -- dom refs -- | ||
| 100 | const dropZone = document.getElementById('drop-zone'); | ||
| 101 | const fileInput = document.getElementById('file-input'); | ||
| 102 | const fileList = document.getElementById('file-list'); | ||
| 103 | const emptyMsg = document.getElementById('empty-msg'); | ||
| 104 | const mergeBtn = document.getElementById('merge-btn'); | ||
| 105 | const cmdSec = document.getElementById('cmd-section'); | ||
| 106 | const cmdPre = document.getElementById('command-display'); | ||
| 107 | const statusSec = document.getElementById('status-section'); | ||
| 108 | const statusMsg = document.getElementById('status-msg'); | ||
| 109 | const logPre = document.getElementById('log-output'); | ||
| 110 | const dlSec = document.getElementById('dl-section'); | ||
| 111 | const dlLink = document.getElementById('dl-link'); | ||
| 112 | |||
| 113 | // -- file input -- | ||
| 114 | fileInput.addEventListener('change', () => { | ||
| 115 | addFiles(fileInput.files); | ||
| 116 | fileInput.value = ''; | ||
| 117 | }); | ||
| 118 | |||
| 119 | // -- drag & drop -- | ||
| 120 | dropZone.addEventListener('dragover', e => { | ||
| 121 | e.preventDefault(); | ||
| 122 | dropZone.classList.add('over'); | ||
| 123 | }); | ||
| 124 | dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over')); | ||
| 125 | dropZone.addEventListener('drop', e => { | ||
| 126 | e.preventDefault(); | ||
| 127 | dropZone.classList.remove('over'); | ||
| 128 | addFiles(e.dataTransfer.files); | ||
| 129 | }); | ||
| 130 | dropZone.addEventListener('click', () => fileInput.click()); | ||
| 131 | |||
| 132 | // -- list management -- | ||
| 133 | |||
| 134 | function addFiles(rawFiles) { | ||
| 135 | for (const f of rawFiles) { | ||
| 136 | if (!f.name.toLowerCase().endsWith('.pptx')) continue; | ||
| 137 | files.push({ id: nextId++, name: f.name, file: f }); | ||
| 138 | } | ||
| 139 | render(); | ||
| 140 | } | ||
| 141 | |||
| 142 | function render() { | ||
| 143 | fileList.innerHTML = ''; | ||
| 144 | |||
| 145 | if (files.length === 0) { | ||
| 146 | emptyMsg.classList.remove('hidden'); | ||
| 147 | mergeBtn.disabled = true; | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | |||
| 151 | emptyMsg.classList.add('hidden'); | ||
| 152 | mergeBtn.disabled = busy; | ||
| 153 | |||
| 154 | files.forEach((item, i) => { | ||
| 155 | const row = document.createElement('div'); | ||
| 156 | row.className = 'file-item'; | ||
| 157 | |||
| 158 | const up = mkbtn('▲', i === 0, () => swap(i, i - 1)); | ||
| 159 | const dn = mkbtn('▼', i === files.length - 1, () => swap(i, i + 1)); | ||
| 160 | const rm = mkbtn('✕', false, () => { files.splice(i, 1); render(); }); | ||
| 161 | |||
| 162 | const span = document.createElement('span'); | ||
| 163 | span.className = 'fname'; | ||
| 164 | span.textContent = (i + 1) + '. ' + item.name; | ||
| 165 | |||
| 166 | row.append(up, dn, rm, span); | ||
| 167 | fileList.appendChild(row); | ||
| 168 | }); | ||
| 169 | } | ||
| 170 | |||
| 171 | function mkbtn(label, disabled, onclick) { | ||
| 172 | const b = document.createElement('button'); | ||
| 173 | b.textContent = label; | ||
| 174 | b.disabled = disabled; | ||
| 175 | b.onclick = onclick; | ||
| 176 | return b; | ||
| 177 | } | ||
| 178 | |||
| 179 | function swap(a, b) { | ||
| 180 | [files[a], files[b]] = [files[b], files[a]]; | ||
| 181 | render(); | ||
| 182 | } | ||
| 183 | |||
| 184 | // -- merge flow -- | ||
| 185 | |||
| 186 | mergeBtn.addEventListener('click', async () => { | ||
| 187 | if (files.length === 0 || busy) return; | ||
| 188 | busy = true; | ||
| 189 | mergeBtn.disabled = true; | ||
| 190 | |||
| 191 | // reset ui sections | ||
| 192 | cmdSec.classList.add('hidden'); | ||
| 193 | statusSec.classList.add('hidden'); | ||
| 194 | dlSec.classList.add('hidden'); | ||
| 195 | logPre.textContent = ''; | ||
| 196 | logPre.classList.add('hidden'); | ||
| 197 | |||
| 198 | const jobId = Date.now() + '_' + rnd(); | ||
| 199 | |||
| 200 | statusSec.classList.remove('hidden'); | ||
| 201 | |||
| 202 | // 1. upload each file | ||
| 203 | for (let i = 0; i < files.length; i++) { | ||
| 204 | const item = files[i]; | ||
| 205 | statusMsg.textContent = 'uploading ' + (i + 1) + '/' + files.length + ': ' + item.name; | ||
| 206 | statusMsg.className = 'running'; | ||
| 207 | |||
| 208 | const serverName = item.id + '_' + item.name; | ||
| 209 | try { | ||
| 210 | const res = await fetch('/api/upload', { | ||
| 211 | method: 'POST', | ||
| 212 | headers: { | ||
| 213 | 'X-Job-Id': jobId, | ||
| 214 | 'X-Filename': encodeURIComponent(serverName), | ||
| 215 | }, | ||
| 216 | body: item.file, | ||
| 217 | }); | ||
| 218 | if (!res.ok) { | ||
| 219 | const err = await res.json().catch(() => ({ error: 'upload failed' })); | ||
| 220 | throw new Error(err.error); | ||
| 221 | } | ||
| 222 | } catch (e) { | ||
| 223 | statusMsg.textContent = 'upload error: ' + e.message; | ||
| 224 | statusMsg.className = 'error'; | ||
| 225 | busy = false; | ||
| 226 | mergeBtn.disabled = false; | ||
| 227 | return; | ||
| 228 | } | ||
| 229 | } | ||
| 230 | |||
| 231 | // 2. send merge request | ||
| 232 | statusMsg.textContent = 'starting merge...'; | ||
| 233 | const serverFiles = files.map(f => f.id + '_' + f.name); | ||
| 234 | |||
| 235 | let mergeData; | ||
| 236 | try { | ||
| 237 | const res = await fetch('/api/merge', { | ||
| 238 | method: 'POST', | ||
| 239 | headers: { 'Content-Type': 'application/json' }, | ||
| 240 | body: JSON.stringify({ job_id: jobId, files: serverFiles }), | ||
| 241 | }); | ||
| 242 | mergeData = await res.json(); | ||
| 243 | if (!res.ok) throw new Error(mergeData.error || 'merge failed'); | ||
| 244 | } catch (e) { | ||
| 245 | statusMsg.textContent = 'merge error: ' + e.message; | ||
| 246 | statusMsg.className = 'error'; | ||
| 247 | busy = false; | ||
| 248 | mergeBtn.disabled = false; | ||
| 249 | return; | ||
| 250 | } | ||
| 251 | |||
| 252 | // 3. show the command | ||
| 253 | cmdSec.classList.remove('hidden'); | ||
| 254 | cmdPre.textContent = '$ ' + mergeData.command; | ||
| 255 | |||
| 256 | // 4. poll for completion | ||
| 257 | statusMsg.textContent = 'merging... this may take a while depending on slide count.'; | ||
| 258 | |||
| 259 | const poll = setInterval(async () => { | ||
| 260 | try { | ||
| 261 | const res = await fetch('/api/status/' + jobId); | ||
| 262 | const s = await res.json(); | ||
| 263 | |||
| 264 | if (s.log) { | ||
| 265 | logPre.classList.remove('hidden'); | ||
| 266 | logPre.textContent = s.log; | ||
| 267 | } | ||
| 268 | |||
| 269 | if (s.status === 'done') { | ||
| 270 | clearInterval(poll); | ||
| 271 | statusMsg.textContent = 'merge complete!'; | ||
| 272 | statusMsg.className = 'done'; | ||
| 273 | dlSec.classList.remove('hidden'); | ||
| 274 | dlLink.href = '/output/' + mergeData.output; | ||
| 275 | dlLink.download = mergeData.output; | ||
| 276 | busy = false; | ||
| 277 | mergeBtn.disabled = false; | ||
| 278 | } else if (s.status === 'error') { | ||
| 279 | clearInterval(poll); | ||
| 280 | statusMsg.textContent = 'merge failed — check the log below.'; | ||
| 281 | statusMsg.className = 'error'; | ||
| 282 | busy = false; | ||
| 283 | mergeBtn.disabled = false; | ||
| 284 | } | ||
| 285 | } catch (_) { | ||
| 286 | // network hiccup, keep polling | ||
| 287 | } | ||
| 288 | }, 2000); | ||
| 289 | }); | ||
| 290 | |||
| 291 | function rnd() { | ||
| 292 | return Math.random().toString(36).substring(2, 10); | ||
| 293 | } | ||
| 294 | })(); | ||
| 295 | </script> | ||
| 296 | </body> | ||
| 297 | </html> | ||
diff --git a/demoware/server.py b/demoware/server.py new file mode 100644 index 0000000..949edb8 --- /dev/null +++ b/demoware/server.py | |||
| @@ -0,0 +1,210 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | |||
| 3 | # kjandoc demoware server | ||
| 4 | # a tiny threaded http server for the web interface. | ||
| 5 | # | ||
| 6 | # usage: python server.py [port] | ||
| 7 | # default port: 8080 | ||
| 8 | |||
| 9 | import os | ||
| 10 | import sys | ||
| 11 | import json | ||
| 12 | import uuid | ||
| 13 | import time | ||
| 14 | import shutil | ||
| 15 | import threading | ||
| 16 | import subprocess | ||
| 17 | from http.server import HTTPServer, SimpleHTTPRequestHandler | ||
| 18 | from socketserver import ThreadingMixIn | ||
| 19 | from pathlib import Path | ||
| 20 | from urllib.parse import unquote | ||
| 21 | |||
| 22 | |||
| 23 | PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 | ||
| 24 | BASE_DIR = Path(__file__).parent.resolve() | ||
| 25 | UPLOAD_DIR = BASE_DIR / 'uploads' | ||
| 26 | OUTPUT_DIR = BASE_DIR / 'output' | ||
| 27 | |||
| 28 | UPLOAD_DIR.mkdir(exist_ok=True) | ||
| 29 | OUTPUT_DIR.mkdir(exist_ok=True) | ||
| 30 | |||
| 31 | # {job_id: {status, command, output, log}} | ||
| 32 | jobs = {} | ||
| 33 | jobs_lock = threading.Lock() | ||
| 34 | |||
| 35 | |||
| 36 | class ThreadedServer(ThreadingMixIn, HTTPServer): | ||
| 37 | daemon_threads = True | ||
| 38 | |||
| 39 | |||
| 40 | class Handler(SimpleHTTPRequestHandler): | ||
| 41 | |||
| 42 | def __init__(self, *args, **kwargs): | ||
| 43 | super().__init__(*args, directory=str(BASE_DIR), **kwargs) | ||
| 44 | |||
| 45 | # -- routing -- | ||
| 46 | |||
| 47 | def do_POST(self): | ||
| 48 | if self.path == '/api/upload': | ||
| 49 | self._upload() | ||
| 50 | elif self.path == '/api/merge': | ||
| 51 | self._merge() | ||
| 52 | else: | ||
| 53 | self.send_error(404) | ||
| 54 | |||
| 55 | def do_GET(self): | ||
| 56 | if self.path.startswith('/api/status/'): | ||
| 57 | self._status() | ||
| 58 | else: | ||
| 59 | super().do_GET() | ||
| 60 | |||
| 61 | # -- helpers -- | ||
| 62 | |||
| 63 | def _json(self, code, data): | ||
| 64 | body = json.dumps(data).encode('utf-8') | ||
| 65 | self.send_response(code) | ||
| 66 | self.send_header('Content-Type', 'application/json') | ||
| 67 | self.send_header('Content-Length', str(len(body))) | ||
| 68 | self.end_headers() | ||
| 69 | self.wfile.write(body) | ||
| 70 | |||
| 71 | # -- handlers -- | ||
| 72 | |||
| 73 | def _upload(self): | ||
| 74 | job_id = self.headers.get('X-Job-Id', '') | ||
| 75 | raw_name = self.headers.get('X-Filename', '') | ||
| 76 | length = int(self.headers.get('Content-Length', 0)) | ||
| 77 | |||
| 78 | if not job_id or not raw_name: | ||
| 79 | return self._json(400, {"error": "missing headers"}) | ||
| 80 | if length <= 0: | ||
| 81 | return self._json(400, {"error": "empty file"}) | ||
| 82 | |||
| 83 | name = Path(unquote(raw_name)).name | ||
| 84 | if not name.lower().endswith('.pptx'): | ||
| 85 | return self._json(400, {"error": "not a .pptx file"}) | ||
| 86 | |||
| 87 | job_dir = UPLOAD_DIR / job_id | ||
| 88 | job_dir.mkdir(parents=True, exist_ok=True) | ||
| 89 | dest = job_dir / name | ||
| 90 | |||
| 91 | with open(dest, 'wb') as f: | ||
| 92 | left = length | ||
| 93 | while left > 0: | ||
| 94 | chunk = self.rfile.read(min(left, 65536)) | ||
| 95 | if not chunk: | ||
| 96 | break | ||
| 97 | f.write(chunk) | ||
| 98 | left -= len(chunk) | ||
| 99 | |||
| 100 | self._json(200, {"ok": True, "file": name}) | ||
| 101 | |||
| 102 | def _merge(self): | ||
| 103 | length = int(self.headers.get('Content-Length', 0)) | ||
| 104 | raw = self.rfile.read(length) | ||
| 105 | try: | ||
| 106 | data = json.loads(raw) | ||
| 107 | except json.JSONDecodeError: | ||
| 108 | return self._json(400, {"error": "bad json"}) | ||
| 109 | |||
| 110 | job_id = data.get('job_id', '') | ||
| 111 | files = data.get('files', []) | ||
| 112 | |||
| 113 | if not job_id or not files: | ||
| 114 | return self._json(400, {"error": "missing job_id or files"}) | ||
| 115 | |||
| 116 | job_dir = UPLOAD_DIR / job_id | ||
| 117 | if not job_dir.exists(): | ||
| 118 | return self._json(400, {"error": "upload dir not found — did you upload first?"}) | ||
| 119 | |||
| 120 | # validate filenames (no path traversal) | ||
| 121 | for f in files: | ||
| 122 | if '/' in f or '\\' in f or '..' in f: | ||
| 123 | return self._json(400, {"error": f"bad filename: {f}"}) | ||
| 124 | if not (job_dir / f).exists(): | ||
| 125 | return self._json(400, {"error": f"not found: {f}"}) | ||
| 126 | |||
| 127 | # output: epoch_shortid.pptx | ||
| 128 | epoch = int(time.time()) | ||
| 129 | uid = uuid.uuid4().hex[:8] | ||
| 130 | out_name = f"{epoch}_{uid}.pptx" | ||
| 131 | out_path = OUTPUT_DIR / out_name | ||
| 132 | |||
| 133 | # build the real command | ||
| 134 | inputs = [str(job_dir / f) for f in files] | ||
| 135 | cmd = ['kjandoc'] + inputs + ['-o', str(out_path)] | ||
| 136 | |||
| 137 | # pretty command for display (strip numeric id prefixes like "3_") | ||
| 138 | pretty_names = [] | ||
| 139 | for f in files: | ||
| 140 | parts = f.split('_', 1) | ||
| 141 | pretty_names.append(parts[1] if len(parts) == 2 and parts[0].isdigit() else f) | ||
| 142 | cmd_str = f"kjandoc {' '.join(pretty_names)} -o output/{out_name}" | ||
| 143 | |||
| 144 | with jobs_lock: | ||
| 145 | jobs[job_id] = { | ||
| 146 | "status": "running", | ||
| 147 | "command": cmd_str, | ||
| 148 | "output": out_name, | ||
| 149 | "log": "", | ||
| 150 | } | ||
| 151 | |||
| 152 | def do_merge(): | ||
| 153 | try: | ||
| 154 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) | ||
| 155 | log = result.stdout + result.stderr | ||
| 156 | status = "done" if result.returncode == 0 else "error" | ||
| 157 | except subprocess.TimeoutExpired: | ||
| 158 | log = "timed out (10 min limit)" | ||
| 159 | status = "error" | ||
| 160 | except Exception as e: | ||
| 161 | log = str(e) | ||
| 162 | status = "error" | ||
| 163 | |||
| 164 | with jobs_lock: | ||
| 165 | jobs[job_id]["log"] = log | ||
| 166 | jobs[job_id]["status"] = status | ||
| 167 | |||
| 168 | # clean up uploads for this job | ||
| 169 | shutil.rmtree(job_dir, ignore_errors=True) | ||
| 170 | |||
| 171 | threading.Thread(target=do_merge, daemon=True).start() | ||
| 172 | |||
| 173 | self._json(200, { | ||
| 174 | "ok": True, | ||
| 175 | "job_id": job_id, | ||
| 176 | "command": cmd_str, | ||
| 177 | "output": out_name, | ||
| 178 | }) | ||
| 179 | |||
| 180 | def _status(self): | ||
| 181 | job_id = self.path.rsplit('/', 1)[-1] | ||
| 182 | with jobs_lock: | ||
| 183 | job = jobs.get(job_id) | ||
| 184 | snapshot = dict(job) if job else None | ||
| 185 | if not snapshot: | ||
| 186 | return self._json(404, {"error": "unknown job"}) | ||
| 187 | self._json(200, snapshot) | ||
| 188 | |||
| 189 | |||
| 190 | def main(): | ||
| 191 | kjandoc_path = shutil.which('kjandoc') | ||
| 192 | if not kjandoc_path: | ||
| 193 | print(f"[!] kjandoc not found in PATH", file=sys.stderr) | ||
| 194 | print(f" install kjandoc or ensure it's available in your PATH", file=sys.stderr) | ||
| 195 | sys.exit(1) | ||
| 196 | |||
| 197 | srv = ThreadedServer(('', PORT), Handler) | ||
| 198 | print(f"[*] kjandoc demoware") | ||
| 199 | print(f"[*] http://localhost:{PORT}") | ||
| 200 | print(f"[*] kjandoc: {kjandoc_path}") | ||
| 201 | print(f"[*] ctrl-c to stop") | ||
| 202 | try: | ||
| 203 | srv.serve_forever() | ||
| 204 | except KeyboardInterrupt: | ||
| 205 | print("\n[*] bye") | ||
| 206 | srv.shutdown() | ||
| 207 | |||
| 208 | |||
| 209 | if __name__ == '__main__': | ||
| 210 | main() | ||
