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:
| M | app.py | | | 83 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | static/css/style.css | | | 45 | +++++++++++++++++++++++++++++++++++++-------- |
| M | templates/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 + '">✕</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 [];