stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

commit c8d96505524be91ebe815afee890b9e6f9605834
parent fbbf974a30107958d9583349f148d9216344f127
Author: Zach Rice <bynxmusic@gmail.com>
Date:   Thu, 28 May 2026 19:00:24 -0400

Add more dictionary management features: drag to reorder, swap files, swap entire configs, clear all dictionaries

Diffstat:
Mapp.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/css/style.css | 45+++++++++++++++++++++++++++++++++++++--------
Mtemplates/settings.html | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 302 insertions(+), 16 deletions(-)

diff --git a/app.py b/app.py @@ -4,6 +4,7 @@ import configparser import json import os import random +import shutil import re import time from functools import wraps @@ -859,6 +860,76 @@ def api_toggle_dict(dict_id): return jsonify({"ok": True}) +@app.route("/api/dictionaries/reorder", methods=["POST"]) +@login_required +def api_reorder_dicts(): + uid = current_user_id() + data = request.get_json() + order = data.get("order", []) + if not order: + return jsonify({"error": "No order provided"}), 400 + with db._get_conn() as conn: + for i, dict_id in enumerate(order): + conn.execute( + "UPDATE user_dictionaries SET dict_order = ? WHERE id = ? AND user_id = ?", + (i, dict_id, uid), + ) + reload_engine(uid) + return jsonify({"ok": True}) + + +@app.route("/api/dictionaries/<int:dict_id>/replace", methods=["POST"]) +@login_required +def api_replace_dict(dict_id): + uid = current_user_id() + dicts = {d["id"]: d for d in db.get_user_dictionaries(uid)} + if dict_id not in dicts: + return jsonify({"error": "Dictionary not found"}), 404 + + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "No file provided"}), 400 + + ext = os.path.splitext(f.filename)[1].lower() + if ext not in ALLOWED_DICT_EXTENSIONS: + return jsonify({"error": "Only .json and .py files are allowed"}), 400 + + old = dicts[dict_id] + user_dir = _get_user_dict_dir(uid) + os.makedirs(user_dir, exist_ok=True) + + old_path = os.path.join(user_dir, old["filename"]) + if os.path.exists(old_path): + os.remove(old_path) + + safe = secure_filename(f.filename) + dest = os.path.join(user_dir, safe) + f.save(dest) + + entry_count = 0 + if ext == ".json": + try: + with open(dest, "r", encoding="utf-8") as jf: + entry_count = len(json.load(jf)) + except Exception: + pass + + with db._get_conn() as conn: + conn.execute( + "UPDATE user_dictionaries SET filename = ?, display_name = ?, entry_count = ? WHERE id = ? AND user_id = ?", + (safe, f.filename, entry_count, dict_id, uid), + ) + + reload_engine(uid) + return jsonify({ + "ok": True, + "id": dict_id, + "filename": safe, + "display_name": f.filename, + "entry_count": entry_count, + }) + + @app.route("/api/dictionaries/<int:dict_id>", methods=["DELETE"]) @login_required def api_delete_dict(dict_id): @@ -872,6 +943,18 @@ def api_delete_dict(dict_id): return jsonify({"ok": True}) +@app.route("/api/dictionaries/clear", methods=["POST"]) +@login_required +def api_clear_dicts(): + uid = current_user_id() + user_dir = _get_user_dict_dir(uid) + db.clear_user_dictionaries(uid) + if os.path.isdir(user_dir): + shutil.rmtree(user_dir) + reload_engine(uid) + return jsonify({"ok": True}) + + # ── Stats API ── diff --git a/static/css/style.css b/static/css/style.css @@ -1824,6 +1824,13 @@ a:hover { color: var(--text-primary); } margin-bottom: 1rem; } +.dict-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; +} + .dict-upload-group { display: flex; gap: 0.75rem; @@ -1861,18 +1868,31 @@ a:hover { color: var(--text-primary); } color: var(--warning); } -.dict-item__order { - width: 24px; - height: 24px; +.dict-item__handle { display: flex; align-items: center; justify-content: center; - border-radius: 50%; - background: var(--accent); - color: var(--text-on-color); - font-size: 0.7rem; - font-weight: 700; + width: 20px; flex-shrink: 0; + cursor: grab; + color: var(--text-muted); + opacity: 0.5; + transition: opacity var(--transition), color var(--transition); + touch-action: none; +} +.dict-item__handle:active { cursor: grabbing; } +.dict-item:hover .dict-item__handle { opacity: 1; color: var(--text-secondary); } + +.dict-item--dragging { + opacity: 0.4; + box-shadow: var(--neu-pressed); +} + +.dict-item--drop-above { + box-shadow: var(--neu-raised-sm), inset 0 3px 0 0 var(--accent); +} +.dict-item--drop-below { + box-shadow: var(--neu-raised-sm), inset 0 -3px 0 0 var(--accent); } .dict-item__name { @@ -1891,6 +1911,12 @@ a:hover { color: var(--text-primary); } flex-shrink: 0; } +.dict-item__swap { + flex-shrink: 0; + cursor: pointer; + color: var(--accent); +} + .dict-item__toggle { flex-shrink: 0; cursor: pointer; @@ -1945,7 +1971,10 @@ a:hover { color: var(--text-primary); } .login-card { padding: 2rem 1.5rem; } .nav-user { flex-direction: column; gap: 0.25rem; } .nav-avatar { width: 40px; height: 40px; } + .dict-actions { flex-direction: column; } + .dict-actions .btn { width: 100%; } .dict-upload-group { flex-direction: column; } .dict-item { flex-wrap: wrap; } + .dict-item__handle { opacity: 1; } .dict-item__name { flex-basis: calc(100% - 100px); } } diff --git a/templates/settings.html b/templates/settings.html @@ -8,14 +8,17 @@ <div class="settings-section"> <h2 class="section-title">Dictionaries</h2> - <p class="setting-help" style="margin-bottom: 1rem;">Select your Plover config directory — only plover.cfg and referenced dictionaries will be uploaded.</p> + <p class="setting-help" style="margin-bottom: 1rem;">Select your Plover config directory — replaces all current dictionaries with the ones from plover.cfg.</p> <div class="dict-upload-row"> - <div class="dict-upload-group"> + <div class="dict-actions"> <label class="btn btn--primary dict-upload-btn"> - Select Plover Config Directory + {% if dictionaries %}Swap Config Directory{% else %}Select Plover Config Directory{% endif %} <input type="file" id="folder-upload" webkitdirectory hidden> </label> + {% if dictionaries %} + <button id="clear-dicts-btn" class="btn btn--danger">Clear All Dictionaries</button> + {% endif %} </div> </div> @@ -27,10 +30,17 @@ <div id="dict-list" class="dict-list"> {% if dictionaries %} {% for d in dictionaries %} - <div class="dict-item" data-id="{{ d.id }}"> - <span class="dict-item__order">{{ d.dict_order + 1 }}</span> + <div class="dict-item" data-id="{{ d.id }}" draggable="false"> + <span class="dict-item__handle" aria-label="Drag to reorder"> + <svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor"> + <circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/> + <circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/> + <circle cx="2" cy="14" r="1.5"/><circle cx="8" cy="14" r="1.5"/> + </svg> + </span> <span class="dict-item__name">{{ d.display_name or d.filename }}</span> <span class="dict-item__count">{{ d.entry_count }} entries</span> + <label class="dict-item__swap btn btn--ghost btn--small" data-id="{{ d.id }}">Swap<input type="file" accept=".json,.py" hidden></label> <label class="dict-item__toggle"> <input type="checkbox" class="dict-toggle-cb" data-id="{{ d.id }}" {{ 'checked' if d.enabled else '' }}> </label> @@ -109,9 +119,28 @@ document.addEventListener('DOMContentLoaded', () => { progressEl.style.display = 'none'; } + function updateDictActions(hasDicts) { + const actions = document.querySelector('.dict-actions'); + const uploadLabel = actions.querySelector('.dict-upload-btn'); + uploadLabel.childNodes[0].textContent = hasDicts ? 'Swap Config Directory' : 'Select Plover Config Directory'; + let clearBtn = document.getElementById('clear-dicts-btn'); + if (hasDicts && !clearBtn) { + clearBtn = document.createElement('button'); + clearBtn.id = 'clear-dicts-btn'; + clearBtn.className = 'btn btn--danger'; + clearBtn.textContent = 'Clear All Dictionaries'; + actions.appendChild(clearBtn); + bindClearDicts(); + } else if (!hasDicts && clearBtn) { + clearBtn.remove(); + } + } + function renderDictList(dicts) { const container = document.getElementById('dict-list'); - if (!dicts || dicts.length === 0) { + const hasDicts = dicts && dicts.length > 0; + updateDictActions(hasDicts); + if (!hasDicts) { container.innerHTML = '<div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div>'; return; } @@ -119,10 +148,11 @@ document.addEventListener('DOMContentLoaded', () => { dicts.forEach(d => { const statusClass = d.uploaded ? '' : ' dict-item--missing'; const statusLabel = d.uploaded ? d.entry_count + ' entries' : 'not uploaded'; - html += '<div class="dict-item' + statusClass + '" data-id="' + d.id + '">'; - html += '<span class="dict-item__order">' + (d.order + 1) + '</span>'; + html += '<div class="dict-item' + statusClass + '" data-id="' + d.id + '" draggable="false">'; + html += '<span class="dict-item__handle" aria-label="Drag to reorder"><svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor"><circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/><circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="2" cy="14" r="1.5"/><circle cx="8" cy="14" r="1.5"/></svg></span>'; html += '<span class="dict-item__name">' + (d.display_name || d.filename) + '</span>'; html += '<span class="dict-item__count">' + statusLabel + '</span>'; + html += '<label class="dict-item__swap btn btn--ghost btn--small" data-id="' + d.id + '">Swap<input type="file" accept=".json,.py" hidden></label>'; html += '<label class="dict-item__toggle"><input type="checkbox" class="dict-toggle-cb" data-id="' + d.id + '"' + (d.enabled ? ' checked' : '') + '></label>'; html += '<button class="dict-item__delete btn btn--ghost btn--small" data-id="' + d.id + '">&#10005;</button>'; html += '</div>'; @@ -131,6 +161,102 @@ document.addEventListener('DOMContentLoaded', () => { bindDictEvents(); } + // ── Drag & Drop Reorder ── + function bindDragReorder() { + const list = document.getElementById('dict-list'); + let dragItem = null; + + list.addEventListener('mousedown', e => { + const handle = e.target.closest('.dict-item__handle'); + const item = e.target.closest('.dict-item'); + if (item) item.draggable = !!handle; + }); + + list.addEventListener('dragstart', e => { + const item = e.target.closest('.dict-item'); + if (!item) { e.preventDefault(); return; } + dragItem = item; + item.classList.add('dict-item--dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', item.dataset.id); + }); + + list.addEventListener('dragend', e => { + if (dragItem) dragItem.classList.remove('dict-item--dragging'); + list.querySelectorAll('.dict-item').forEach(el => { + el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); + }); + dragItem = null; + }); + + list.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const target = e.target.closest('.dict-item'); + if (!target || target === dragItem) return; + + list.querySelectorAll('.dict-item').forEach(el => { + el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); + }); + + const rect = target.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + if (e.clientY < mid) { + target.classList.add('dict-item--drop-above'); + } else { + target.classList.add('dict-item--drop-below'); + } + }); + + list.addEventListener('dragleave', e => { + const target = e.target.closest('.dict-item'); + if (target) { + target.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); + } + }); + + list.addEventListener('drop', async e => { + e.preventDefault(); + const target = e.target.closest('.dict-item'); + if (!target || !dragItem || target === dragItem) return; + + const rect = target.getBoundingClientRect(); + const before = e.clientY < rect.top + rect.height / 2; + + if (before) { + list.insertBefore(dragItem, target); + } else { + list.insertBefore(dragItem, target.nextSibling); + } + + dragItem.classList.remove('dict-item--dragging'); + list.querySelectorAll('.dict-item').forEach(el => { + el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); + }); + + if (typeof gsap !== 'undefined') { + gsap.fromTo(dragItem, { opacity: 0.4, y: before ? -8 : 8 }, + { opacity: 1, y: 0, duration: 0.25, ease: 'power2.out' }); + } + + const order = Array.from(list.querySelectorAll('.dict-item')) + .map(el => parseInt(el.dataset.id)); + + try { + await fetch('/api/dictionaries/reorder', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({order: order}) + }); + } catch(err) { + showToast('Failed to save order.', 'error'); + } + + dragItem = null; + }); + } + bindDragReorder(); + function bindDictEvents() { document.querySelectorAll('.dict-toggle-cb').forEach(cb => { cb.addEventListener('change', async () => { @@ -148,6 +274,32 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + document.querySelectorAll('.dict-item__swap input[type="file"]').forEach(input => { + input.addEventListener('change', async () => { + const file = input.files[0]; + if (!file) return; + const id = input.closest('.dict-item__swap').dataset.id; + const item = input.closest('.dict-item'); + const form = new FormData(); + form.append('file', file); + try { + const r = await fetch('/api/dictionaries/' + id + '/replace', { method: 'POST', body: form }); + const d = await r.json(); + if (d.ok) { + item.querySelector('.dict-item__name').textContent = d.display_name || d.filename; + item.querySelector('.dict-item__count').textContent = d.entry_count + ' entries'; + item.classList.remove('dict-item--missing'); + showToast('Dictionary replaced.', 'success'); + } else { + showToast(d.error || 'Replace failed.', 'error'); + } + } catch(e) { + showToast('Failed to replace dictionary.', 'error'); + } + input.value = ''; + }); + }); + document.querySelectorAll('.dict-item__delete').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Delete this dictionary?')) return; @@ -164,6 +316,28 @@ document.addEventListener('DOMContentLoaded', () => { } bindDictEvents(); + function bindClearDicts() { + const btn = document.getElementById('clear-dicts-btn'); + if (!btn) return; + btn.addEventListener('click', async () => { + if (!confirm('Remove all dictionaries and uploaded files? This cannot be undone.')) return; + try { + const r = await fetch('/api/dictionaries/clear', { + method: 'POST', + headers: {'Content-Type': 'application/json'} + }); + const d = await r.json(); + if (d.ok) { + renderDictList([]); + showToast('All dictionaries cleared.', 'success'); + } + } catch(e) { + showToast('Failed to clear dictionaries.', 'error'); + } + }); + } + bindClearDicts(); + function parsePloverCfg(text) { const match = text.match(/^\[System:\s*English Stenotype\]\s*$/m); if (!match) return [];