Compare commits

..

1 Commits

Author SHA1 Message Date
6e72346129 fix(ams): support multiple daisy-chained ACE units (Issue #95)
All checks were successful
Nightly Build / build (push) Successful in 9m6s
ace_direct mode (no toolhead buffer) only kept the first reported ACE
unit and silently dropped any others. Affects e.g. the Kobra S1 with two
ACE Pro units - the dashboard and OrcaSlicer sync only ever saw 4 of 8
slots. Global slot index is now box_id * 4 + local slot across all
units, matching the existing //4-%4 fallback in _global_to_box_slot.
Single-ACE (Kobra X) behavior is unchanged (unit id 0 -> same indices
as before).

feat(store): multi-select + bulk delete in the GCode browser (Issue #94)

Checkbox on every file card enters select mode; clicking anywhere on a
selected-mode card toggles it, existing per-file actions keep working via
stopPropagation. 'Select All' only affects the currently filtered/visible
files. Bulk delete is N parallel calls to the existing single-file DELETE
endpoint (no new backend route) with one confirmation dialog.
2026-07-19 18:44:46 +02:00
11 changed files with 308 additions and 55 deletions

View File

@@ -1,5 +1,4 @@
## Changes in this build
- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87)
- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87)
- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way)
- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs)
- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim)

View File

