aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Dockerfile40
-rw-r--r--demoware/.gitignore2
-rw-r--r--demoware/index.html297
-rw-r--r--demoware/server.py210
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 @@
+FROM ubuntu:24.04
+
+ENV PYTHONUNBUFFERED=1
+
+# system deps + libreoffice fresh ppa
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ software-properties-common \
+ ca-certificates && \
+ add-apt-repository -y ppa:libreoffice/ppa && \
+ apt-get update && \
+ apt-get install -y --no-install-recommends \
+ python3 \
+ python3-pip \
+ python3-venv \
+ libreoffice \
+ poppler-utils \
+ fonts-liberation \
+ fonts-dejavu-core && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+# python deps
+COPY src/requirements.txt /tmp/requirements.txt
+RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
+
+# kjandoc binary -> /usr/local/bin
+COPY src/kjandoc /usr/local/bin/kjandoc
+RUN chmod +x /usr/local/bin/kjandoc
+
+# demoware
+WORKDIR /app
+COPY demoware/ /app/
+
+# storage dirs
+RUN mkdir -p /app/uploads /app/output
+
+EXPOSE 8080
+
+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 @@
+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()