ui: file manager read pills and Edit raw (v2.7.9)

The decoded view is a read-only field table, so pressing Edit there did
nothing (no readonly inputs to strip) and Save found nothing to write.

- Remove the redundant Read button: the Raw/Decoded pills already read
  the file, so they become explicit read actions — Read raw / Read
  decoded (Читать сырые данные / Декодировать).  They are buttons now
  so the server/card availability model disables them like the old Read
  button did.
- Edit raw (Редактировать сырые данные) always edits the hex view: from
  decoded (or with no content loaded yet) it switches back to raw and
  reads the file first; the decoded view stays read-only.
- Save/Cancel moved next to Edit raw (right-aligned group); the pills
  are inert and dimmed while editing (mode switching would discard
  unsaved changes).
- Tests: frontend/tests/fs_edit.test.js (skipRead, edit guard, decoded
  auto-switch ordering, read failure abort, cancel/reset); help EN/RU.
This commit is contained in:
2026-09-20 23:12:07 +03:00
parent 2c793720f6
commit cad14e2b5a
7 changed files with 240 additions and 29 deletions
+2 -3
View File
@@ -352,9 +352,8 @@
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 Файловый менеджер</h3>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке <em>pySim JSON (сервер)</em></li>
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке <em>pySim JSON (сервер)</em>. Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл.</li>
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
<li><strong>ADM</strong> — файлы, требующие администраторский PIN, возвращают <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; если в подходящей предустановке карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка <strong>«Проверить ADM»</strong>, а значок в заголовке (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.</li>
</ul>
+2 -3
View File
@@ -352,9 +352,8 @@
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
<li><strong>Edit</strong>modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
<li><strong>Raw / Decoded</strong> — toggle between the hex dump and a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure</li>
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file.</li>
<li><strong>Edit raw</strong>edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card&rsquo;s unblock key.</li>
</ul>
+45 -20
View File
@@ -861,13 +861,13 @@
<div id="pysim-fs-info" class="mb-1"></div>
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
<div class="flex gap-2 mt-1 items-center">
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
<button data-needs="card" onclick="pysimFsSave()" id="pysim-fs-save-btn" class="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 hidden disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
<button onclick="pysimFsCancel()" id="pysim-fs-cancel-btn" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 hidden" data-l10n="Cancel">Cancel</button>
<span class="text-xs text-gray-400 dark:text-slate-500">|</span>
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white" data-mode="raw" onclick="pysimFsSetMode('raw')" data-l10n="Raw">Raw</span>
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-mode="dec" onclick="pysimFsSetMode('dec')" data-l10n="Decoded">Decoded</span>
<button data-needs="card" onclick="pysimFsEdit()" id="pysim-fs-edit-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 ml-auto disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Edit">Edit</button>
<button type="button" data-needs="card" onclick="pysimFsSetMode('raw')" id="pysim-fs-read-raw-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white disabled:opacity-40 disabled:cursor-not-allowed" data-mode="raw" data-l10n="Read raw">Read raw</button>
<button type="button" data-needs="card" onclick="pysimFsSetMode('dec')" id="pysim-fs-read-dec-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-mode="dec" data-l10n="Read decoded">Read decoded</button>
<div class="ml-auto flex gap-2">
<button data-needs="card" onclick="pysimFsSave()" id="pysim-fs-save-btn" class="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 hidden disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
<button onclick="pysimFsCancel()" id="pysim-fs-cancel-btn" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 hidden" data-l10n="Cancel">Cancel</button>
<button data-needs="card" onclick="pysimFsEdit()" id="pysim-fs-edit-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Edit raw">Edit raw</button>
</div>
</div>
<div id="pysim-fs-status" class="mt-1 text-xs font-mono text-gray-500 dark:text-slate-400" style="min-height:1.2em"></div>
</div>
@@ -1413,7 +1413,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '2.7.8';
const SIMPLE_VERSION = '2.7.9';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -7176,6 +7176,7 @@ async function pysimFsClickFile(name) {
}
async function pysimFsRead() {
if (!pysimFsSelected) return false;
const rawName = pysimFsSelected;
const out = document.getElementById('pysim-fs-content');
const statusEl = document.getElementById('pysim-fs-status');
@@ -7188,7 +7189,7 @@ async function pysimFsRead() {
const data = await pysimFetch('/api/read', body);
if (!data.success) {
pysimFsShowError(statusEl, data.sw, data.error);
return;
return false;
}
statusEl.textContent = 'SW: ' + data.sw + ' OK';
if (pysimFsDecodedMode) {
@@ -7206,8 +7207,10 @@ async function pysimFsRead() {
} else {
out.innerHTML = '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '(no output)') + '</textarea>';
}
return true;
} catch (e) {
statusEl.textContent = 'Error: ' + e.message;
return false;
}
}
@@ -7267,13 +7270,22 @@ async function pysimFsLoadPySimJson() {
let pysimFsEditMode = false;
let pysimFsEditData = null;
function pysimFsEdit() {
// Edit raw: transparent files become an editable textarea, record files
// per-record inputs with selection checkboxes. The decoded view is a
// read-only field table and a freshly selected file has no content yet, so
// make sure the editable raw view is loaded first (the pills are read
// actions; Edit switches back to raw and reads on demand).
async function pysimFsEdit() {
const out = document.getElementById('pysim-fs-content');
if (pysimFsDecodedMode || !out.innerHTML.trim()) {
pysimFsSetMode('raw', true);
if (!await pysimFsRead()) return;
}
pysimFsEditMode = true;
document.getElementById('pysim-fs-read-btn').classList.add('hidden');
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
const out = document.getElementById('pysim-fs-content');
pysimFsPillsEnabled(false);
out.querySelectorAll('textarea, input:not([type=checkbox])').forEach(el => el.removeAttribute('readonly'));
out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
out.querySelectorAll('input:not([type=checkbox])').forEach(el => {
@@ -7287,10 +7299,10 @@ function pysimFsEdit() {
function pysimFsCancel() {
pysimFsEditMode = false;
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
pysimFsPillsEnabled(true);
const out = document.getElementById('pysim-fs-content');
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
pysimFsEditData = null;
@@ -7350,7 +7362,12 @@ async function pysimFsSave() {
}
}
function pysimFsSetMode(mode) {
// The Read raw / Read decoded pills are the read actions: they switch the
// view mode and (re-)read the selected file. `skipRead` updates only the
// pills — used by pysimFsEdit() right before its own read. While editing
// they are inert: re-rendering would discard unsaved changes.
function pysimFsSetMode(mode, skipRead) {
if (pysimFsEditMode) return;
pysimFsDecodedMode = mode === 'dec';
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
const active = el.dataset.mode === mode;
@@ -7361,18 +7378,25 @@ function pysimFsSetMode(mode) {
el.classList.toggle('text-gray-700', !active);
el.classList.toggle('dark:text-slate-300', !active);
});
// Re-render the open file in the new mode (not while editing: that would
// discard unsaved changes).
if (pysimFsSelected && !pysimFsEditMode) pysimFsRead();
if (!skipRead && pysimFsSelected) pysimFsRead();
}
// Visual cue for the inert pills while editing (pysimFsSetMode guards the
// click; this only dims them).
function pysimFsPillsEnabled(enabled) {
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
el.classList.toggle('opacity-50', !enabled);
el.classList.toggle('pointer-events-none', !enabled);
});
}
function pysimFsResetEdit() {
pysimFsEditMode = false;
pysimFsEditData = null;
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
pysimFsPillsEnabled(true);
}
// ===== proactive UICC events =====
@@ -13055,11 +13079,12 @@ const LANG_RU = {
'Custom files': 'Пользовательские файлы',
'pySim command line': 'Командная строка pySim',
'Raw APDU': 'Отправка APDU',
'Read': 'Прочитать',
'Read raw': 'Читать сырые данные',
'Read decoded': 'Декодировать',
'Edit raw': 'Редактировать сырые данные',
'Refresh': 'Обновить',
'Last refresh': 'Последнее обновление',
'Save': 'Сохранить',
'Raw': 'Данные как на карте',
'Decoded': 'Декодированные данные',
'Edit': 'Редактировать',
'Clone': 'Клонировать',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v211';
const CACHE = 'simple-v212';
const URLS = [
'index.html',
'help.html',
+188
View File
@@ -0,0 +1,188 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsDecodedMode = false;\n'
+ 'var pysimFsEditMode = false;\n'
+ 'var pysimFsEditData = null;\n'
+ 'var pysimFsSelected = null;\n';
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
'pysimFsCancel', 'pysimFsResetEdit']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
let events = [];
let readCalls = 0;
let readResult = true;
let content, buttons, pills, editables, checkboxes, inputs;
function fakeClassList() {
const set = new Set();
return {
add: (...c) => c.forEach(x => set.add(x)),
remove: (...c) => c.forEach(x => set.delete(x)),
toggle: (c, on) => { if (on) set.add(c); else set.delete(c); },
contains: c => set.has(c),
};
}
function fakeEl() {
return { classList: fakeClassList(), dataset: {} };
}
function setup(opts) {
opts = opts || {};
events = [];
readCalls = 0;
readResult = opts.readResult !== false;
editables = [{ removeAttribute: () => events.push('readonly-removed') }];
checkboxes = [fakeEl()];
inputs = [{ addEventListener: () => {} }];
content = fakeEl();
content.innerHTML = opts.contentHtml || '';
content.querySelectorAll = sel => {
if (sel === '.pysim-fs-record-cb') return checkboxes;
if (sel.indexOf('textarea') === 0) return editables;
return inputs;
};
buttons = {
'pysim-fs-edit-btn': fakeEl(),
'pysim-fs-save-btn': fakeEl(),
'pysim-fs-cancel-btn': fakeEl(),
};
buttons['pysim-fs-save-btn'].classList.add('hidden');
buttons['pysim-fs-cancel-btn'].classList.add('hidden');
pills = [fakeEl(), fakeEl()];
pills[0].dataset.mode = 'raw';
pills[1].dataset.mode = 'dec';
pysimFsDecodedMode = !!opts.decoded;
pysimFsEditMode = false;
pysimFsEditData = null;
pysimFsSelected = 'EF.IMSI';
globalThis.document = {
getElementById: id => id === 'pysim-fs-content' ? content : (buttons[id] || null),
querySelectorAll: sel => sel === '.pysim-mode-pill' ? pills : [],
};
globalThis.pysimFsRead = async () => {
readCalls++;
events.push('read');
return readResult;
};
return {
content,
btn: id => buttons['pysim-fs-' + id + '-btn'],
pill: mode => pills.find(p => p.dataset.mode === mode),
reads: () => readCalls,
readonlyRemoved: () => events.filter(e => e === 'readonly-removed').length,
events,
};
}
test('Read raw / Read decoded pills switch the view and read the file', () => {
const h = setup();
pysimFsSetMode('raw', true);
assert.strictEqual(pysimFsDecodedMode, false);
assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
pysimFsSetMode('dec');
assert.strictEqual(pysimFsDecodedMode, true);
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
});
test('pill clicks are ignored while editing', () => {
const h = setup();
pysimFsDecodedMode = true;
pysimFsEditMode = true;
pysimFsSetMode('raw');
assert.strictEqual(pysimFsDecodedMode, true, 'the view mode must not change mid-edit');
assert.strictEqual(h.reads(), 0);
});
test('Edit raw keeps a loaded raw view and enters edit mode without re-reading', async () => {
const h = setup({ contentHtml: '<textarea readonly>aa</textarea>' });
await pysimFsEdit();
assert.strictEqual(h.reads(), 0);
assert.strictEqual(pysimFsEditMode, true);
assert.ok(h.btn('edit').classList.contains('hidden'));
assert.ok(!h.btn('save').classList.contains('hidden'));
assert.ok(!h.btn('cancel').classList.contains('hidden'));
assert.strictEqual(h.readonlyRemoved(), 1);
assert.ok(h.pill('raw').classList.contains('opacity-50'), 'pills are dimmed');
assert.ok(h.pill('raw').classList.contains('pointer-events-none'));
});
test('Edit raw switches from the decoded view to raw and reads before editing', async () => {
const h = setup({ decoded: true });
await pysimFsEdit();
assert.strictEqual(pysimFsDecodedMode, false);
assert.strictEqual(h.reads(), 1);
assert.deepStrictEqual(h.events, ['read', 'readonly-removed'],
'the raw view must be rendered before the readonly attributes are stripped');
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
assert.strictEqual(pysimFsEditMode, true);
});
test('Edit raw reads a fresh selection and aborts if the read fails', async () => {
const h = setup({ readResult: false });
await pysimFsEdit();
assert.strictEqual(h.reads(), 1);
assert.strictEqual(pysimFsEditMode, false);
assert.ok(h.btn('save').classList.contains('hidden'));
assert.ok(!h.pill('raw').classList.contains('opacity-50'));
});
test('Cancel restores the view and re-enables the pills', async () => {
const h = setup({ contentHtml: '<textarea readonly>aa</textarea>' });
await pysimFsEdit();
pysimFsCancel();
assert.strictEqual(pysimFsEditMode, false);
assert.strictEqual(h.content.innerHTML, '<textarea readonly>aa</textarea>');
assert.ok(!h.pill('raw').classList.contains('opacity-50'));
assert.ok(!h.pill('raw').classList.contains('pointer-events-none'));
assert.ok(!h.btn('edit').classList.contains('hidden'));
assert.ok(h.btn('save').classList.contains('hidden'));
assert.ok(h.btn('cancel').classList.contains('hidden'));
});
test('Reset edit re-enables the pills', () => {
const h = setup();
pysimFsEditMode = true;
pysimFsPillsEnabled(false);
pysimFsResetEdit();
assert.strictEqual(pysimFsEditMode, false);
assert.ok(!h.pill('dec').classList.contains('opacity-50'));
});
test('the file manager row exposes the read pills and Edit raw', () => {
assert.ok(!html.includes('id="pysim-fs-read-btn"'), 'the redundant Read button is gone');
assert.ok(html.includes('data-l10n="Read raw">Read raw<'));
assert.ok(html.includes('data-l10n="Read decoded">Read decoded<'));
assert.ok(html.includes('data-l10n="Edit raw">Edit raw<'));
assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
});