@@ -1585,7 +1585,10 @@ class KobraXBridge:
Modes:
- toolhead: only toolhead slots
- ace_direct: ACE channels directly mapped (no toolhead box present)
- ace_direct: ACE channels directly mapped, no toolhead box present.
Covers one unit (Kobra X) as well as multiple daisy-chained units
(Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
block of 4 global slots at box_id * 4.
- ace_hub: toolhead + ACE via hub (slot 4 as hub path)
"""
toolhead = any(b.get("id") == -1 for b in boxes)
@@ -1621,19 +1624,22 @@ class KobraXBridge:
return global_slots, global_loaded
if mode == "ace_direct":
# ace_direct exposes exactly 4 channels total.
# If firmware reports multiple ACE boxes, keep only the first one.
if ace_boxes:
ace = ace_boxes[0]
ace_id = ace["id"]
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = ace_loaded
# One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
# Kobra S1: up to 2+ units, Issue #95). Global index =
# box_id * 4 + local slot, so the numbering matches
# _global_to_box_slot's //4-%4 fallback and stays stable
# regardless of report order.
for ace in ace_boxes:
ace_id = int(ace["id"])
base = ace_id * 4
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = base + local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = base + ace_loaded
return global_slots, global_loaded
# ace_hub
@@ -1824,20 +1830,18 @@ class KobraXBridge:
if box_id == -1:
return local_slot
if self._filament_mode == "ace_direct":
return local_slot
# Multi-ACE (Issue #95): each unit occupies its own block of 4.
# Identical to the old `return local_slot` for a single unit (id 0).
return box_id * 4 + local_slot
return 3 + box_id * 4 + local_slot
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
"""Build {global_slot_index: loading|unloading} from feed_status data."""
# Note: all boxes are considered — the old primary_ace_id filter (skip
# every ACE box except the first in ace_direct mode) is gone since the
# slot aggregation now handles multiple ACE units (Issue #95).
activity: dict = {}
primary_ace_id = -1
if self._filament_mode == "ace_direct":
ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0)
if ace_ids:
primary_ace_id = ace_ids[0]
for box in boxes:
if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id:
continue
fs = box.get("feed_status") or {}
current_status = int(fs.get("current_status", -1))
local_slot = int(fs.get("slot_index", -1))

View File

@@ -0,0 +1,117 @@
"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1).
A Kobra S1 with two daisy-chained ACE Pro units reports
multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead
entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently
dropping the second unit — the dashboard and the OrcaSlicer sync only ever
saw 4 of the 8 slots. Payloads below are trimmed from the real log attached
to the issue.
"""
from kobrax_moonraker_bridge import KobraXBridge
def _slot(index, type_="PLA", color=(1, 2, 3), status=5):
return {
"index": index, "sku": "", "type": type_, "color": list(color),
"edit_status": 0, "status": status,
"color_group": [list(color) + [255]], "icon_type": 0,
"consumables_percent": 50,
}
def _ace_box(box_id, loaded_slot=-1, n_slots=4):
return {
"id": box_id, "status": 1, "model_id": 0, "auto_feed": 1,
"loaded_slot": loaded_slot,
"feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1},
"temp": 30, "humidity": 0,
"drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"slots": [_slot(i) for i in range(n_slots)],
}
def _toolhead_box(n_slots=4):
box = _ace_box(-1, n_slots=n_slots)
return box
# ── mode detection ───────────────────────────────────────────────────────────
def test_two_ace_units_no_toolhead_is_ace_direct():
boxes = [_ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct"
# ── ace_direct aggregation ───────────────────────────────────────────────────
def test_single_ace_unit_yields_4_slots():
"""Kobra X regression: one unit, global indices 0-3 exactly as before."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct")
assert len(slots) == 4
assert [s["global_index"] for s in slots] == [0, 1, 2, 3]
assert all(s["box_id"] == 0 for s in slots)
assert loaded == -1
def test_two_ace_units_yield_8_slots():
"""Issue #95: the second unit's slots must appear as global 4-7."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
assert len(slots) == 8
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_two_ace_units_report_order_does_not_matter():
"""Boxes sorted by id — global numbering stays stable if the firmware
reports unit 1 before unit 0."""
slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct")
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_loaded_slot_on_second_unit_maps_to_global():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct")
assert loaded == 6 # 1*4 + 2
def test_loaded_slot_on_first_unit_unchanged():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct")
assert loaded == 3
# ── ace_hub regression (unchanged behavior) ──────────────────────────────────
def test_ace_hub_numbering_unchanged():
boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub"
slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub")
# 3 toolhead + 4 + 4 ACE
assert len(slots) == 11
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert [s["box_id"] for s in slots][:3] == [-1, -1, -1]
# ── _box_local_to_global / _global_to_box_slot round-trip ────────────────────
def _bridge_with_mode(mode, slots):
b = object.__new__(KobraXBridge)
b._filament_mode = mode
b._ams_slots = slots
return b
def test_box_local_to_global_ace_direct_second_unit():
b = _bridge_with_mode("ace_direct", [])
assert b._box_local_to_global(0, 2, []) == 2
assert b._box_local_to_global(1, 2, []) == 6
def test_global_to_box_slot_round_trip_two_units():
slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
b = _bridge_with_mode("ace_direct", slots)
for g in range(8):
box_id, local = b._global_to_box_slot(g)
assert (box_id, local) == (g // 4, g % 4)
assert b._box_local_to_global(box_id, local, []) == g

View File

@@ -354,6 +354,9 @@ function applyLang(){
setText('store-web-verify-msg',T.store_web_verify_msg);
setText('store-web-verify-confirm',T.store_web_verify_confirm);
setText('store-web-verify-abort',T.store_web_verify_abort);
setText('store-lbl-select-all',T.store_select_all||'Select All');
setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('store-lbl-exit-select',T.store_exit_select||'Cancel');
// Dashboard card titles
setText('d-card-progress',T.card_progress);
setText('d-card-temps',T.card_temps);
@@ -2756,10 +2759,42 @@ function aceDryStop(aceId){
function loadStore(){
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
storeFiles=d.result||[];
// Reset any pending selection - stale ids from a deleted/renamed file
// should never carry over into a fresh file list.
storeExitSelectMode();
renderStore();
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
}
// Applies the store's search/filter/sort controls to storeFiles. Shared by
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
// the user can currently see, not the entire (possibly filtered-out) list.
function _storeFilteredFiles(){
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
return files;
}
function uploadGcode(file){
if(!file) return;
var zone=document.getElementById('store-upload-zone');
@@ -2804,32 +2839,7 @@ function uploadGcode(file){
function renderStore(){
var grid=document.getElementById('store-grid');
var empty=document.getElementById('store-empty');
// Suche
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
// Filter
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
// Sortierung
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
var files=_storeFilteredFiles();
if(!storeFiles.length){
empty.textContent=T.store_empty;
@@ -2862,22 +2872,56 @@ function renderStore(){
} else if(!f.last_print_status){
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
}
return '<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">'+
var isSelected=!!_storeSelected[f.id];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
// Always visible (not just once select mode is on) - otherwise there is
// no way to ever enter select mode, since it's normally entered by
// clicking this very checkbox. White circle behind it so it stays
// legible against any thumbnail.
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
return '<div '+cardClick+'>'+
checkbox+
thumb+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
lastInfo+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
'<button onclick="storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
'<button onclick="storeDelete(\''+f.id+'\')" '+
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_storeUpdateSelectBar(files);
}
// Reflects the current selection onto the select-all checkbox (incl.
// indeterminate state), the selected-count label, and the delete button's
// disabled state. `files` is the currently-filtered/visible list.
function _storeUpdateSelectBar(files){
var selectAll=document.getElementById('store-select-all');
var countEl=document.getElementById('store-selected-count');
var delBtn=document.getElementById('store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var visibleIds=files.map(function(f){return f.id;});
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
var n=selectedVisible.length;
selectAll.checked=n>0&&n===visibleIds.length;
selectAll.indeterminate=n>0&&n<visibleIds.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function formatDur(sec){
@@ -2897,6 +2941,52 @@ var _pendingWebVerifyAutoOpen=false;
// wurde (Issue #29 / Theme-Auslagerung PR #27).
var storeFiles=[];
// Multi-select state for the GCode browser (Issue #94).
var _storeSelectMode=false;
var _storeSelected={}; // file.id -> true
function storeToggleSelect(id){
if(!_storeSelectMode){
_storeSelectMode=true;
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='flex';
}
if(_storeSelected[id])delete _storeSelected[id];
else _storeSelected[id]=true;
renderStore();
}
function storeToggleSelectAll(checked){
// Only the currently filtered/visible files are affected, so search/filter
// never causes "Select All" to silently pick up hidden files too.
var visible=_storeFilteredFiles();
_storeSelected={};
if(checked)visible.forEach(function(f){_storeSelected[f.id]=true;});
renderStore();
}
function storeExitSelectMode(){
_storeSelectMode=false;
_storeSelected={};
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='none';
renderStore();
}
function storeDeleteSelected(){
var ids=Object.keys(_storeSelected);
if(!ids.length)return;
if(!confirm((T.store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',ids.length)))return;
Promise.all(ids.map(function(id){
return fetch(_apiUrl('/kx/files/'+id),{method:'DELETE'}).then(function(r){return{id:id,ok:r.ok};});
})).then(function(results){
var failed=results.filter(function(r){return !r.ok;});
if(failed.length)clog((T.log_delete_failed||'Delete failed')+': '+failed.length,'msg-err');
storeExitSelectMode();
loadStore();
});
}
var _gcodeFilaments=[];
function _setGcodeFilamentsFromFileObj(fileObj){

View File

@@ -417,6 +417,19 @@
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>

View File

@@ -311,6 +311,8 @@
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_delete_selected": "Auswahl löschen",
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
@@ -319,6 +321,9 @@
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_exit_select": "Abbrechen",
"store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",

View File

@@ -311,6 +311,8 @@
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_delete_selected": "Delete Selected",
"store_delete_selected_confirm": "Delete {n} selected files?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
@@ -319,6 +321,9 @@
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_exit_select": "Cancel",
"store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",

View File

@@ -311,6 +311,8 @@
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_delete_selected": "Eliminar seleccionados",
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
@@ -319,6 +321,9 @@
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_exit_select": "Cancelar",
"store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",

View File

@@ -297,6 +297,8 @@
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_delete_selected": "Supprimer la sélection",
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
@@ -305,6 +307,9 @@
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_exit_select": "Annuler",
"store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",

View File

@@ -297,6 +297,8 @@
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_delete_selected": "Elimina selezionati",
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
@@ -305,6 +307,9 @@
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_exit_select": "Annulla",
"store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",

View File

@@ -311,6 +311,8 @@
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_delete_selected": "删除所选",
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
@@ -319,6 +321,9 @@
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_exit_select": "取消",
"store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",