file manager: implement the tree filter (categories + per-service)
Fill in the scaffolded file-tree filter: - FS_FILTER_GROUPS: Identifiers, PLMN selection, Provider names, WLAN/I-WLAN, SMS-related and CB-related (cell broadcast), each with its exact file list; plus one option per file-backed feature of SERVICES_FS_MAP (svc:<feature>). - "All" is the default and exclusive; a top-level "Custom files" option matches the injected custom-file nodes by their flag. - fsFilterIndexBuild inverts name -> criterion set; pysimFsFilterMatch matches by name (and custom by flag); pysimFsFilterShow keeps a directory while it has a matching (loaded) descendant, and keeps never-expanded directories. The dropdown renders All / Custom files and three optgroups (Identifiers / PLMN / Services). - i18n EN/RU for the new labels. Frontend 1145 tests green (fs_filter.test.js 19).
This commit is contained in:
1 parent
3878156646
commit
01e50d841f
3 files changed
+423
-34
No files matched your search
+134
-24
@@ -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 = '<option disabled>' + esc(t('(no filters yet)')) + '</option>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = FS_FILTER_OPTIONS.map(o =>
|
||||
'<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>').join('');
|
||||
const opt = o => '<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>';
|
||||
let out = '<option value="' + esc(FS_FILTER_ALL) + '">' + esc(t('All')) + '</option>' +
|
||||
'<option value="' + esc(FS_FILTER_CUSTOM) + '">' + esc(t('Custom files')) + '</option>';
|
||||
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 += '<optgroup label="' + esc(t(group)) + '">' + items.join('') + '</optgroup>';
|
||||
}
|
||||
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 += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||
} 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',
|
||||
|
||||
Reference in new issue
Block a user