summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKyle Javier [kj_sh604]2026-02-13 23:52:44 -0500
committerGitHub2026-02-13 23:52:44 -0500
commit8e3f2cf9efab60629d920da96a9d49c1fc20cbe3 (patch)
treeaa20fcf16f9ebf2e231e5a3864164e527c5ca61e
parentbf3c2e1c9b5f2fd22de39c55769af239277a819a (diff)
parenteaf6b4a594c9a42076caf5766d08b40019a77b78 (diff)
[merge] pull request #1 from kj-sh604/feat/demoware
feat: demoware, docker
-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 @@
1FROM ubuntu:24.04
2
3ENV PYTHONUNBUFFERED=1
4
5# system deps + libreoffice fresh ppa
6RUN 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
24COPY src/requirements.txt /tmp/requirements.txt
25RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
26
27# kjandoc binary -> /usr/local/bin
28COPY src/kjandoc /usr/local/bin/kjandoc
29RUN chmod +x /usr/local/bin/kjandoc
30
31# demoware
32WORKDIR /app
33COPY demoware/ /app/
34
35# storage dirs
36RUN mkdir -p /app/uploads /app/output
37
38EXPOSE 8080
39
40CMD ["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 @@
1uploads/
2output/
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
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'
27
28UPLOAD_DIR.mkdir(exist_ok=True)
29OUTPUT_DIR.mkdir(exist_ok=True)
30
31# {job_id: {status, command, output, log}}
32jobs = {}
33jobs_lock = threading.Lock()
34
35
36class ThreadedServer(ThreadingMixIn, HTTPServer):
37 daemon_threads = True
38
39
40class 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
190def 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
209if __name__ == '__main__':
210 main()