diff options
Diffstat (limited to 'demoware')
| -rw-r--r-- | demoware/.gitignore | 2 | ||||
| -rw-r--r-- | demoware/index.html | 297 | ||||
| -rw-r--r-- | demoware/server.py | 210 |
3 files changed, 509 insertions, 0 deletions
diff --git a/demoware/.gitignore b/demoware/.gitignore new file mode 100644 index 0000000..390f45d --- /dev/null +++ b/demoware/.gitignore @@ -0,0 +1,2 @@ +uploads/ +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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>slide merging demoware</title> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.min.css"> + <style> + .file-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0; + } + .file-item button { + padding: 0.15rem 0.5rem; + margin: 0; + font-size: 0.85rem; + min-width: 2rem; + } + .file-item .fname { + flex: 1; + font-family: var(--font-family, monospace); + } + #drop-zone { + border: 2px dashed #555; + padding: 2rem; + text-align: center; + border-radius: 6px; + margin: 1rem 0; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + } + #drop-zone.over { + border-color: #7ec8e3; + background: rgba(126, 200, 227, 0.06); + } + #command-display { + white-space: pre-wrap; + word-break: break-all; + } + .hidden { display: none !important; } + .running { color: #ff9800; } + .done { color: #4caf50; } + .error { color: #f44336; } + #log-output { + max-height: 300px; + overflow-y: auto; + font-size: 0.85rem; + } + footer { margin-top: 2rem; } + </style> +</head> +<body> + <h1>an attempt at a .pptx merging tool</h1> + <p>merge multiple <code>.pptx</code> files into a <em>single</em> <code>.pptx</code> file.</p> + <hr> + + <h2>1. add files</h2> + <div id="drop-zone"> + drop <code>.pptx</code> files here — or click to browse + </div> + <input type="file" id="file-input" multiple accept=".pptx" style="margin-top:0.5rem;"> + + <h2>2. merge order</h2> + <p id="empty-msg"><em>no files added yet.</em></p> + <div id="file-list"></div> + + <h2>3. merge</h2> + <button id="merge-btn" disabled>merge presentations</button> + + <div id="cmd-section" class="hidden"> + <h3><code>kjandoc</code> command being used:</h3> + <pre id="command-display"></pre> + </div> + + <div id="status-section" class="hidden"> + <p id="status-msg"></p> + <pre id="log-output" class="hidden"></pre> + </div> + + <div id="dl-section" class="hidden"> + <a id="dl-link"><button>⬇ download merged presentation</button></a> + </div> + + <footer> + <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> + </footer> + + <script> + (() => { + 'use strict'; + + // -- state -- + let files = []; // {id, name, file} + let nextId = 1; + let busy = false; + + // -- dom refs -- + const dropZone = document.getElementById('drop-zone'); + const fileInput = document.getElementById('file-input'); + const fileList = document.getElementById('file-list'); + const emptyMsg = document.getElementById('empty-msg'); + const mergeBtn = document.getElementById('merge-btn'); + const cmdSec = document.getElementById('cmd-section'); + const cmdPre = document.getElementById('command-display'); + const statusSec = document.getElementById('status-section'); + const statusMsg = document.getElementById('status-msg'); + const logPre = document.getElementById('log-output'); + const dlSec = document.getElementById('dl-section'); + const dlLink = document.getElementById('dl-link'); + + // -- file input -- + fileInput.addEventListener('change', () => { + addFiles(fileInput.files); + fileInput.value = ''; + }); + + // -- drag & drop -- + dropZone.addEventListener('dragover', e => { + e.preventDefault(); + dropZone.classList.add('over'); + }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over')); + dropZone.addEventListener('drop', e => { + e.preventDefault(); + dropZone.classList.remove('over'); + addFiles(e.dataTransfer.files); + }); + dropZone.addEventListener('click', () => fileInput.click()); + + // -- list management -- + + function addFiles(rawFiles) { + for (const f of rawFiles) { + if (!f.name.toLowerCase().endsWith('.pptx')) continue; + files.push({ id: nextId++, name: f.name, file: f }); + } + render(); + } + + function render() { + fileList.innerHTML = ''; + + if (files.length === 0) { + emptyMsg.classList.remove('hidden'); + mergeBtn.disabled = true; + return; + } + + emptyMsg.classList.add('hidden'); + mergeBtn.disabled = busy; + + files.forEach((item, i) => { + const row = document.createElement('div'); + row.className = 'file-item'; + + const up = mkbtn('▲', i === 0, () => swap(i, i - 1)); + const dn = mkbtn('▼', i === files.length - 1, () => swap(i, i + 1)); + const rm = mkbtn('✕', false, () => { files.splice(i, 1); render(); }); + + const span = document.createElement('span'); + span.className = 'fname'; + span.textContent = (i + 1) + '. ' + item.name; + + row.append(up, dn, rm, span); + fileList.appendChild(row); + }); + } + + function mkbtn(label, disabled, onclick) { + const b = document.createElement('button'); + b.textContent = label; + b.disabled = disabled; + b.onclick = onclick; + return b; + } + + function swap(a, b) { + [files[a], files[b]] = [files[b], files[a]]; + render(); + } + + // -- merge flow -- + + mergeBtn.addEventListener('click', async () => { + if (files.length === 0 || busy) return; + busy = true; + mergeBtn.disabled = true; + + // reset ui sections + cmdSec.classList.add('hidden'); + statusSec.classList.add('hidden'); + dlSec.classList.add('hidden'); + logPre.textContent = ''; + logPre.classList.add('hidden'); + + const jobId = Date.now() + '_' + rnd(); + + statusSec.classList.remove('hidden'); + + // 1. upload each file + for (let i = 0; i < files.length; i++) { + const item = files[i]; + statusMsg.textContent = 'uploading ' + (i + 1) + '/' + files.length + ': ' + item.name; + statusMsg.className = 'running'; + + const serverName = item.id + '_' + item.name; + try { + const res = await fetch('/api/upload', { + method: 'POST', + headers: { + 'X-Job-Id': jobId, + 'X-Filename': encodeURIComponent(serverName), + }, + body: item.file, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'upload failed' })); + throw new Error(err.error); + } + } catch (e) { + statusMsg.textContent = 'upload error: ' + e.message; + statusMsg.className = 'error'; + busy = false; + mergeBtn.disabled = false; + return; + } + } + + // 2. send merge request + statusMsg.textContent = 'starting merge...'; + const serverFiles = files.map(f => f.id + '_' + f.name); + + let mergeData; + try { + const res = await fetch('/api/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: jobId, files: serverFiles }), + }); + mergeData = await res.json(); + if (!res.ok) throw new Error(mergeData.error || 'merge failed'); + } catch (e) { + statusMsg.textContent = 'merge error: ' + e.message; + statusMsg.className = 'error'; + busy = false; + mergeBtn.disabled = false; + return; + } + + // 3. show the command + cmdSec.classList.remove('hidden'); + cmdPre.textContent = '$ ' + mergeData.command; + + // 4. poll for completion + statusMsg.textContent = 'merging... this may take a while depending on slide count.'; + + const poll = setInterval(async () => { + try { + const res = await fetch('/api/status/' + jobId); + const s = await res.json(); + + if (s.log) { + logPre.classList.remove('hidden'); + logPre.textContent = s.log; + } + + if (s.status === 'done') { + clearInterval(poll); + statusMsg.textContent = 'merge complete!'; + statusMsg.className = 'done'; + dlSec.classList.remove('hidden'); + dlLink.href = '/output/' + mergeData.output; + dlLink.download = mergeData.output; + busy = false; + mergeBtn.disabled = false; + } else if (s.status === 'error') { + clearInterval(poll); + statusMsg.textContent = 'merge failed — check the log below.'; + statusMsg.className = 'error'; + busy = false; + mergeBtn.disabled = false; + } + } catch (_) { + // network hiccup, keep polling + } + }, 2000); + }); + + function rnd() { + return Math.random().toString(36).substring(2, 10); + } + })(); + </script> +</body> +</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 @@ +#!/usr/bin/env python3 + +# kjandoc demoware server +# a tiny threaded http server for the web interface. +# +# usage: python server.py [port] +# default port: 8080 + +import os +import sys +import json +import uuid +import time +import shutil +import threading +import subprocess +from http.server import HTTPServer, SimpleHTTPRequestHandler +from socketserver import ThreadingMixIn +from pathlib import Path +from urllib.parse import unquote + + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 +BASE_DIR = Path(__file__).parent.resolve() +UPLOAD_DIR = BASE_DIR / 'uploads' +OUTPUT_DIR = BASE_DIR / 'output' + +UPLOAD_DIR.mkdir(exist_ok=True) +OUTPUT_DIR.mkdir(exist_ok=True) + +# {job_id: {status, command, output, log}} +jobs = {} +jobs_lock = threading.Lock() + + +class ThreadedServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +class Handler(SimpleHTTPRequestHandler): + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(BASE_DIR), **kwargs) + + # -- routing -- + + def do_POST(self): + if self.path == '/api/upload': + self._upload() + elif self.path == '/api/merge': + self._merge() + else: + self.send_error(404) + + def do_GET(self): + if self.path.startswith('/api/status/'): + self._status() + else: + super().do_GET() + + # -- helpers -- + + def _json(self, code, data): + body = json.dumps(data).encode('utf-8') + self.send_response(code) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + # -- handlers -- + + def _upload(self): + job_id = self.headers.get('X-Job-Id', '') + raw_name = self.headers.get('X-Filename', '') + length = int(self.headers.get('Content-Length', 0)) + + if not job_id or not raw_name: + return self._json(400, {"error": "missing headers"}) + if length <= 0: + return self._json(400, {"error": "empty file"}) + + name = Path(unquote(raw_name)).name + if not name.lower().endswith('.pptx'): + return self._json(400, {"error": "not a .pptx file"}) + + job_dir = UPLOAD_DIR / job_id + job_dir.mkdir(parents=True, exist_ok=True) + dest = job_dir / name + + with open(dest, 'wb') as f: + left = length + while left > 0: + chunk = self.rfile.read(min(left, 65536)) + if not chunk: + break + f.write(chunk) + left -= len(chunk) + + self._json(200, {"ok": True, "file": name}) + + def _merge(self): + length = int(self.headers.get('Content-Length', 0)) + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except json.JSONDecodeError: + return self._json(400, {"error": "bad json"}) + + job_id = data.get('job_id', '') + files = data.get('files', []) + + if not job_id or not files: + return self._json(400, {"error": "missing job_id or files"}) + + job_dir = UPLOAD_DIR / job_id + if not job_dir.exists(): + return self._json(400, {"error": "upload dir not found — did you upload first?"}) + + # validate filenames (no path traversal) + for f in files: + if '/' in f or '\\' in f or '..' in f: + return self._json(400, {"error": f"bad filename: {f}"}) + if not (job_dir / f).exists(): + return self._json(400, {"error": f"not found: {f}"}) + + # output: epoch_shortid.pptx + epoch = int(time.time()) + uid = uuid.uuid4().hex[:8] + out_name = f"{epoch}_{uid}.pptx" + out_path = OUTPUT_DIR / out_name + + # build the real command + inputs = [str(job_dir / f) for f in files] + cmd = ['kjandoc'] + inputs + ['-o', str(out_path)] + + # pretty command for display (strip numeric id prefixes like "3_") + pretty_names = [] + for f in files: + parts = f.split('_', 1) + pretty_names.append(parts[1] if len(parts) == 2 and parts[0].isdigit() else f) + cmd_str = f"kjandoc {' '.join(pretty_names)} -o output/{out_name}" + + with jobs_lock: + jobs[job_id] = { + "status": "running", + "command": cmd_str, + "output": out_name, + "log": "", + } + + def do_merge(): + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + log = result.stdout + result.stderr + status = "done" if result.returncode == 0 else "error" + except subprocess.TimeoutExpired: + log = "timed out (10 min limit)" + status = "error" + except Exception as e: + log = str(e) + status = "error" + + with jobs_lock: + jobs[job_id]["log"] = log + jobs[job_id]["status"] = status + + # clean up uploads for this job + shutil.rmtree(job_dir, ignore_errors=True) + + threading.Thread(target=do_merge, daemon=True).start() + + self._json(200, { + "ok": True, + "job_id": job_id, + "command": cmd_str, + "output": out_name, + }) + + def _status(self): + job_id = self.path.rsplit('/', 1)[-1] + with jobs_lock: + job = jobs.get(job_id) + snapshot = dict(job) if job else None + if not snapshot: + return self._json(404, {"error": "unknown job"}) + self._json(200, snapshot) + + +def main(): + kjandoc_path = shutil.which('kjandoc') + if not kjandoc_path: + print(f"[!] kjandoc not found in PATH", file=sys.stderr) + print(f" install kjandoc or ensure it's available in your PATH", file=sys.stderr) + sys.exit(1) + + srv = ThreadedServer(('', PORT), Handler) + print(f"[*] kjandoc demoware") + print(f"[*] http://localhost:{PORT}") + print(f"[*] kjandoc: {kjandoc_path}") + print(f"[*] ctrl-c to stop") + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\n[*] bye") + srv.shutdown() + + +if __name__ == '__main__': + main() |
