diff --git a/frontend/index.html b/frontend/index.html
index 7b3013d..6ae32b6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -16077,49 +16077,152 @@ function pysimFsSetSort(mode) {
}
// The tree filter: a multiselect of file criteria, applied when the tree
-// renders. The option list is large (dozens of items) and not decided yet —
-// populate FS_FILTER_OPTIONS later; the mechanism (state, persistence,
-// per-node match, render wiring) is scaffolded here.
-const FS_FILTER_OPTIONS = [
- // { id: 'decoded-editor', label: 'Has a decoded editor' },
- // { id: 'present', label: 'Present on card' },
- // ... to be decided
+// renders. The criteria name standard files by their symbolic name - the
+// same value in the tree nodes and in frontend/uicc_files.json - so a file
+// can belong to several criteria (EF.SPN is a provider name and the file of
+// the "Service Provider Name" service). `all` is the exclusive default.
+// The categories are shown under three headings (`group`): the subscriber
+// data, the network-selection data, and services (the SMS files plus one
+// option per file-backed feature of the service cross-reference).
+const FS_FILTER_ALL = 'all';
+// "Custom files" is not a name-based criterion: it matches the injected
+// custom-file nodes by their `custom` flag (user-chosen names).
+const FS_FILTER_CUSTOM = 'custom';
+const FS_FILTER_GROUPS = [
+ { id: 'identifiers', label: 'Identifiers', group: 'Identifiers', files: [
+ 'EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2'] },
+ { id: 'plmn-selection', label: 'PLMN selection', group: 'PLMN', files: [
+ 'EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
+ 'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN'] },
+ { id: 'provider-names', label: 'Provider names', group: 'Identifiers', files: [
+ 'EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL'] },
+ { id: 'wlan', label: 'WLAN / I-WLAN', group: 'PLMN', files: [
+ 'EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
+ 'EF.WLRPLMN', 'EF.HPLMNDAI'] },
+ // EF.PSISMSC is a standard name pySim does not model, so it has no tree
+ // node to match - it stays listed for completeness.
+ { id: 'sms', label: 'SMS-related', group: 'Services', files: [
+ 'EF.SMS', 'EF.SMSS', 'EF.SMSP', 'EF.SMSR', 'EF.PSISMSC'] },
+ { id: 'cb', label: 'CB-related', group: 'Services', files: [
+ 'EF.CBMI', 'EF.CBMID', 'EF.CBMIR'] },
];
+const FS_FILTER_GROUP_ORDER = ['Identifiers', 'PLMN', 'Services'];
-let pysimFsFilter = new Set();
-try {
- const saved = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
- if (Array.isArray(saved)) pysimFsFilter = new Set(saved);
-} catch (e) { /* ignore */ }
+// One option per file-backed feature of the service cross-reference: a
+// capability/RFU row has no files, so there is nothing to filter to. The
+// map is declared later in this file, so it is read when this is called,
+// never while the filter block itself is evaluated.
+function fsFilterServiceOptions() {
+ return Object.keys(SERVICES_FS_MAP)
+ .filter(name => (SERVICES_FS_MAP[name] || []).length)
+ .map(name => ({ id: 'svc:' + name, label: name, files: SERVICES_FS_MAP[name] }));
+}
+
+// The inverted index: uppercased file name -> Set(criterion ids). It is
+// static (the file lists and the service map do not change), so it is built
+// once on first use.
+let _fsFilterIndex = null;
+
+function fsFilterIndexBuild(groups, services) {
+ const idx = new Map();
+ for (const o of (groups || []).concat(services || [])) {
+ for (const f of (o.files || [])) {
+ const key = String(f).toUpperCase();
+ if (!idx.has(key)) idx.set(key, new Set());
+ idx.get(key).add(o.id);
+ }
+ }
+ return idx;
+}
+
+function fsFilterIndex() {
+ if (!_fsFilterIndex) _fsFilterIndex = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
+ return _fsFilterIndex;
+}
+
+let pysimFsFilter = new Set([FS_FILTER_ALL]);
+
+// The persisted selection, normalised: `all` is exclusive, and a missing or
+// unusable entry falls back to `all` (show everything).
+function pysimFsFilterRestore() {
+ let saved = [];
+ try {
+ const raw = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
+ if (Array.isArray(raw)) saved = raw.filter(v => typeof v === 'string' && v);
+ } catch (e) { /* ignore a broken entry */ }
+ pysimFsFilter = new Set(saved);
+ if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) pysimFsFilter = new Set([FS_FILTER_ALL]);
+}
+pysimFsFilterRestore();
function pysimFsFilterRender() {
const el = document.getElementById('pysim-fs-filter');
if (!el) return;
- if (!FS_FILTER_OPTIONS.length) {
+ const services = fsFilterServiceOptions();
+ if (!FS_FILTER_GROUPS.length && !services.length) {
el.innerHTML = '';
return;
}
- el.innerHTML = FS_FILTER_OPTIONS.map(o =>
- '').join('');
+ const opt = o => '';
+ let out = '' +
+ '';
+ for (const group of FS_FILTER_GROUP_ORDER) {
+ const items = FS_FILTER_GROUPS.filter(g => g.group === group).map(opt);
+ if (group === 'Services') items.push(...services.map(opt));
+ if (items.length) out += '';
+ }
+ el.innerHTML = out;
el.querySelectorAll('option').forEach(o => { o.selected = pysimFsFilter.has(o.value); });
}
function pysimFsSetFilter() {
const el = document.getElementById('pysim-fs-filter');
if (!el) return;
- pysimFsFilter = new Set(Array.from(el.selectedOptions).map(o => o.value));
+ const prev = pysimFsFilter;
+ const next = new Set(Array.from(el.selectedOptions).map(o => o.value).filter(v => v));
+ // `all` is exclusive: choosing it clears the category selections, choosing
+ // a category clears it. A native multiselect's onchange reports only the
+ // new selection, so the previous one tells the two gestures apart: `all`
+ // just appearing means it was clicked (it clears the rest); it staying
+ // next to a new category means the category was clicked (it drops `all`).
+ if (next.has(FS_FILTER_ALL)) {
+ if (!prev.has(FS_FILTER_ALL)) {
+ next.clear();
+ next.add(FS_FILTER_ALL);
+ } else {
+ next.delete(FS_FILTER_ALL);
+ }
+ }
+ if (!next.size) next.add(FS_FILTER_ALL);
+ pysimFsFilter = next;
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
+ pysimFsFilterRender();
pysimFsRenderTree();
}
-// Whether a node passes the active filter. With no filter active the tree
-// shows everything. When FS_FILTER_OPTIONS is populated, this maps each
-// active id to a per-node test (kind, name/fid, presence, decoded-editor
-// availability, ...). Note for later: a directory should stay visible when
-// it contains any matching descendant, not only when it matches itself.
+// Whether a file node passes the active filter. An empty selection and
+// `all` both show everything; otherwise the node's name must be listed under
+// one of the selected criteria.
function pysimFsFilterMatch(node) {
- if (!pysimFsFilter.size) return true;
- return true;
+ if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
+ // "Custom files" matches by the node's custom flag, not its name (custom
+ // files carry user-chosen names outside the standard list).
+ if (pysimFsFilter.has(FS_FILTER_CUSTOM) && node && node.custom) return true;
+ const name = String((node && node.name) || '').toUpperCase();
+ const ids = name ? fsFilterIndex().get(name) : null;
+ if (!ids) return false;
+ for (const id of pysimFsFilter) if (ids.has(id)) return true;
+ return false;
+}
+
+// Whether a node stays in the filtered tree. A matching file shows; a
+// directory shows when it matches itself, has a matching loaded descendant,
+// or was never expanded (its children are unknown, so it cannot be pruned).
+function pysimFsFilterShow(node) {
+ if (pysimFsFilterMatch(node)) return true;
+ if (!node || !node.isDir) return false;
+ if (!node.children) return true;
+ return node.children.some(c => pysimFsFilterShow(c));
}
async function pysimFsRefresh() {
@@ -16320,7 +16423,7 @@ function pysimFsRenderNode(node, depth) {
html += '
' + pad + ' ' + esc(t('(empty)')) + '
';
} else {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
- if (!pysimFsFilterMatch(child)) continue;
+ if (!pysimFsFilterShow(child)) continue;
html += pysimFsRenderNode(child, depth + 1);
}
}
@@ -27765,6 +27868,13 @@ const LANG_RU = {
'Name': 'Имя',
'Filter:': 'Фильтр:',
'(no filters yet)': '(фильтров пока нет)',
+ 'Identifiers': 'Идентификаторы',
+ 'PLMN selection': 'Выбор PLMN',
+ 'Provider names': 'Имена операторов',
+ 'WLAN / I-WLAN': 'WLAN / I-WLAN',
+ 'SMS-related': 'Связанное с SMS',
+ 'CB-related': 'Связанное с CB',
+ 'Custom files': 'Пользовательские файлы',
'Probe all files': 'Проверить все файлы',
'Feature': 'Функция',
'SST': 'SST',
diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js
index e10ea81..de283e3 100644
--- a/frontend/tests/fs_filter.test.js
+++ b/frontend/tests/fs_filter.test.js
@@ -21,21 +21,297 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
-// module state the extracted match function closes over
-let pysimFsFilter = new Set();
-eval(extractFunc(html, 'pysimFsFilterMatch'));
+function extractConst(src, name) {
+ const m = new RegExp('const ' + name + ' = ').exec(src);
+ if (!m) throw new Error('const ' + name + ' not found');
+ const begin = m.index + m[0].length;
+ const opener = src[begin];
+ const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
+ if (!closer) throw new Error('unsupported const ' + name);
+ let depth = 0, i = begin;
+ for (; i < src.length; i++) {
+ if (src[i] === opener) depth++;
+ else if (src[i] === closer) {
+ depth--;
+ if (depth === 0) { i++; break; }
+ }
+ }
+ return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
+}
+
+function extractStrConst(src, name) {
+ const m = new RegExp("const " + name + " = '([^']*)';").exec(src);
+ if (!m) throw new Error('const ' + name + ' not found');
+ return 'var ' + name + " = '" + m[1] + "';\n";
+}
+
+// module state the extracted functions close over
+let pysimFsFilter = new Set(['all']);
+let _fsFilterIndex = null;
+const _store = {};
+let _treeRenders = 0;
+
+globalThis.localStorage = {
+ getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
+ setItem: (k, v) => { _store[k] = String(v); },
+ removeItem: k => { delete _store[k]; },
+};
+globalThis.esc = s => String(s);
+globalThis.t = k => k;
+globalThis.pysimFsRenderTree = () => { _treeRenders++; };
+let _select = null;
+globalThis.document = { getElementById: id => (id === 'pysim-fs-filter' ? _select : null) };
+
+let code = '';
+for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex',
+ 'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterRender', 'pysimFsSetFilter',
+ 'pysimFsFilterRestore']) {
+ code += extractFunc(html, f) + '\n';
+}
+for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c);
+code += extractStrConst(html, 'FS_FILTER_ALL');
+code += extractStrConst(html, 'FS_FILTER_CUSTOM');
+eval(code);
+
+function withFilter(ids, fn) {
+ const saved = pysimFsFilter;
+ pysimFsFilter = new Set(ids);
+ try { return fn(); } finally { pysimFsFilter = saved; }
+}
+
+function fakeSelect(selected) {
+ const options = (selected || []).map(v => ({ value: v, selected: true }));
+ return {
+ _html: '',
+ get innerHTML() { return this._html; },
+ set innerHTML(v) {
+ this._html = v;
+ options.length = 0;
+ for (const m of v.matchAll(/