diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 3ea079a..222d1d6 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -352,9 +352,8 @@

6.1 Файловый менеджер

Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по FID или символьному имени (пиллы и кнопка «Проверить все файлы» закреплены над прокручиваемым деревом; выбор сохраняется в localStorage). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.

diff --git a/frontend/help.html b/frontend/help.html index 2841a93..80f6ff2 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -352,9 +352,8 @@

6.1 File manager

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 FID or symbolic Name (pills pinned above the scrolling tree together with Probe all files; the choice is remembered in localStorage). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.

diff --git a/frontend/index.html b/frontend/index.html index c3f9dc3..790f040 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -861,13 +861,13 @@
- - - - | - Raw - Decoded - + + +
+ + + +
@@ -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 = ''; } + 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': 'Клонировать', diff --git a/frontend/sw.js b/frontend/sw.js index 30b4c27..e96fd18 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v211'; +const CACHE = 'simple-v212'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/fs_edit.test.js b/frontend/tests/fs_edit.test.js new file mode 100644 index 0000000..9a6c526 --- /dev/null +++ b/frontend/tests/fs_edit.test.js @@ -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: '' }); + 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: '' }); + await pysimFsEdit(); + pysimFsCancel(); + assert.strictEqual(pysimFsEditMode, false); + assert.strictEqual(h.content.innerHTML, ''); + 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"')); +}); diff --git a/pyproject.toml b/pyproject.toml index 4dd53a2..5d6ea9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.7.8" +version = "2.7.9" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index c365bee..17ce9f7 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -26,7 +26,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '2.7.8' +VERSION = '2.7.9' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE