summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorkj_sh6042026-02-13 22:47:17 -0500
committerkj_sh6042026-02-13 22:47:23 -0500
commitbd1e20b26c0f1ff7c1fd6f3b9bc34e32b5e067c9 (patch)
treeae937ac33190ebf008a436bf4c70d1be5dbe19a1
parentbf3c2e1c9b5f2fd22de39c55769af239277a819a (diff)
feat: try making demoware for this
-rw-r--r--demoware/.gitignore2
-rw-r--r--demoware/index.html297
-rw-r--r--demoware/server.py210
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 @@
1uploads/
2output/
diff --git a/demoware/index.html b/demoware/index.html
new file mode 100644
index 0000000..c882c8c
--- /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/dark.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: #f0ad4e; }
44 .done { color: #5cb85c; }
45 .error { color: #d9534f; }
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..a4b1029
--- /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
9import os
10import sys
11import json
12import uuid
13import time
14import shutil
15import threading
16import subprocess
17from http.server import HTTPServer, SimpleHTTPRequestHandler
18from socketserver import ThreadingMixIn
19from pathlib import Path
20from urllib.parse import unquote
21
22
23PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
24BASE_DIR = Path(__file__).parent.resolve()
25UPLOAD_DIR = BASE_DIR / 'uploads'
26OUTPUT_DIR = BASE_DIR / 'output'
27KJANDOC = (BASE_DIR.parent / 'src' / 'kjandoc').resolve()
28
29UPLOAD_DIR.mkdir(exist_ok=True)
30OUTPUT_DIR.mkdir(exist_ok=True)
31
32# {job_id: {status, command, output, log}}
33jobs = {}
34jobs_lock = threading.Lock()
35
36
37class ThreadedServer(ThreadingMixIn, HTTPServer):
38 daemon_threads = True
39
40
41class Handler(SimpleHTTPRequestHandler):
42
43 def __init__(self, *args, **kwargs):
44 super().__init__(*args, directory=str(BASE_DIR), **kwargs)
45
46 # -- routing --
47
48 def do_POST(self):
49 if self.path == '/api/upload':
50 self._upload()
51 elif self.path == '/api/merge':
52 self._merge()
53 else:
54 self.send_error(404)
55
56 def do_GET(self):
57 if self.path.startswith('/api/status/'):
58 self._status()
59 else:
60 super().do_GET()
61
62 # -- helpers --
63
64 def _json(self, code, data):
65 body = json.dumps(data).encode('utf-8')
66 self.send_response(code)
67 self.send_header('Content-Type', 'application/json')
68 self.send_header('Content-Length', str(len(body)))
69 self.end_headers()
70 self.wfile.write(body)
71
72 # -- handlers --
73
74 def _upload(self):
75 job_id = self.headers.get('X-Job-Id', '')
76 raw_name = self.headers.get('X-Filename', '')
77 length = int(self.headers.get('Content-Length', 0))
78
79 if not job_id or not raw_name:
80 return self._json(400, {"error": "missing headers"})
81 if length <= 0:
82 return self._json(400, {"error": "empty file"})
83
84 name = Path(unquote(raw_name)).name
85 if not name.lower().endswith('.pptx'):
86 return self._json(400, {"error": "not a .pptx file"})
87
88 job_dir = UPLOAD_DIR / job_id
89 job_dir.mkdir(parents=True, exist_ok=True)
90 dest = job_dir / name
91
92 with open(dest, 'wb') as f:
93 left = length
94 while left > 0:
95 chunk = self.rfile.read(min(left, 65536))
96 if not chunk:
97 break
98 f.write(chunk)
99 left -= len(chunk)
100
101 self._json(200, {"ok": True, "file": name})
102
103 def _merge(self):
104 length = int(self.headers.get('Content-Length', 0))
105 raw = self.rfile.read(length)
106 try:
107 data = json.loads(raw)
108 except json.JSONDecodeError:
109 return self._json(400, {"error": "bad json"})
110
111 job_id = data.get('job_id', '')
112 files = data.get('files', [])
113
114 if not job_id or not files:
115 return self._json(400, {"error": "missing job_id or files"})
116
117 job_dir = UPLOAD_DIR / job_id
118 if not job_dir.exists():
119 return self._json(400, {"error": "upload dir not found — did you upload first?"})
120
121 # validate filenames (no path traversal)
122 for f in files:
123 if '/' in f or '\\' in f or '..' in f:
124 return self._json(400, {"error": f"bad filename: {f}"})
125 if not (job_dir / f).exists():
126 return self._json(400, {"error": f"not found: {f}"})
127
128 # output: epoch_shortid.pptx
129 epoch = int(time.time())
130 uid = uuid.uuid4().hex[:8]
131 out_name = f"{epoch}_{uid}.pptx"
132 out_path = OUTPUT_DIR / out_name
133
134 # build the real command
135 inputs = [str(job_dir / f) for f in files]
136 cmd = [sys.executable, str(KJANDOC)] + inputs + ['-o', str(out_path)]
137
138 # pretty command for display (strip numeric id prefixes like "3_")
139 pretty_names = []
140 for f in files:
141 parts = f.split('_', 1)
142 pretty_names.append(parts[1] if len(parts) == 2 and parts[0].isdigit() else f)
143 cmd_str = f"kjandoc {' '.join(pretty_names)} -o output/{out_name}"
144
145 with jobs_lock:
146 jobs[job_id] = {
147 "status": "running",
148 "command": cmd_str,
149 "output": out_name,
150 "log": "",
151 }
152
153 def do_merge():
154 try:
155 result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
156 log = result.stdout + result.stderr
157 status = "done" if result.returncode == 0 else "error"
158 except subprocess.TimeoutExpired:
159 log = "timed out (10 min limit)"
160 status = "error"
161 except Exception as e:
162 log = str(e)
163 status = "error"
164
165 with jobs_lock:
166 jobs[job_id]["log"] = log
167 jobs[job_id]["status"] = status
168
169 # clean up uploads for this job
170 shutil.rmtree(job_dir, ignore_errors=True)
171
172 threading.Thread(target=do_merge, daemon=True).start()
173
174 self._json(200, {
175 "ok": True,
176 "job_id": job_id,
177 "command": cmd_str,
178 "output": out_name,
179 })
180
181 def _status(self):
182 job_id = self.path.rsplit('/', 1)[-1]
183 with jobs_lock:
184 job = jobs.get(job_id)
185 snapshot = dict(job) if job else None
186 if not snapshot:
187 return self._json(404, {"error": "unknown job"})
188 self._json(200, snapshot)
189
190
191def main():
192 if not KJANDOC.exists():
193 print(f"[!] kjandoc not found: {KJANDOC}", file=sys.stderr)
194 print(f" expected at ../src/kjandoc relative to this script", 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}")
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
209if __name__ == '__main__':
210 main()