PHP Classes

File: templates/admin/filearea_rules.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/filearea_rules.twig   Download  
File: templates/admin/filearea_rules.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 41,076 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.admin.filearea_rules.page_title_prefix', {}, locale, ['common']) }} - {{ parent() }}{% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2> <i class="fas fa-filter"></i> {{ t('ui.admin.filearea_rules.heading', {}, locale, ['common']) }} </h2> <div class="d-flex gap-2 align-items-center"> <small class="text-muted" id="rulesStatus">{{ t('ui.common.loading', {}, locale, ['common']) }}</small> <button class="btn btn-outline-secondary" id="resetRulesBtn" disabled> <i class="fas fa-undo"></i> {{ t('ui.admin.filearea_rules.reset_to_example', {}, locale, ['common']) }} </button> <button class="btn btn-fidonet" id="saveRulesBtn" disabled> <i class="fas fa-save"></i> {{ t('ui.admin.filearea_rules.save_rules', {}, locale, ['common']) }} </button> </div> </div> <div id="rulesEditorAlert"></div> <ul class="nav nav-tabs mb-3" id="rulesTabs" role="tablist"> <li class="nav-item" role="presentation"> <button class="nav-link active" id="guiTabBtn" data-bs-toggle="tab" data-bs-target="#guiTab" type="button" role="tab"> <i class="fas fa-th-list me-1"></i>{{ t('ui.admin.filearea_rules.tab_gui', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="jsonTabBtn" data-bs-toggle="tab" data-bs-target="#jsonTab" type="button" role="tab"> <i class="fas fa-code me-1"></i>{{ t('ui.admin.filearea_rules.tab_json', {}, locale, ['common']) }} </button> </li> </ul> <div class="tab-content" id="rulesTabContent"> <!-- GUI Tab --> <div class="tab-pane fade show active" id="guiTab" role="tabpanel"> <!-- Global Rules --> <div class="card mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0"><i class="fas fa-globe me-1"></i>{{ t('ui.admin.filearea_rules.global_rules_section', {}, locale, ['common']) }}</h6> <button class="btn btn-sm btn-outline-primary" id="addGlobalRuleBtn"> <i class="fas fa-plus me-1"></i>{{ t('ui.admin.filearea_rules.add_rule', {}, locale, ['common']) }} </button> </div> <div id="globalRulesContainer"></div> </div> <!-- Area Rules --> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0"><i class="fas fa-folder me-1"></i>{{ t('ui.admin.filearea_rules.area_rules_section', {}, locale, ['common']) }}</h6> <button class="btn btn-sm btn-outline-primary" id="addAreaBtn"> <i class="fas fa-plus me-1"></i>{{ t('ui.admin.filearea_rules.add_area', {}, locale, ['common']) }} </button> </div> <div class="card-body" id="areaRulesContainer"></div> </div> </div> <!-- JSON Tab --> <div class="tab-pane fade" id="jsonTab" role="tabpanel"> <div class="row g-4"> <div class="col-lg-8"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0">{{ t('ui.admin.filearea_rules.config_filename', {}, locale, ['common']) }}</h6> </div> <div class="card-body"> <textarea id="rulesJsonEditor" class="form-control" rows="26" spellcheck="false" style="font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: .85em;"></textarea> <small class="text-muted">{{ t('ui.admin.filearea_rules.editing_requires_valid_json', {}, locale, ['common']) }}</small> </div> </div> </div> <div class="col-lg-4"> <div class="card"> <div class="card-header"><h6 class="mb-0">{{ t('ui.admin.filearea_rules.macros', {}, locale, ['common']) }}</h6></div> <div class="card-body"> <ul class="list-unstyled mb-0 small"> <li><code>%basedir%</code> {{ t('ui.admin.filearea_rules.macro_basedir', {}, locale, ['common']) }}</li> <li><code>%filepath%</code> {{ t('ui.admin.filearea_rules.macro_filepath', {}, locale, ['common']) }}</li> <li><code>%filename%</code> {{ t('ui.admin.filearea_rules.macro_filename', {}, locale, ['common']) }}</li> <li><code>%filesize%</code> {{ t('ui.admin.filearea_rules.macro_filesize', {}, locale, ['common']) }}</li> <li><code>%domain%</code> {{ t('ui.admin.filearea_rules.macro_domain', {}, locale, ['common']) }}</li> <li><code>%areatag%</code> {{ t('ui.admin.filearea_rules.macro_areatag', {}, locale, ['common']) }}</li> <li><code>%uploader%</code> {{ t('ui.admin.filearea_rules.macro_uploader', {}, locale, ['common']) }}</li> <li><code>%ticfile%</code> {{ t('ui.admin.filearea_rules.macro_ticfile', {}, locale, ['common']) }}</li> <li><code>%tempdir%</code> {{ t('ui.admin.filearea_rules.macro_tempdir', {}, locale, ['common']) }}</li> </ul> </div> </div> <div class="card mt-3"> <div class="card-header"><h6 class="mb-0">{{ t('ui.admin.filearea_rules.tips', {}, locale, ['common']) }}</h6></div> <div class="card-body"> <small class="text-muted"> {{ t('ui.admin.filearea_rules.tip_1', {}, locale, ['common']) }} {{ t('ui.admin.filearea_rules.tip_2', {}, locale, ['common']) }} {{ t('ui.admin.filearea_rules.tip_3', {}, locale, ['common']) }} </small> </div> </div> </div> </div> </div> </div> <div class="mt-3 d-flex justify-content-end"> <button class="btn btn-fidonet" id="saveRulesBtnBottom" disabled> <i class="fas fa-save"></i> {{ t('ui.admin.filearea_rules.save_rules', {}, locale, ['common']) }} </button> </div> <!-- Rule Edit/Add Modal --> <div class="modal fade" id="ruleModal" tabindex="-1"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="ruleModalTitle"></h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div class="row g-3"> <div class="col-md-8"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_name', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="ruleFieldName" placeholder="My Rule"> </div> <div class="col-md-4 d-flex align-items-end"> <div class="form-check mb-2"> <input type="checkbox" class="form-check-input" id="ruleFieldEnabled"> <label class="form-check-label" for="ruleFieldEnabled">{{ t('ui.admin.filearea_rules.field_enabled', {}, locale, ['common']) }}</label> </div> </div> <div class="col-12"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_pattern', {}, locale, ['common']) }}</label> <div class="input-group"> <input type="text" class="form-control font-monospace" id="ruleFieldPattern" placeholder="/^filename\.(zip|rar)$/i"> <button type="button" class="btn btn-outline-secondary" id="patternTestToggle" title="{{ t('ui.admin.filearea_rules.pattern_test_title', {}, locale, ['common']) }}"> <i class="fas fa-vial"></i> </button> </div> <div class="form-text">{{ t('ui.admin.filearea_rules.field_pattern_hint', {}, locale, ['common']) }}</div> <!-- Pattern tester panel --> <div id="patternTesterPanel" class="border rounded p-3 mt-2 bg-light" style="display:none;"> <div class="d-flex align-items-center gap-2 mb-2"> <input type="text" class="form-control form-control-sm font-monospace" id="patternTestInput" placeholder="{{ t('ui.admin.filearea_rules.pattern_test_placeholder', {}, locale, ['common']) }}"> <span id="patternTestResult" class="text-nowrap fw-semibold" style="min-width:90px;"></span> </div> <div id="patternTestAreaFiles" class="mt-2"></div> </div> </div> <div class="col-md-8"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_script', {}, locale, ['common']) }}</label> <input type="text" class="form-control font-monospace" id="ruleFieldScript" placeholder="php %basedir%/scripts/myscript.php %filepath%"> </div> <div class="col-md-4"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_timeout', {}, locale, ['common']) }}</label> <div class="input-group"> <input type="number" class="form-control" id="ruleFieldTimeout" value="600" min="1" max="3600"> <span class="input-group-text">s</span> </div> </div> <div class="col-md-6"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_domain', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="ruleFieldDomain" placeholder="fidonet"> <div class="form-text">{{ t('ui.admin.filearea_rules.field_domain_hint', {}, locale, ['common']) }}</div> </div> <div class="col-md-6"></div> <div class="col-md-6"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_success_action', {}, locale, ['common']) }}</label> <div id="successActionChecks" class="d-flex flex-wrap gap-2 mb-2"></div> <div class="d-flex align-items-center gap-2"> <small class="text-muted text-nowrap">{{ t('ui.admin.filearea_rules.action_move', {}, locale, ['common']) }}</small> <input type="text" class="form-control form-control-sm font-monospace" id="ruleFieldSuccessMove" placeholder="AREATAG"> </div> </div> <div class="col-md-6"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.field_fail_action', {}, locale, ['common']) }}</label> <div id="failActionChecks" class="d-flex flex-wrap gap-2 mb-2"></div> <div class="d-flex align-items-center gap-2"> <small class="text-muted text-nowrap">{{ t('ui.admin.filearea_rules.action_move', {}, locale, ['common']) }}</small> <input type="text" class="form-control form-control-sm font-monospace" id="ruleFieldFailMove" placeholder="AREATAG"> </div> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-fidonet" id="ruleModalSaveBtn">{{ t('ui.common.save', {}, locale, ['common']) }}</button> </div> </div> </div> </div> <!-- Add Area Modal --> <div class="modal fade" id="addAreaModal" tabindex="-1"> <div class="modal-dialog modal-sm"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ t('ui.admin.filearea_rules.add_area_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <label class="form-label fw-semibold">{{ t('ui.admin.filearea_rules.area_tag_label', {}, locale, ['common']) }}</label> <select class="form-select font-monospace" id="newAreaTagInput"> <option value=""></option> {% for areaOption in filearea_rule_options|default([]) %} <option value="{{ areaOption.value }}">{{ areaOption.label }}</option> {% endfor %} </select> <div class="form-text">{{ t('ui.admin.filearea_rules.area_tag_hint', {}, locale, ['common']) }}</div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-fidonet" id="addAreaSaveBtn">{{ t('ui.common.add', {}, locale, ['common']) }}</button> </div> </div> </div> </div> {% endblock %} {% block scripts %} <script> // ??? State ???????????????????????????????????????????????????????????????????? let guiState = { global_rules: [], area_rules: {} }; let editingCtx = null; // { scope: 'global'|'area', areaTag: string|null, index: number } let exampleJson = null; let dirty = false; let activeTab = 'gui'; const ACTION_FLAGS = ['keep', 'delete', 'notify', 'archive', 'stop']; function uiT(key, fallback, params = {}) { return window.t ? window.t(key, params, fallback) : fallback; } // ??? Action helpers ??????????????????????????????????????????????????????????? function parseAction(str) { const parts = (str || '').split('+').map(s => s.trim()); const obj = { keep: false, delete: false, notify: false, archive: false, stop: false, move: '' }; for (const p of parts) { const pl = p.toLowerCase(); if (ACTION_FLAGS.includes(pl)) obj[pl] = true; else if (pl.startsWith('move:')) obj.move = p.slice(5); } return obj; } function serializeAction(obj) { const parts = []; for (const f of ACTION_FLAGS) { if (obj[f]) parts.push(f); } if (obj.move && obj.move.trim()) parts.push('move:' + obj.move.trim()); return parts.join('+'); } // ??? HTML helpers ????????????????????????????????????????????????????????????? function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function escAttr(s) { return escHtml(s); } // ??? Render GUI ??????????????????????????????????????????????????????????????? function renderGui() { renderGlobalRules(); renderAreaRules(); } function renderRulesTable(rules, scope, areaTag) { const scopeA = escAttr(scope); const tagA = escAttr(areaTag || ''); if (!rules || rules.length === 0) { return `<p class="text-muted small p-3 mb-0">${uiT('ui.admin.filearea_rules.no_rules', 'No rules. Click Add Rule to create one.')}</p>`; } const rows = rules.map((rule, idx) => { const enabled = rule.enabled !== false; const sa = escHtml(rule.success_action || ''); const fa = escHtml(rule.fail_action || ''); return `<tr> <td class="text-center align-middle"> <div class="form-check d-inline-block mb-0"> <input type="checkbox" class="form-check-input" ${enabled ? 'checked' : ''} onchange="toggleEnabled('${scopeA}','${tagA}',${idx},this.checked)"> </div> </td> <td class="align-middle">${escHtml(rule.name || '')}</td> <td class="align-middle"><code class="small">${escHtml(rule.pattern || '')}</code></td> <td class="align-middle"><code class="small">${sa || '<span class="text-muted">?</span>'}</code></td> <td class="align-middle"><code class="small">${fa || '<span class="text-muted">?</span>'}</code></td> <td class="align-middle text-end"> <div class="d-flex gap-1 justify-content-end"> <button class="btn btn-sm btn-outline-secondary" title="${uiT('ui.admin.filearea_rules.edit_rule_title','Edit')}" onclick="openEditModal('${scopeA}','${tagA}',${idx})"> <i class="fas fa-edit"></i> </button> <button class="btn btn-sm btn-outline-secondary" title="${uiT('ui.admin.filearea_rules.clone_rule','Clone rule')}" onclick="cloneRule('${scopeA}','${tagA}',${idx})"> <i class="fas fa-copy"></i> </button> <button class="btn btn-sm btn-outline-danger" title="${uiT('ui.admin.filearea_rules.delete_rule_confirm','Delete')}" onclick="deleteRule('${scopeA}','${tagA}',${idx})"> <i class="fas fa-trash"></i> </button> </div> </td> </tr>`; }).join(''); return `<table class="table table-sm table-hover mb-0"> <thead class="table-light"> <tr> <th style="width:60px">${uiT('ui.admin.filearea_rules.col_enabled','Enabled')}</th> <th>${uiT('ui.admin.filearea_rules.col_name','Name')}</th> <th>${uiT('ui.admin.filearea_rules.col_pattern','Pattern')}</th> <th>${uiT('ui.admin.filearea_rules.col_success','On Success')}</th> <th>${uiT('ui.admin.filearea_rules.col_fail','On Failure')}</th> <th style="width:80px"></th> </tr> </thead> <tbody>${rows}</tbody> </table>`; } function renderGlobalRules() { document.getElementById('globalRulesContainer').innerHTML = renderRulesTable(guiState.global_rules, 'global', null); } function renderAreaRules() { const container = document.getElementById('areaRulesContainer'); const tags = Object.keys(guiState.area_rules); if (tags.length === 0) { container.innerHTML = `<p class="text-muted mb-0">${uiT('ui.admin.filearea_rules.no_areas','No area rules. Click Add Area to create one.')}</p>`; return; } container.innerHTML = tags.map(tag => { const tagA = escAttr(tag); return `<div class="border rounded mb-3"> <div class="d-flex justify-content-between align-items-center px-3 py-2 bg-light border-bottom rounded-top"> <strong class="font-monospace">${escHtml(tag)}</strong> <div class="d-flex gap-2"> <button class="btn btn-sm btn-outline-primary" onclick="openAddModal('area','${tagA}')"> <i class="fas fa-plus me-1"></i>${uiT('ui.admin.filearea_rules.add_rule','Add Rule')} </button> <button class="btn btn-sm btn-outline-danger" onclick="deleteArea('${tagA}')" title="${uiT('ui.admin.filearea_rules.delete_area_confirm','Delete area')}"> <i class="fas fa-trash"></i> </button> </div> </div> ${renderRulesTable(guiState.area_rules[tag], 'area', tag)} </div>`; }).join(''); } // ??? Modal: action checkboxes ????????????????????????????????????????????????? function buildActionChecks(containerId, prefix) { const el = document.getElementById(containerId); el.innerHTML = ACTION_FLAGS.map(f => ` <div class="form-check"> <input class="form-check-input" type="checkbox" id="${prefix}_${f}"> <label class="form-check-label font-monospace small" for="${prefix}_${f}">${f}</label> </div>`).join(''); } function fillActionChecks(prefix, moveFieldId, actionStr) { const obj = parseAction(actionStr); for (const f of ACTION_FLAGS) { const el = document.getElementById(`${prefix}_${f}`); if (el) el.checked = !!obj[f]; } document.getElementById(moveFieldId).value = obj.move || ''; } function readActionChecks(prefix, moveFieldId) { const obj = { move: document.getElementById(moveFieldId).value.trim() }; for (const f of ACTION_FLAGS) { const el = document.getElementById(`${prefix}_${f}`); obj[f] = el ? el.checked : false; } return serializeAction(obj); } // ??? Open modal: add ?????????????????????????????????????????????????????????? function openAddModal(scope, areaTag) { editingCtx = { scope, areaTag: areaTag || null, index: -1 }; document.getElementById('ruleModalTitle').textContent = uiT('ui.admin.filearea_rules.add_rule_title', 'Add Rule'); document.getElementById('ruleFieldName').value = ''; document.getElementById('ruleFieldEnabled').checked = true; document.getElementById('ruleFieldPattern').value = ''; document.getElementById('ruleFieldScript').value = ''; document.getElementById('ruleFieldTimeout').value = '600'; document.getElementById('ruleFieldDomain').value = ''; buildActionChecks('successActionChecks', 'sa'); buildActionChecks('failActionChecks', 'fa'); fillActionChecks('sa', 'ruleFieldSuccessMove', ''); fillActionChecks('fa', 'ruleFieldFailMove', ''); bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleModal')).show(); } // ??? Open modal: edit ????????????????????????????????????????????????????????? function openEditModal(scope, areaTag, index) { editingCtx = { scope, areaTag: areaTag || null, index }; const rule = scope === 'global' ? guiState.global_rules[index] : guiState.area_rules[areaTag][index]; if (!rule) return; document.getElementById('ruleModalTitle').textContent = uiT('ui.admin.filearea_rules.edit_rule_title', 'Edit Rule'); document.getElementById('ruleFieldName').value = rule.name || ''; document.getElementById('ruleFieldEnabled').checked = rule.enabled !== false; document.getElementById('ruleFieldPattern').value = rule.pattern || ''; document.getElementById('ruleFieldScript').value = rule.script || ''; document.getElementById('ruleFieldTimeout').value = rule.timeout != null ? rule.timeout : 600; document.getElementById('ruleFieldDomain').value = rule.domain || ''; buildActionChecks('successActionChecks', 'sa'); buildActionChecks('failActionChecks', 'fa'); fillActionChecks('sa', 'ruleFieldSuccessMove', rule.success_action || ''); fillActionChecks('fa', 'ruleFieldFailMove', rule.fail_action || ''); bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleModal')).show(); } // Global Add Rule button wires to openAddModal document.getElementById('addGlobalRuleBtn').addEventListener('click', () => openAddModal('global', null)); // ??? Save rule from modal ????????????????????????????????????????????????????? document.getElementById('ruleModalSaveBtn').addEventListener('click', () => { const name = document.getElementById('ruleFieldName').value.trim(); if (!name) { document.getElementById('ruleFieldName').focus(); return; } const rule = { name: name, enabled: document.getElementById('ruleFieldEnabled').checked, pattern: document.getElementById('ruleFieldPattern').value.trim(), script: document.getElementById('ruleFieldScript').value.trim(), timeout: parseInt(document.getElementById('ruleFieldTimeout').value, 10) || 600, success_action: readActionChecks('sa', 'ruleFieldSuccessMove'), fail_action: readActionChecks('fa', 'ruleFieldFailMove'), }; const domain = document.getElementById('ruleFieldDomain').value.trim(); if (domain) rule.domain = domain; const { scope, areaTag, index } = editingCtx; if (scope === 'global') { if (index === -1) guiState.global_rules.push(rule); else guiState.global_rules[index] = rule; } else { if (!guiState.area_rules[areaTag]) guiState.area_rules[areaTag] = []; if (index === -1) guiState.area_rules[areaTag].push(rule); else guiState.area_rules[areaTag][index] = rule; } bootstrap.Modal.getInstance(document.getElementById('ruleModal')).hide(); setDirty(true); renderGui(); }); // ??? Toggle enabled in-table ?????????????????????????????????????????????????? function toggleEnabled(scope, areaTag, index, value) { const rule = scope === 'global' ? guiState.global_rules[index] : guiState.area_rules[areaTag][index]; if (rule) { rule.enabled = value; setDirty(true); } } // ??? Clone rule ??????????????????????????????????????????????????????????????? function cloneRule(scope, areaTag, index) { if (!confirm(uiT('ui.admin.filearea_rules.clone_rule_confirm', 'Clone rule?'))) return; const source = scope === 'global' ? guiState.global_rules[index] : guiState.area_rules[areaTag][index]; if (!source) return; const clone = Object.assign({}, source, { name: source.name + ' (copy)' }); if (scope === 'global') { guiState.global_rules.splice(index + 1, 0, clone); } else { guiState.area_rules[areaTag].splice(index + 1, 0, clone); } setDirty(true); renderGui(); } // ??? Delete rule ?????????????????????????????????????????????????????????????? function deleteRule(scope, areaTag, index) { if (!confirm(uiT('ui.admin.filearea_rules.delete_rule_confirm', 'Delete this rule?'))) return; if (scope === 'global') guiState.global_rules.splice(index, 1); else guiState.area_rules[areaTag].splice(index, 1); setDirty(true); renderGui(); } // ??? Delete area ?????????????????????????????????????????????????????????????? function deleteArea(tag) { if (!confirm(uiT('ui.admin.filearea_rules.delete_area_confirm', 'Delete this area and all its rules?'))) return; delete guiState.area_rules[tag]; setDirty(true); renderAreaRules(); } // ??? Add area modal ??????????????????????????????????????????????????????????? document.getElementById('addAreaBtn').addEventListener('click', () => { document.getElementById('newAreaTagInput').value = ''; bootstrap.Modal.getOrCreateInstance(document.getElementById('addAreaModal')).show(); setTimeout(() => document.getElementById('newAreaTagInput').focus(), 300); }); document.getElementById('newAreaTagInput').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('addAreaSaveBtn').click(); }); document.getElementById('addAreaSaveBtn').addEventListener('click', () => { const tag = document.getElementById('newAreaTagInput').value.trim(); if (!tag) return; if (!guiState.area_rules[tag]) { guiState.area_rules[tag] = []; setDirty(true); renderAreaRules(); } bootstrap.Modal.getInstance(document.getElementById('addAreaModal')).hide(); }); // ??? Tab switching: sync GUI ? JSON ?????????????????????????????????????????? document.getElementById('jsonTabBtn').addEventListener('show.bs.tab', () => { document.getElementById('rulesJsonEditor').value = JSON.stringify(guiState, null, 2); activeTab = 'json'; }); document.getElementById('guiTabBtn').addEventListener('show.bs.tab', () => { const raw = document.getElementById('rulesJsonEditor').value; let parsed; try { parsed = JSON.parse(raw); } catch (e) { setAlert(uiT('ui.admin.filearea_rules.json_parse_error', 'JSON parse error ? fix syntax before switching to the visual editor.'), 'danger'); return; } guiState = normalizeState(parsed); activeTab = 'gui'; renderGui(); }); document.getElementById('rulesJsonEditor').addEventListener('input', () => { setDirty(true); }); // ??? Normalize loaded state ??????????????????????????????????????????????????? function normalizeState(data) { const state = { global_rules: Array.isArray(data.global_rules) ? data.global_rules : [], area_rules: {} }; if (data.area_rules && typeof data.area_rules === 'object') { for (const [k, v] of Object.entries(data.area_rules)) { state.area_rules[k] = Array.isArray(v) ? v : []; } } return state; } // ??? Load / Save ?????????????????????????????????????????????????????????????? function setAlert(msg, type) { const el = document.getElementById('rulesEditorAlert'); el.innerHTML = msg ? `<div class="alert alert-${type} mb-3">${msg}</div>` : ''; } function setStatus(msg) { document.getElementById('rulesStatus').textContent = msg; } function setDirty(val) { dirty = val; document.getElementById('saveRulesBtn').disabled = !val; document.getElementById('saveRulesBtnBottom').disabled = !val; } function loadRulesConfig() { setStatus(uiT('ui.common.loading', 'Loading...')); fetch('/admin/api/filearea-rules') .then(r => r.json()) .then(data => { if (!data.success) throw new Error(window.getApiErrorMessage ? window.getApiErrorMessage(data, 'Failed to load') : (data.error || 'Failed to load')); const cfg = data.config || {}; exampleJson = cfg.example_json || null; document.getElementById('resetRulesBtn').disabled = !exampleJson; const rawJson = cfg.config_json || '{"global_rules":[],"area_rules":{}}'; guiState = normalizeState(JSON.parse(rawJson)); document.getElementById('rulesJsonEditor').value = JSON.stringify(guiState, null, 2); renderGui(); setDirty(false); setStatus(cfg.active ? uiT('ui.admin.filearea_rules.active', 'Active') : uiT('ui.admin.filearea_rules.missing_config', 'Missing config')); }) .catch(err => { setAlert(err.message || 'Failed to load rules', 'danger'); setStatus(uiT('ui.admin.filearea_rules.error', 'Error')); }); } function saveRules() { // If on JSON tab, sync JSON ? guiState first if (activeTab === 'json') { const raw = document.getElementById('rulesJsonEditor').value; try { guiState = normalizeState(JSON.parse(raw)); } catch (e) { setAlert(uiT('ui.admin.filearea_rules.invalid_json', 'Rules JSON is invalid. Please fix errors before saving.'), 'danger'); return; } } setAlert('', ''); const payload = JSON.stringify(guiState, null, 2); fetch('/admin/api/filearea-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ json: payload }) }) .then(r => r.json()) .then(data => { if (!data.success) throw new Error(window.getApiErrorMessage ? window.getApiErrorMessage(data, 'Failed to save') : (data.error || 'Failed to save')); const updated = data.config?.config_json || payload; guiState = normalizeState(JSON.parse(updated)); document.getElementById('rulesJsonEditor').value = JSON.stringify(guiState, null, 2); renderGui(); setDirty(false); setAlert(uiT('ui.admin.filearea_rules.saved_success', 'Rules saved successfully.'), 'success'); }) .catch(err => { setAlert(err.message || 'Failed to save rules', 'danger'); }); } function resetRules() { if (!exampleJson) return; if (!confirm(uiT('ui.admin.filearea_rules.reset_confirm', 'Replace current rules with the example file?'))) return; guiState = normalizeState(JSON.parse(exampleJson)); document.getElementById('rulesJsonEditor').value = JSON.stringify(guiState, null, 2); renderGui(); setDirty(true); setAlert(uiT('ui.admin.filearea_rules.loaded_example', 'Loaded example rules. Save to apply them.'), 'info'); } // ??? Pattern tester ??????????????????????????????????????????????????????????? let testerAreaFilenames = null; // null = not loaded, [] = loaded (empty or not) let testerLoading = false; /** Parse PHP-style /pattern/flags ? JS RegExp, or null on error */ function phpRegexToJs(raw) { const str = raw.trim(); if (!str) return null; const delim = str[0]; if (/[a-zA-Z0-9\\s]/.test(delim)) return null; const escapedDelim = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const m = str.match(new RegExp('^' + escapedDelim + '((?:.|\\n)*)' + escapedDelim + '([gimsuy]*)$')); if (!m) return null; try { return new RegExp(m[1], m[2]); } catch (e) { return null; } } function matchBadge(matches) { return matches ? `<span class="badge bg-success"><i class="fas fa-check me-1"></i>${uiT('ui.admin.filearea_rules.pattern_match','match')}</span>` : `<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>${uiT('ui.admin.filearea_rules.pattern_no_match','no match')}</span>`; } function updateTester() { const pattern = document.getElementById('ruleFieldPattern').value; const testVal = document.getElementById('patternTestInput').value; const resultEl = document.getElementById('patternTestResult'); const areaEl = document.getElementById('patternTestAreaFiles'); const re = phpRegexToJs(pattern); // Free-text result if (!testVal) { resultEl.innerHTML = ''; } else if (!re) { resultEl.innerHTML = `<span class="badge bg-warning text-dark">${uiT('ui.admin.filearea_rules.pattern_invalid','invalid regex')}</span>`; } else { resultEl.innerHTML = matchBadge(re.test(testVal)); } // Area file list if (testerAreaFilenames && testerAreaFilenames.length > 0) { if (!re) { areaEl.innerHTML = `<small class="text-warning">${uiT('ui.admin.filearea_rules.pattern_invalid','invalid regex')}</small>`; return; } const rows = testerAreaFilenames.map(fn => { const m = re.test(fn); const icon = m ? 'fa-check text-success' : 'fa-times text-secondary'; return `<div class="d-flex align-items-center gap-2 py-1 border-bottom"> <i class="fas ${icon}" style="width:14px"></i> <code class="small flex-grow-1">${escHtml(fn)}</code> </div>`; }).join(''); areaEl.innerHTML = `<div style="max-height:200px;overflow-y:auto;">${rows}</div>`; } } function loadTesterAreaFiles(tag, domain) { const areaEl = document.getElementById('patternTestAreaFiles'); if (!tag) { testerAreaFilenames = []; areaEl.innerHTML = ''; return; } if (testerLoading) return; testerLoading = true; testerAreaFilenames = null; areaEl.innerHTML = `<small class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>${uiT('ui.common.loading','Loading...')}</small>`; const qs = new URLSearchParams({ tag }); if (domain) qs.set('domain', domain); fetch('/admin/api/filearea-rules/filenames?' + qs) .then(r => r.json()) .then(data => { testerLoading = false; if (!data.success) { areaEl.innerHTML = ''; return; } testerAreaFilenames = data.filenames || []; if (!data.area_found) { areaEl.innerHTML = `<small class="text-muted">${uiT('ui.admin.filearea_rules.pattern_test_area_not_found','Area not found in database.')}</small>`; } else if (testerAreaFilenames.length === 0) { areaEl.innerHTML = `<small class="text-muted">${uiT('ui.admin.filearea_rules.pattern_test_no_files','No files in this area.')}</small>`; } else { areaEl.innerHTML = `<small class="text-muted mb-1 d-block">${uiT('ui.admin.filearea_rules.pattern_test_area_files','Files in area:')}</small>`; updateTester(); } }) .catch(() => { testerLoading = false; areaEl.innerHTML = ''; }); } function openTesterPanel() { const panel = document.getElementById('patternTesterPanel'); const isVisible = panel.style.display !== 'none'; if (isVisible) { panel.style.display = 'none'; return; } panel.style.display = ''; document.getElementById('patternTestInput').focus(); // Load area files if editing an area rule and not yet loaded if (editingCtx && editingCtx.scope === 'area' && editingCtx.areaTag) { const tag = editingCtx.areaTag.replace(/@.*$/, ''); const domain = editingCtx.areaTag.includes('@') ? editingCtx.areaTag.split('@')[1] : ''; const fieldDomain = document.getElementById('ruleFieldDomain').value.trim(); loadTesterAreaFiles(tag, fieldDomain || domain); } else { testerAreaFilenames = []; } updateTester(); } function resetTester() { testerAreaFilenames = null; testerLoading = false; document.getElementById('patternTesterPanel').style.display = 'none'; document.getElementById('patternTestInput').value = ''; document.getElementById('patternTestResult').innerHTML = ''; document.getElementById('patternTestAreaFiles').innerHTML = ''; } document.addEventListener('DOMContentLoaded', () => { loadI18nNamespaces(['common', 'errors']).then(() => loadRulesConfig()); document.getElementById('saveRulesBtn').addEventListener('click', saveRules); document.getElementById('saveRulesBtnBottom').addEventListener('click', saveRules); document.getElementById('resetRulesBtn').addEventListener('click', resetRules); document.getElementById('patternTestToggle').addEventListener('click', openTesterPanel); document.getElementById('ruleFieldPattern').addEventListener('input', updateTester); document.getElementById('patternTestInput').addEventListener('input', updateTester); // Reset tester when modal closes document.getElementById('ruleModal').addEventListener('hidden.bs.modal', resetTester); }); </script> {% endblock %}