From 7836eceda26fc954e79bf75c105aabe9898bb1ce Mon Sep 17 00:00:00 2001 From: DaTekShaman Date: Tue, 6 Jan 2026 15:40:56 +0300 Subject: [PATCH] Add SUB STORE YAML ASSEMBLER script for content-tag aware processing --- .../scripts/assemble-mihomo-config.js | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 config-sub-converter/scripts/assemble-mihomo-config.js diff --git a/config-sub-converter/scripts/assemble-mihomo-config.js b/config-sub-converter/scripts/assemble-mihomo-config.js new file mode 100644 index 0000000..82979bb --- /dev/null +++ b/config-sub-converter/scripts/assemble-mihomo-config.js @@ -0,0 +1,239 @@ +/** + * SUB STORE YAML ASSEMBLER (Content-Tag Aware) + * * Требование: Каждый файл должен начинаться с комментария: + * # @file: filename.yaml + */ + +// --- UTILS --- + +// Функция нормализации теперь парсит содержимое, чтобы найти имя файла +function normalizeFiles(rawFiles) { + const map = new Map(); + if (!rawFiles || !Array.isArray(rawFiles)) return map; + + rawFiles.forEach((content, index) => { + if (typeof content !== 'string') return; + + // Ищем магический тег: # @file: filename.yaml + const match = content.match(/^#\s*@file:\s*(.+?)(\s|$)/m); + + if (match) { + const filename = match[1].trim(); + map.set(filename, content); + } else { + // Если тега нет, файл остается "анонимным" и недоступным через манифест, + // но мы можем логировать это. + // console.log(`File at index ${index} has no @file tag`); + } + }); + + return map; +} + +function cleanText(text) { + return String(text || "").replace(/\r\n/g, "\n"); +} + +// Извлечение name из элемента списка +function extractName(block) { + // Ищем name: value с учетом отступов (4 пробела) + const match = block.match(/^ {4}name:\s*(?:["']?)(.*?)(?:["']?)\s*(?:#.*)?$/m); + return match ? match[1].trim() : null; +} + +// Извлечение ключа из map (0 или 2 пробела) +function extractKey(block, indentLevel) { + const spaceStr = " ".repeat(indentLevel); + const re = new RegExp(`^${spaceStr}([^#\\s][^:]+):`); + const match = block.match(re); + return match ? match[1].trim().replace(/['"]/g, "") : null; +} + +// Разбивка текста на логические блоки +function splitBlocks(text, type) { + const lines = cleanText(text).split("\n"); + const blocks = []; + let currentBuf = []; + + // Детекторы начала блока + const isListStart = (l) => l.match(/^ {2}-\s/); // " - " + const isMapStart2 = (l) => l.match(/^ {2}[^ \-#][^:]*:/); // " key:" + const isMapStart0 = (l) => l.match(/^[^ \-#][^:]*:/); // "key:" (root) + + const isStart = (l) => { + if (type === 'list') return isListStart(l); + if (type === 'map2') return isMapStart2(l); + if (type === 'map0') return isMapStart0(l); + return false; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Если строка начинается с # @file:, пропускаем ее, чтобы не мусорить в конфиге + if (line.match(/^#\s*@file:/)) continue; + + if (isStart(line)) { + if (currentBuf.length > 0) { + blocks.push(currentBuf.join("\n")); + currentBuf = []; + } + } + + // Пропуск пустых строк в начале, если буфер пуст + if (currentBuf.length === 0 && !line.trim()) continue; + + currentBuf.push(line); + } + + if (currentBuf.length > 0) { + blocks.push(currentBuf.join("\n")); + } + + return blocks; +} + +// --- MERGE LOGIC --- + +function processSection(sectionName, manifestEntries, fileMap) { + let sectionOutput = []; + const seenKeys = new Set(); + + // Добавляем заголовок секции (кроме root, x-substore и rules) + if (!['root', 'x-substore', 'rules'].includes(sectionName)) { + sectionOutput.push(`${sectionName}:`); + } + if (sectionName === 'x-substore') { + sectionOutput.push(`x-substore:`); + } + + for (const entry of manifestEntries) { + const content = fileMap.get(entry.file); + + if (!content) { + // FAIL-FAST: Если файл из манифеста не найден (нет тега или не загружен) + throw new Error(`CRITICAL: File "${entry.file}" not found inside input bundle. Did you add '# @file: ${entry.file}' header?`); + } + + const mode = entry.mode || "concat"; + sectionOutput.push(`\n# --- source: ${entry.file} | mode: ${mode} ---`); + + if (mode === 'concat') { + // Просто чистим от тега @file при вставке + const lines = cleanText(content).split('\n').filter(l => !l.match(/^#\s*@file:/)); + sectionOutput.push(lines.join('\n')); + continue; + } + + if (mode === 'first_wins') { + let blockType = 'map2'; + if (sectionName === 'root') blockType = 'map0'; + if (['proxies', 'proxy-groups'].includes(sectionName)) blockType = 'list'; + + const blocks = splitBlocks(content, blockType); + + if (blocks.length === 0) { + sectionOutput.push(`# WARNING: No valid blocks found in ${entry.file}`); + } + + for (const block of blocks) { + let id = null; + + if (blockType === 'list') { + id = extractName(block); + } else if (blockType === 'map0') { + id = extractKey(block, 0); + } else { + id = extractKey(block, 2); + } + + if (id) { + if (seenKeys.has(id)) { + sectionOutput.push(`# [SKIP] Duplicate "${id}" ignored`); + continue; + } + seenKeys.add(id); + } + + sectionOutput.push(block); + } + } + } + + return sectionOutput.join("\n"); +} + +// --- MAIN EXECUTION --- + +try { + // 1. Создаем карту файлов на основе тегов # @file: + const fileMap = normalizeFiles($files); + + // 2. Ищем Манифест + let manifestKey = null; + // Ищем файл, чье имя (из тега) начинается с 00-manifest + for (const k of fileMap.keys()) { + if (k.startsWith("00-manifest")) { + manifestKey = k; + break; + } + } + + if (!manifestKey) { + const foundFiles = Array.from(fileMap.keys()).join(", "); + throw new Error(`Manifest file (00-manifest-...) not found in headers. Found files: [${foundFiles}]`); + } + + // 3. Парсим Манифест + const manifestRaw = fileMap.get(manifestKey); + const manifestObj = ProxyUtils.yaml.safeLoad(manifestRaw); + + if (!manifestObj?.['x-substore']?.manifest) { + throw new Error("Invalid Manifest: missing x-substore.manifest structure."); + } + + const manifestList = manifestObj['x-substore'].manifest; + const replacements = manifestObj['x-substore'].replacements || []; + + // 4. Планирование порядка секций + const sectionOrder = [ + "x-substore", "root", "hosts", "sniffer", "tun", "dns", + "proxies", "proxy-providers", "proxy-groups", "rule-providers", "rules" + ]; + + const plan = {}; + sectionOrder.forEach(s => plan[s] = []); + + manifestList.forEach(entry => { + // Если секция в манифесте есть, а в нашем плане нет - добавляем динамически (на всякий случай) + if (!plan[entry.section]) plan[entry.section] = []; + plan[entry.section].push(entry); + }); + + // 5. Сборка + const finalChunks = []; + + for (const sec of sectionOrder) { + if (!plan[sec] || plan[sec].length === 0) continue; + + // Обработка секции + const secStr = processSection(sec, plan[sec], fileMap); + finalChunks.push(secStr); + } + + let result = finalChunks.join("\n\n"); + + // 6. Replacements (Literal) + if (Array.isArray(replacements)) { + replacements.forEach(rep => { + if (rep.from && rep.to) { + result = result.split(rep.from).join(rep.to); + } + }); + } + + $content = result; + +} catch (err) { + $content = `# CRITICAL ERROR in Assembler:\n# ${err.message}\n\n# Stack:\n${err.stack}`; +} \ No newline at end of file