🤞 Sync 2026-06-15 08:50:27

This commit is contained in:
github-actions[bot] 2026-06-15 08:50:27 +08:00
parent 9475a645b7
commit d2ec4a48f6
12 changed files with 1252 additions and 143 deletions

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.12.0
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)

View File

@ -0,0 +1,171 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
function deriveAirportName(value) {
try {
const url = new URL(String(value || ''));
const filename = url.searchParams.get('filename');
if (filename) {
const name = filename.replace(/\.(?:ya?ml)$/i, '').trim();
if (name)
return name;
}
return url.hostname || '机场_1';
} catch (e) {
return '机场_1';
}
}
function nextPastedName(names) {
const used = {};
(names || []).forEach(function(name) { used[String(name)] = true; });
let index = 1;
while (used['机场_' + index])
index++;
return '机场_' + index;
}
function makeAirportId() {
return 'airport_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
function backendId(id) {
return String(id || '').replace(/[^A-Za-z0-9]/g, '') || 'airport';
}
function backendGroupName(name, backend, id) {
const value = String(name || '').trim();
if (isGroupNameValid(value, backend))
return value || backendId(id);
return backendId(id);
}
function isGroupNameValid(name, backend) {
const value = String(name || '').trim();
return !!value && (backend !== 'dae' || /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value));
}
// Coerce an auto-derived name (URL hostname/filename) into a name the third
// step accepts, so the group name is pre-filled valid and needs no manual fix.
// daed allows any non-empty name (incl. CJK); dae requires
// /^[A-Za-z_][A-Za-z0-9_-]*$/, so collapse illegal chars to '_' and drop a
// leading non-letter/underscore run.
function sanitizeGroupName(name, backend) {
const value = String(name || '').trim();
if (!value || backend !== 'dae')
return value;
const cleaned = value
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^[^A-Za-z_]+/, '')
.replace(/[-_]+$/, '');
return cleaned || 'airport';
}
function hashSource(value) {
// LuCI is commonly served over plain HTTP, where Web Crypto is unavailable.
// This stable opaque key is only used to match a previously managed source.
let h1 = 0x811c9dc5, h2 = 0x9e3779b9, h3 = 0x85ebca6b, h4 = 0xc2b2ae35;
const text = unescape(encodeURIComponent(String(value || '')));
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 0x01000193);
h2 = Math.imul(h2 ^ code, 0x5bd1e995);
h3 = Math.imul(h3 ^ code, 0x27d4eb2d);
h4 = Math.imul(h4 ^ code, 0x165667b1);
}
const words = [ h1, h2, h3, h4, h1 ^ h3, h2 ^ h4, h1 ^ h2 ^ h4, h1 ^ h2 ^ h3 ^ h4 ];
return Promise.resolve(words.map(function(word) {
return (word >>> 0).toString(16).padStart(8, '0');
}).join(''));
}
function list(value) {
if (Array.isArray(value))
return value.filter(Boolean).map(String);
return value ? [ String(value) ] : [];
}
function parseAirportSection(section) {
if (!section)
return null;
return {
sid: section['.name'],
id: String(section.id || ''),
backend: String(section.backend || ''),
name: String(section.name || ''),
source_hash: String(section.source_hash || ''),
group_id: String(section.group_id || ''),
node_ids: list(section.node_id)
};
}
function airportSectionValues(record) {
return {
id: String(record.id || ''),
backend: String(record.backend || ''),
name: String(record.name || ''),
source_hash: String(record.sourceHash || record.source_hash || ''),
group_id: String(record.groupId || record.group_id || ''),
node_id: list(record.nodeIds || record.node_ids)
};
}
function matchAirport(records, candidate) {
const sameBackend = (records || []).filter(function(record) {
return record.backend === candidate.backend;
});
if (candidate.selectedId) {
const selected = sameBackend.find(function(record) { return record.id === candidate.selectedId; });
if (selected)
return selected;
}
if (candidate.sourceHash) {
const bySource = sameBackend.find(function(record) { return record.source_hash === candidate.sourceHash; });
if (bySource)
return bySource;
}
return sameBackend.find(function(record) { return record.name === candidate.name; }) || null;
}
function safeOldNodeIds(oldIds, newIds, otherAirportNodeIds, otherGroupNodeIds) {
const keep = {};
list(newIds).forEach(function(id) { keep[id] = true; });
(otherAirportNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); });
(otherGroupNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); });
return list(oldIds).filter(function(id) { return !keep[id]; });
}
function findManagedGroup(groups, record) {
if (!record || !record.group_id)
return null;
return (groups || []).find(function(group) {
return group.id === record.group_id && group.name === record.name;
}) || null;
}
const api = {
deriveAirportName: deriveAirportName,
nextPastedName: nextPastedName,
makeAirportId: makeAirportId,
backendId: backendId,
backendGroupName: backendGroupName,
isGroupNameValid: isGroupNameValid,
sanitizeGroupName: sanitizeGroupName,
hashSource: hashSource,
parseAirportSection: parseAirportSection,
airportSectionValues: airportSectionValues,
matchAirport: matchAirport,
findManagedGroup: findManagedGroup,
safeOldNodeIds: safeOldNodeIds
};
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
if (typeof baseclass !== 'undefined')
return baseclass.extend(api);
return api;

View File

@ -94,6 +94,26 @@ function convertSs(node) {
return 'ss://' + auth + '@' + endpoint(node.server, node.port) + query + '#' + encodeURIComponent(node.name || 'Node');
}
function convertSsr(node) {
requireFields(node, [ 'server', 'port', 'cipher', 'password', 'protocol', 'obfs' ]);
const query = [];
query.push('remarks=' + base64Url(node.name || 'Node'));
if (node['protocol-param'])
query.push('protoparam=' + base64Url(node['protocol-param']));
if (node['obfs-param'])
query.push('obfsparam=' + base64Url(node['obfs-param']));
const payload = [
node.server,
node.port,
node.protocol,
node.cipher,
node.obfs,
base64Url(node.password)
].join(':') + '/?' + query.join('&');
return 'ssr://' + base64Url(payload);
}
function convertVmess(node) {
requireFields(node, [ 'server', 'port', 'uuid' ]);
const network = node.network || 'tcp';
@ -200,10 +220,22 @@ function convertAnytls(node) {
(params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node');
}
function isMetadataProxy(node) {
const name = String(node && node.name || '').trim();
if (!name)
return false;
return /^(?:traffic|bandwidth|expire|expiry|expiration|subscription(?:\s+info)?|official\s+website|website)\s*[:|]/i.test(name) ||
/^(?:(?:剩余|可用|已用|总)?流量|套餐到期|到期时间|到期日|过期时间|有效期|订阅信息)\s*[:|]/i.test(name) ||
/^(?:官方网站|官网地址|官方网址|网站地址)\s*[:|]/i.test(name) ||
/^(?:加入|联系|客服)?\s*QQ\s*(?:群|交流群|客服|联系)\s*[:|]/i.test(name);
}
function convertProxy(node) {
const type = String(node && node.type || '').toLowerCase();
const converters = {
ss: convertSs,
ssr: convertSsr,
vmess: convertVmess,
vless: convertVless,
trojan: convertTrojan,
@ -249,6 +281,7 @@ function normalizeLink(link) {
const api = {
convertProxy: convertProxy,
convertProxies: convertProxies,
isMetadataProxy: isMetadataProxy,
normalizeLink: normalizeLink
};

View File

@ -5,12 +5,53 @@
'require uci';
'require ui';
'require view';
'require view.daede.airport-sync as airportSync';
'require view.daede.backend as backend';
'require view.daede.clash-converter as clashConverter';
'require view.daede.styles as styles';
const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh';
const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh';
const FETCH_CHUNK_BYTES = 16384;
function decodeHexChunks(chunks) {
const total = chunks.reduce(function(size, chunk) { return size + chunk.length / 2; }, 0);
const bytes = new Uint8Array(total);
let offset = 0;
chunks.forEach(function(chunk) {
for (let i = 0; i < chunk.length; i += 2)
bytes[offset++] = parseInt(chunk.slice(i, i + 2), 16);
});
return new TextDecoder('utf-8').decode(bytes);
}
function fetchYaml(url, userAgent) {
let token = '';
return fs.exec(FETCHER, [ url, userAgent, 'handle' ]).then(function(res) {
if (!res || res.code !== 0)
throw new Error((res && (res.stderr || res.stdout)) || _('Fetch failed'));
const match = String(res.stdout || '').trim().match(/^([A-Za-z0-9]+)\t(\d+)$/);
if (!match)
throw new Error(_('Fetcher returned an invalid response'));
token = match[1];
const chunks = Math.ceil(Number(match[2]) / FETCH_CHUNK_BYTES);
const values = [];
let chain = Promise.resolve();
for (let index = 0; index < chunks; index++) {
chain = chain.then(function() {
return fs.exec(FETCHER, [ 'chunk', token, String(index) ]).then(function(part) {
if (!part || part.code !== 0)
throw new Error((part && (part.stderr || part.stdout)) || _('Failed to read subscription data'));
values.push(String(part.stdout || '').trim());
});
});
}
return chain.then(function() { return decodeHexChunks(values); });
}).finally(function() {
if (token)
fs.exec(FETCHER, [ 'cleanup', token ]).catch(function() {});
});
}
function loadYamlParser() {
if (window.jsyaml && window.jsyaml.load)
@ -100,26 +141,80 @@ function uniqueNodeTag(existing, index) {
return tag;
}
function applyUciChanges() {
return uci.save().then(function() {
return uci.changes().then(function(changes) {
return changes && Object.keys(changes).length ? uci.apply() : null;
});
}).then(function() {
// uci.apply() resolves before its rollback-confirm RPC has finished.
// A second immediate apply is rejected, so wait for that confirmation.
return new Promise(function(resolve) { window.setTimeout(resolve, 1800); });
});
}
function airportRecords() {
return (uci.sections('daede', 'airport') || []).map(airportSync.parseAirportSection).filter(Boolean);
}
function uciSection(config, sid) {
return (uci.sections(config) || []).find(function(section) { return section['.name'] === sid; }) || null;
}
function writeAirportRecord(existing, record) {
const sid = existing && existing.sid ? existing.sid : uci.add('daede', 'airport');
const values = airportSync.airportSectionValues(record);
Object.keys(values).forEach(function(key) {
uci.set('daede', sid, key, values[key]);
});
return sid;
}
function looksLikeBase64Subscription(text) {
const value = String(text || '').replace(/\s+/g, '');
if (!value || !/^[A-Za-z0-9+/_=-]+$/.test(value))
return false;
try {
const decoded = atob(value.replace(/-/g, '+').replace(/_/g, '/'));
return /(?:ss|ssr|vmess|vless|trojan|tuic|hysteria2|hy2|anytls):\/\//i.test(decoded);
} catch (e) {
return false;
}
}
return view.extend({
load: function() {
return Promise.all([
backend.detectBackend(),
uci.load('dae').catch(function() {}),
uci.load('daed').catch(function() {}),
uci.load('daede').catch(function() {}),
loadYamlParser()
]).then(function(values) {
return { ctx: values[0], yaml: values[3] };
return { ctx: values[0], yaml: values[4] };
});
},
render: function(data) {
const state = {
results: [],
ignoredInfo: 0,
filter: '',
sourceHash: '',
sourceName: '',
selectedAirportId: '',
target: data.ctx.installed[data.ctx.name] ? data.ctx.name : (data.ctx.installed.dae ? 'dae' : 'daed')
};
const urlInput = E('input', { 'class': 'dd-conv-url', 'placeholder': 'https://example.com/clash.yaml', 'autocomplete': 'off' });
const uaSelect = E('select', { 'class': 'dd-conv-ua', 'title': _('Subscription User-Agent') }, [
E('option', { 'value': 'auto' }, _('Auto User-Agent (recommended)')),
E('option', { 'value': 'ClashMeta' }, 'ClashMeta'),
E('option', { 'value': 'clash-verge/v2.4.2' }, 'clash-verge/v2.4.2'),
E('option', { 'value': 'ClashForWindows/0.20.39' }, 'ClashForWindows/0.20.39'),
E('option', { 'value': 'Clash' }, 'Clash'),
E('option', { 'value': 'browser' }, _('Browser User-Agent'))
]);
const yamlInput = E('textarea', { 'class': 'dd-conv-yaml', 'placeholder': _('Or paste Clash YAML here'), 'spellcheck': 'false' });
const parseUrl = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Fetch and parse'));
const parsePaste = E('button', { 'class': 'cbi-button' }, _('Parse pasted YAML'));
@ -127,9 +222,12 @@ return view.extend({
const selectNew = E('button', { 'class': 'cbi-button' }, _('Select compatible new nodes'));
const clearSelection = E('button', { 'class': 'cbi-button' }, _('Clear selection'));
const targetSelect = E('select', { 'class': 'dd-conv-target' });
const importButton = E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': 'disabled' }, _('Import selected nodes'));
const summary = E('span', { 'class': 'dd-meta' }, _('No YAML parsed yet'));
const status = E('div', { 'class': 'dd-conv-status' }, '');
const airportName = E('input', { 'class': 'dd-conv-airport-name', 'placeholder': _('Group name'), 'autocomplete': 'off' });
const importButton = E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': 'disabled' }, _('Import node group'));
const summary = E('div', { 'class': 'dd-conv-summary' });
const groupSummary = E('div', { 'class': 'dd-conv-group-summary' });
const sourceStatus = E('div', { 'class': 'dd-conv-status' }, '');
const importStatus = E('div', { 'class': 'dd-conv-status' }, '');
const resultBody = E('div', { 'class': 'dd-conv-results' });
[ 'dae', 'daed' ].forEach(function(name) {
@ -137,13 +235,26 @@ return view.extend({
targetSelect.appendChild(E('option', { 'value': name, 'selected': state.target === name ? 'selected' : null }, name));
});
const setStatus = function(text, kind) {
status.textContent = text || '';
status.className = 'dd-conv-status' + (kind ? ' ' + kind : '');
const setSourceStatus = function(text, kind) {
sourceStatus.textContent = text || '';
sourceStatus.className = 'dd-conv-status' + (kind ? ' ' + kind : '');
};
const setImportStatus = function(text, kind) {
importStatus.textContent = text || '';
importStatus.className = 'dd-conv-status' + (kind ? ' ' + kind : '');
};
const selectedResults = function() {
return state.results.filter(function(item) { return item.ok && item.selected && !item.duplicate; });
return state.results.filter(function(item) { return item.ok && item.selected; });
};
const currentAirport = function() {
return airportSync.matchAirport(airportRecords(), {
backend: state.target,
selectedId: state.selectedAirportId,
name: airportName.value.trim()
});
};
const classifyDaeDuplicates = function() {
@ -154,8 +265,6 @@ return view.extend({
});
state.results.forEach(function(item) {
item.duplicate = !!(item.ok && existing[clashConverter.normalizeLink(item.link)]);
if (item.duplicate)
item.selected = false;
});
};
@ -171,7 +280,7 @@ return view.extend({
shown.forEach(function(item) {
const checkbox = E('input', { 'type': 'checkbox' });
checkbox.checked = !!item.selected;
checkbox.disabled = !item.ok || item.duplicate;
checkbox.disabled = !item.ok;
checkbox.addEventListener('change', function() {
item.selected = checkbox.checked;
renderResults();
@ -179,10 +288,12 @@ return view.extend({
let resultText, resultClass;
if (!item.ok) {
resultText = item.error;
resultText = item.error.indexOf('Unsupported protocol:') === 0
? _('Unsupported protocol: %s').format(item.type)
: item.error;
resultClass = 'bad';
} else if (item.duplicate) {
resultText = _('Already exists, skipped');
resultText = _('Already exists, will be reused');
resultClass = 'dup';
} else {
resultText = _('Ready to import');
@ -196,64 +307,137 @@ return view.extend({
E('span', { 'class': 'dd-conv-result-state ' + resultClass, 'title': resultText }, resultText)
]));
});
if (!shown.length)
resultBody.appendChild(E('div', { 'class': 'dd-conv-empty' }, _('No nodes to preview yet. Fetch or paste Clash YAML above.')));
const compatible = state.results.filter(function(item) { return item.ok; }).length;
const unsupported = state.results.length - compatible;
const duplicates = state.results.filter(function(item) { return item.duplicate; }).length;
summary.textContent = _('Detected %d · compatible %d · duplicates %d · incompatible %d')
.format(state.results.length, compatible, duplicates, unsupported);
importButton.textContent = _('Import selected nodes (%d)').format(selectedResults().length);
importButton.disabled = selectedResults().length === 0;
while (summary.firstChild)
summary.removeChild(summary.firstChild);
[
[ _('Detected'), state.results.length + state.ignoredInfo ],
[ _('Compatible'), compatible ],
[ _('Duplicates'), duplicates ],
[ _('Incompatible'), unsupported ],
[ _('Ignored info'), state.ignoredInfo ]
].forEach(function(item) {
summary.appendChild(E('span', { 'class': 'dd-conv-summary-item' }, [
E('span', { 'class': 'dd-conv-summary-label' }, item[0]),
E('strong', {}, String(item[1]))
]));
});
const count = selectedResults().length;
const name = airportName.value.trim();
const existing = currentAirport();
const validName = airportSync.isGroupNameValid(name, state.target);
if (!name) {
groupSummary.textContent = _('Enter a group name to import the selected nodes.');
groupSummary.className = 'dd-conv-group-summary';
} else if (!validName) {
groupSummary.textContent = _('dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore.');
groupSummary.className = 'dd-conv-group-summary err';
} else if (existing && count === 0) {
groupSummary.textContent = _('Node group "%s" is available on %s. Select nodes above to update it.').format(name, state.target);
groupSummary.className = 'dd-conv-group-summary';
} else {
groupSummary.textContent = existing
? _('Update node group "%s": replace it with %d selected nodes on %s.').format(name, count, state.target)
: _('Import %d selected nodes into group "%s" on %s.').format(count, name, state.target);
groupSummary.className = 'dd-conv-group-summary';
}
importButton.textContent = existing
? _('Update node group (%d)').format(count)
: _('Import node group (%d)').format(count);
importButton.disabled = count === 0 || !validName;
};
const acceptYaml = function(text) {
const clearPreview = function() {
state.results = [];
state.ignoredInfo = 0;
renderResults();
};
const acceptYaml = function(text, sourceValue, defaultName) {
let documentValue;
try {
documentValue = data.yaml.load(text);
} catch (e) {
setStatus(_('YAML parse failed: %s').format(e.message || e), 'err');
return;
clearPreview();
setSourceStatus(_('YAML parse failed: %s').format(e.message || e), 'err');
return Promise.resolve();
}
if (!documentValue || !Array.isArray(documentValue.proxies) || documentValue.proxies.length === 0) {
setStatus(_('No top-level proxies list was found'), 'err');
return;
clearPreview();
setSourceStatus(looksLikeBase64Subscription(text)
? _('The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription.')
: _('No top-level proxies list was found'), 'err');
return Promise.resolve();
}
state.results = clashConverter.convertProxies(documentValue.proxies).map(function(item) {
const proxies = documentValue.proxies.filter(function(node) {
return !clashConverter.isMetadataProxy(node);
});
state.ignoredInfo = documentValue.proxies.length - proxies.length;
state.results = clashConverter.convertProxies(proxies).map(function(item) {
item.selected = item.ok;
item.duplicate = false;
return item;
});
if (state.target === 'dae')
classifyDaeDuplicates();
setStatus(_('Conversion preview is ready'), 'ok');
setSourceStatus(state.results.some(function(item) { return item.ok; })
? _('Conversion preview is ready')
: _('Conversion completed, but no supported nodes were found'), state.results.some(function(item) { return item.ok; }) ? 'ok' : 'err');
renderResults();
return airportSync.hashSource(sourceValue || text).then(function(hash) {
state.sourceHash = hash;
// Pre-fill a name the third step accepts for the chosen backend,
// so a URL hostname like sub.example.com doesn't land as an invalid
// dae group name that the user has to fix by hand.
const safeDefault = airportSync.sanitizeGroupName(defaultName || '', state.target);
const matched = airportSync.matchAirport(airportRecords(), {
backend: state.target,
sourceHash: hash,
name: safeDefault
});
state.selectedAirportId = matched ? matched.id : '';
state.sourceName = matched ? matched.name : safeDefault;
airportName.value = state.sourceName;
renderResults();
});
};
parseUrl.addEventListener('click', function() {
const value = urlInput.value.trim();
if (!/^https?:\/\//i.test(value)) {
setStatus(_('Enter an HTTP or HTTPS subscription URL'), 'err');
clearPreview();
setSourceStatus(_('Enter an HTTP or HTTPS subscription URL'), 'err');
return;
}
parseUrl.disabled = true;
setStatus(_('Fetching subscription…'));
fs.exec(FETCHER, [ value ]).then(function(res) {
if (!res || res.code !== 0)
throw new Error((res && (res.stderr || res.stdout)) || _('Fetch failed'));
acceptYaml(res.stdout || '');
clearPreview();
setSourceStatus(_('Fetching subscription…'));
fetchYaml(value, uaSelect.value).then(function(text) {
return acceptYaml(text, value, airportSync.deriveAirportName(value));
}).catch(function(e) {
setStatus(_('Fetch failed: %s').format(String(e.message || e).trim()), 'err');
clearPreview();
setSourceStatus(_('Fetch failed: %s').format(String(e.message || e).trim()), 'err');
}).finally(function() { parseUrl.disabled = false; });
});
parsePaste.addEventListener('click', function() {
const value = yamlInput.value.trim();
if (!value) {
setStatus(_('Paste Clash YAML first'), 'err');
clearPreview();
setSourceStatus(_('Paste Clash YAML first'), 'err');
return;
}
acceptYaml(value);
acceptYaml(value, value, airportSync.nextPastedName(airportRecords().map(function(record) { return record.name; })))
.catch(function(e) {
clearPreview();
setSourceStatus(_('YAML parse failed: %s').format(e.message || e), 'err');
});
});
filterInput.addEventListener('input', function() {
@ -261,7 +445,7 @@ return view.extend({
renderResults();
});
selectNew.addEventListener('click', function() {
state.results.forEach(function(item) { item.selected = item.ok && !item.duplicate; });
state.results.forEach(function(item) { item.selected = item.ok; });
renderResults();
});
clearSelection.addEventListener('click', function() {
@ -273,40 +457,141 @@ return view.extend({
state.results.forEach(function(item) { item.duplicate = false; });
if (state.target === 'dae')
classifyDaeDuplicates();
const matched = airportSync.matchAirport(airportRecords(), {
backend: state.target,
sourceHash: state.sourceHash,
name: airportName.value.trim()
});
state.selectedAirportId = matched ? matched.id : '';
if (matched)
airportName.value = matched.name;
renderResults();
});
airportName.addEventListener('input', function() {
// Once the user edits the name, the visible group name becomes the
// identity. Do not silently replace a source-matched old group.
state.selectedAirportId = '';
renderResults();
});
const runGenerator = function() {
return fs.exec(GENERATOR, [ 'generate' ]).then(function(res) {
if (res && res.code !== 0)
throw new Error(res.stderr || res.stdout || ('exit ' + res.code));
});
};
const importDae = function(items) {
const existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const groupName = airportSync.backendGroupName(airportName.value, 'dae', airportId);
const batchId = Date.now().toString(36);
const foundGroupSection = existingAirport && existingAirport.group_id ? uciSection('dae', existingAirport.group_id) : null;
const foundOldGroup = foundGroupSection && foundGroupSection.name === existingAirport.name ? foundGroupSection : null;
const oldGroupSection = foundOldGroup ? JSON.parse(JSON.stringify(foundOldGroup)) : null;
const usedTags = {};
const nodesByLink = {};
(uci.sections('dae', 'node') || []).forEach(function(section) {
if (section.tag)
usedTags[section.tag] = true;
if (section.link)
nodesByLink[clashConverter.normalizeLink(section.link)] = section;
});
const oldOwned = existingAirport ? existingAirport.node_ids : [];
const owned = [];
const sources = [];
const created = [];
items.forEach(function(item, index) {
const sid = uci.add('dae', 'node');
uci.set('dae', sid, 'tag', uniqueNodeTag(usedTags, index));
const existingNode = nodesByLink[clashConverter.normalizeLink(item.link)];
if (existingNode) {
sources.push(existingNode.tag);
if (oldOwned.indexOf(existingNode['.name']) >= 0)
owned.push(existingNode['.name']);
return;
}
const sid = uci.add('dae', 'node', airportId + '_node_' + batchId + '_' + (index + 1));
const tag = uniqueNodeTag(usedTags, index);
uci.set('dae', sid, 'tag', tag);
uci.set('dae', sid, 'link', item.link);
uci.set('dae', sid, 'enabled', '1');
sources.push(tag);
owned.push(sid);
created.push(sid);
});
return uci.save().then(function() {
return uci.changes().then(function(changes) {
return changes && Object.keys(changes).length ? uci.apply() : null;
});
let groupSid = foundOldGroup ? existingAirport.group_id : '';
let groupCreated = false;
if (!groupSid) {
const groupBase = airportId + '_group';
groupSid = groupBase;
let suffix = 2;
while (uci.get('dae', groupSid))
groupSid = groupBase + '_' + suffix++;
uci.add('dae', 'group', groupSid);
groupCreated = true;
}
uci.set('dae', groupSid, 'name', groupName);
uci.set('dae', groupSid, 'policy', 'min_moving_avg');
uci.set('dae', groupSid, 'source', sources);
return applyUciChanges().then(runGenerator).catch(function(error) {
created.forEach(function(sid) { uci.remove('dae', sid); });
if (groupCreated) {
uci.remove('dae', groupSid);
} else if (oldGroupSection) {
const current = uciSection('dae', groupSid) || {};
Object.keys(current).forEach(function(key) {
if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(oldGroupSection, key))
uci.unset('dae', groupSid, key);
});
Object.keys(oldGroupSection).forEach(function(key) {
if (key.charAt(0) !== '.')
uci.set('dae', groupSid, key, oldGroupSection[key]);
});
}
return applyUciChanges().then(runGenerator).catch(function() {}).then(function() { throw error; });
}).then(function() {
const referencedTags = {};
(uci.sections('dae', 'group') || []).filter(function(group) {
return group['.name'] !== groupSid;
}).forEach(function(group) {
const source = Array.isArray(group.source) ? group.source : (group.source ? [ group.source ] : []);
source.forEach(function(tag) { referencedTags[tag] = true; });
});
const stale = oldOwned.filter(function(sid) {
const section = uciSection('dae', sid);
return owned.indexOf(sid) < 0 && !(section && referencedTags[section.tag]);
});
stale.forEach(function(sid) { uci.remove('dae', sid); });
writeAirportRecord(existingAirport, {
id: airportId,
backend: 'dae',
name: airportName.value.trim(),
sourceHash: state.sourceHash,
groupId: groupSid,
nodeIds: owned
});
return applyUciChanges().then(runGenerator);
}).then(function() {
return fs.exec(GENERATOR, [ 'generate' ]);
}).then(function(res) {
if (res && res.code !== 0)
throw new Error(res.stderr || res.stdout || ('exit ' + res.code));
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
return { added: items.length, duplicates: 0, failed: 0 };
return { added: created.length, duplicates: items.length - created.length, failed: 0 };
});
};
const importDaed = function(items) {
let credentials;
let token = '';
let existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const name = airportName.value.trim();
const managedName = airportSync.backendId(airportId);
const groupName = airportSync.backendGroupName(name, 'daed', airportId);
const batchTag = managedName + Date.now().toString(36);
const endpoint = daedEndpoint();
let before;
const createdIds = [];
let createdGroupId = '';
let groupReady = false;
return requestDaedCredentials().then(function(value) {
credentials = value;
return graphQL(endpoint,
@ -316,44 +601,121 @@ return view.extend({
token = login.token;
credentials.password = '';
credentials = null;
return graphQL(endpoint, 'query ExistingNodes{nodes(first:10000){edges{link}}}', {}, token);
}).then(function(existingData) {
return graphQL(endpoint, 'query AirportState{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token);
}).then(function(dataValue) {
before = dataValue;
const existing = {};
(existingData.nodes.edges || []).forEach(function(node) {
existing[clashConverter.normalizeLink(node.link)] = true;
(before.nodes.edges || []).forEach(function(node) {
existing[clashConverter.normalizeLink(node.link)] = node;
});
const fresh = items.filter(function(item) {
const duplicate = !!existing[clashConverter.normalizeLink(item.link)];
const duplicate = existing[clashConverter.normalizeLink(item.link)];
if (duplicate) {
item.duplicate = true;
item.selected = false;
}
return !duplicate;
});
if (!fresh.length)
return { importNodes: [], preDuplicates: items.length };
return { importNodes: [], fresh: fresh, existing: existing };
return graphQL(endpoint,
'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}',
{ args: fresh.map(function(item) { return { link: item.link }; }) }, token)
.then(function(value) { value.preDuplicates = items.length - fresh.length; return value; });
{ args: fresh.map(function(item, index) { return { link: item.link, tag: batchTag + (index + 1) }; }) }, token)
.then(function(value) { value.fresh = fresh; value.existing = existing; return value; });
}).then(function(result) {
const rows = result.importNodes || [];
const importedItems = {};
items.forEach(function(item) { importedItems[clashConverter.normalizeLink(item.link)] = item; });
const nodeIds = [];
const ownedIds = [];
const rowByLink = {};
rows.forEach(function(row) {
if (!row.error || row.error === 'node already exists') {
const item = importedItems[clashConverter.normalizeLink(row.link)];
if (item) {
item.duplicate = true;
item.selected = false;
}
}
rowByLink[clashConverter.normalizeLink(row.link)] = row;
});
const failed = rows.filter(function(row) { return !!row.error && row.error !== 'node already exists'; }).length;
const apiDuplicates = rows.filter(function(row) { return row.error === 'node already exists'; }).length;
const duplicates = (result.preDuplicates || 0) + apiDuplicates;
const added = rows.filter(function(row) { return !row.error; }).length;
return { added: added, duplicates: duplicates, failed: failed };
let failed = rows.filter(function(row) { return !!row.error && row.error !== 'node already exists'; }).length;
items.forEach(function(item) {
const key = clashConverter.normalizeLink(item.link);
const row = rowByLink[key];
const old = result.existing[key];
const id = row && row.node && row.node.id || old && old.id;
if (id)
nodeIds.push(id);
if (row && !row.error && row.node) {
ownedIds.push(row.node.id);
createdIds.push(row.node.id);
}
else if (existingAirport && existingAirport.node_ids.indexOf(id) >= 0)
ownedIds.push(id);
});
if (!nodeIds.length) {
const details = rows.filter(function(row) { return !!row.error; }).map(function(row) {
return row.error;
}).filter(function(error, index, errors) {
return errors.indexOf(error) === index;
}).join('; ');
throw new Error(details || _('No usable nodes were imported'));
}
const oldGroup = airportSync.findManagedGroup(before.groups, existingAirport);
const group = oldGroup;
const ensureGroup = group
? Promise.resolve(group.id)
: graphQL(endpoint,
'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}',
{ name: groupName, policy: 'min_moving_avg' }, token).then(function(value) {
createdGroupId = value.createGroup.id;
return createdGroupId;
});
return ensureGroup.then(function(groupId) {
const rename = group && group.name !== groupName
? graphQL(endpoint, 'mutation RenameGroup($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token)
: Promise.resolve();
return rename.then(function() {
return graphQL(endpoint, 'mutation SetPolicy($id:ID!,$policy:Policy!){groupSetPolicy(id:$id,policy:$policy)}', { id: groupId, policy: 'min_moving_avg' }, token);
}).then(function() {
return graphQL(endpoint, 'mutation AddNodes($id:ID!,$nodeIDs:[ID!]!){groupAddNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: nodeIds }, token);
}).then(function() {
groupReady = true;
const oldMembers = group ? group.nodes.map(function(node) { return node.id; }) : [];
const removedMembers = oldMembers.filter(function(id) { return nodeIds.indexOf(id) < 0; });
return (removedMembers.length
? graphQL(endpoint, 'mutation DelNodes($id:ID!,$nodeIDs:[ID!]!){groupDelNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: removedMembers }, token)
: Promise.resolve()).catch(function() {
failed++;
}).then(function() {
const otherAirportNodes = airportRecords().filter(function(record) {
return record.backend === 'daed' && (!existingAirport || record.id !== existingAirport.id);
}).map(function(record) { return record.node_ids; });
const otherGroupNodes = before.groups.filter(function(other) {
return other.id !== groupId;
}).map(function(other) { return other.nodes.map(function(node) { return node.id; }); });
const stale = airportSync.safeOldNodeIds(existingAirport ? existingAirport.node_ids : [], ownedIds, otherAirportNodes, otherGroupNodes);
return (stale.length
? graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: stale }, token)
: Promise.resolve()).catch(function() {
failed++;
}).then(function() {
writeAirportRecord(existingAirport, {
id: airportId,
backend: 'daed',
name: name,
sourceHash: state.sourceHash,
groupId: groupId,
nodeIds: ownedIds
});
return applyUciChanges().then(function() {
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
return { added: ownedIds.length, duplicates: items.length - ownedIds.length, failed: failed };
});
});
});
});
});
}).catch(function(error) {
if (groupReady || !token || (!createdIds.length && !createdGroupId))
throw error;
const cleanup = [];
if (createdIds.length)
cleanup.push(graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdIds }, token));
if (createdGroupId)
cleanup.push(graphQL(endpoint, 'mutation RemoveGroup($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
return Promise.all(cleanup).catch(function() {}).then(function() { throw error; });
}).finally(function() {
if (credentials)
credentials.password = '';
@ -366,15 +728,27 @@ return view.extend({
const items = selectedResults();
if (!items.length)
return;
const name = airportName.value.trim();
if (!name) {
setImportStatus(_('Enter a group name'), 'err');
return;
}
if (!airportSync.isGroupNameValid(name, state.target)) {
setImportStatus(_('dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore.'), 'err');
return;
}
const existing = currentAirport();
if (existing && !window.confirm(_('Replace node group "%s" with %d selected nodes?').format(existing.name, items.length)))
return;
importButton.disabled = true;
setStatus(_('Importing selected nodes…'));
setImportStatus(_('Importing node group…'));
const action = state.target === 'dae' ? importDae(items) : importDaed(items);
action.then(function(result) {
setStatus(_('Import complete: added %d, duplicates %d, failed %d')
setImportStatus(_('Node group imported: added %d, reused %d, failed %d')
.format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok');
renderResults();
}).catch(function(e) {
setStatus(_('Import failed: %s').format(e.message || e), 'err');
setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err');
}).finally(function() {
importButton.disabled = selectedResults().length === 0;
});
@ -385,13 +759,13 @@ return view.extend({
return E('div', { 'class': 'dd-wrap dd-converter' }, [
E('style', {}, styles.CSS),
E('div', { 'class': 'dd-card dd-conv-card' }, [
E('h3', {}, _('Subscription Converter')),
E('p', { 'class': 'dd-settings-descr' }, _('Convert Clash YAML into share links, preview the result, then import selected nodes into dae or daed. Inputs and credentials are not saved.')),
E('h4', { 'class': 'dd-card-title' }, _('1. Input Clash YAML')),
E('div', { 'class': 'dd-conv-url-row' }, [ urlInput, parseUrl ]),
E('div', { 'class': 'dd-conv-url-row' }, [ urlInput, uaSelect, parseUrl ]),
E('div', { 'class': 'dd-conv-or' }, _('or')),
yamlInput,
E('div', { 'class': 'dd-actions' }, [ parsePaste ])
E('div', { 'class': 'dd-actions' }, [ parsePaste ]),
sourceStatus
]),
E('div', { 'class': 'dd-card dd-conv-card' }, [
E('div', { 'class': 'dd-conv-heading' }, [
@ -402,13 +776,17 @@ return view.extend({
resultBody
]),
E('div', { 'class': 'dd-card dd-conv-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('3. Import Selected Nodes')),
E('div', { 'class': 'dd-conv-import' }, [
E('h4', { 'class': 'dd-card-title' }, _('3. Import Node Group')),
E('p', { 'class': 'dd-settings-descr' }, _('Import the selected nodes as one named group, so large airport node lists stay easy to manage.')),
E('div', { 'class': 'dd-conv-import dd-conv-airport' }, [
E('label', {}, _('Group name')),
airportName,
E('label', {}, _('Target backend')),
targetSelect,
importButton
targetSelect
]),
status
groupSummary,
E('div', { 'class': 'dd-conv-import dd-conv-submit' }, [ importButton ]),
importStatus
])
]);
},

View File

@ -55,17 +55,18 @@ function detectLevel(line) {
return 'dd-error';
}
/*
* - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z")转成本地时间
/*
* - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z")转成北京时间
* - logrus 短格式 (e.g. "May 25 07:04:59") 无时区信息原样提取
*/
function shortTs(raw) {
if (!raw) return raw;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(raw)) {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
const d = new Date(raw);
if (!isNaN(d.getTime())) {
const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const pad = n => String(n).padStart(2, '0');
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
return pad(beijing.getUTCHours()) + ':' + pad(beijing.getUTCMinutes()) + ':' + pad(beijing.getUTCSeconds());
}
}
const m = raw.match(/(\d{2}:\d{2}:\d{2})/);

View File

@ -179,18 +179,29 @@ const CSS = [
'.dd-settings-card .cbi-section-table-row>td:nth-child(3){flex:0 0 auto !important;text-align:center}',
'.dd-settings-card .cbi-section-table-row>td.cbi-section-actions{flex:0 0 auto !important;display:flex;gap:4px}',
'}',
'.dd-conv-card{padding:14px 16px;margin-bottom:10px}',
'.dd-conv-card h3{font-size:18px;margin:0 0 6px}',
'.dd-conv-url-row,.dd-conv-import,.dd-conv-toolbar,.dd-conv-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
'.dd-conv-url-row input,.dd-conv-filter,.dd-conv-target,.dd-conv-yaml{box-sizing:border-box;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:transparent;color:inherit;font-size:12px;padding:7px 9px}',
'.dd-conv-url-row input{flex:1;min-width:240px}',
'.dd-converter{width:100%!important;max-width:1180px;margin:0 auto}',
'.dd-conv-card{padding:18px 20px;margin-bottom:12px}',
'.dd-conv-url-row{display:grid;grid-template-columns:minmax(280px,1fr) 230px max-content;align-items:center;gap:8px}',
'.dd-conv-import,.dd-conv-toolbar,.dd-conv-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
'.dd-converter input,.dd-converter select,.dd-converter .cbi-button{box-sizing:border-box!important;height:32px!important;min-height:32px!important;margin:0!important;font-size:12px!important;line-height:22px!important}',
'.dd-converter input,.dd-converter select{padding:4px 8px!important;border:1px solid rgba(128,128,128,.28)!important;border-radius:6px!important;background-color:transparent;color:inherit;box-shadow:none!important}',
'.dd-converter select{padding-right:28px!important}',
'.dd-converter .cbi-button{padding:4px 12px!important}',
'.dd-conv-url-row input,.dd-conv-filter,.dd-conv-target,.dd-conv-ua,.dd-conv-yaml{box-sizing:border-box;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:transparent;color:inherit;font-size:12px;padding:7px 9px}',
'.dd-conv-url-row input{width:100%;min-width:0}',
'.dd-conv-ua{width:230px;max-width:230px}',
'.dd-conv-yaml{width:100%;min-height:150px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.5}',
'.dd-conv-or{text-align:center;font-size:11px;opacity:.5;margin:7px 0}',
'.dd-conv-heading{justify-content:space-between;margin-bottom:8px}',
'.dd-conv-heading{justify-content:space-between;gap:10px;margin-bottom:10px}',
'.dd-conv-heading .dd-card-title{margin:0}',
'.dd-conv-summary{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px 10px;font-size:11px;font-variant-numeric:tabular-nums}',
'.dd-conv-summary-item{display:inline-flex;align-items:baseline;gap:4px;white-space:nowrap;color:inherit;opacity:.62}',
'.dd-conv-summary-item strong{font-size:12px;font-weight:650;opacity:1;color:inherit}',
'.dd-conv-summary-label{font-size:10.5px}',
'.dd-conv-toolbar{margin-bottom:8px}',
'.dd-conv-filter{margin-left:auto;min-width:220px}',
'.dd-conv-results{border:1px solid rgba(128,128,128,.18);border-radius:7px;max-height:420px;overflow:auto}',
'.dd-conv-empty{display:flex;align-items:center;justify-content:center;min-height:72px;padding:16px;text-align:center;font-size:11.5px;opacity:.48}',
'.dd-conv-result{display:grid;grid-template-columns:22px minmax(100px,1fr) 90px minmax(130px,1.3fr);gap:8px;align-items:center;padding:7px 9px;border-bottom:1px solid rgba(128,128,128,.13);font-size:12px}',
'.dd-conv-result:last-child{border-bottom:0}',
'.dd-conv-result input{appearance:checkbox !important;-webkit-appearance:checkbox !important;accent-color:#4aa065;width:15px;height:15px}',
@ -200,9 +211,15 @@ const CSS = [
'.dd-conv-result-state.dup{opacity:.55}',
'.dd-conv-result-state.bad,.dd-conv-status.err{color:#d96d6d}',
'.dd-conv-import label{font-size:12px;font-weight:600;opacity:.72}',
'.dd-conv-airport{display:grid;grid-template-columns:max-content minmax(240px,420px) max-content 130px;gap:8px;align-items:center;margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid rgba(128,128,128,.15)}',
'.dd-conv-airport .dd-conv-target,.dd-conv-airport-name{box-sizing:border-box;width:100% !important;min-width:0 !important;height:32px !important;min-height:32px !important;margin:0 !important;padding:4px 8px !important;border:1px solid rgba(128,128,128,.28);border-radius:6px;background-color:transparent;color:inherit;font-size:12px !important;line-height:22px !important;box-shadow:none}',
'.dd-conv-airport-name{max-width:420px}',
'.dd-conv-group-summary{margin:2px 0 10px;padding:9px 11px;border-left:3px solid rgba(74,160,101,.65);border-radius:3px;background:rgba(74,160,101,.07);font-size:12px;line-height:1.5}',
'.dd-conv-group-summary.err{border-left-color:#d96d6d;background:rgba(217,109,109,.07);color:#d96d6d}',
'.dd-conv-submit{justify-content:flex-end}',
'.dd-conv-import .cbi-button{margin-left:auto}',
'.dd-conv-status{margin-top:9px;font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;min-height:16px}',
'@media(max-width:640px){.dd-conv-filter{margin-left:0;width:100%}.dd-conv-result{grid-template-columns:20px minmax(90px,1fr) 68px}.dd-conv-result-state{grid-column:2/4;font-size:11px}.dd-conv-import .cbi-button{margin-left:0;width:100%}.dd-conv-url-row input{min-width:100%;width:100%}.dd-conv-url-row .cbi-button{width:100%}}'
'@media(max-width:640px){.dd-conv-card{padding:14px}.dd-conv-heading{align-items:flex-start}.dd-conv-heading .dd-card-title{width:100%}.dd-conv-summary{justify-content:flex-start;width:100%;gap:5px 12px;padding-top:2px}.dd-conv-summary-item{min-width:74px}.dd-conv-filter{margin-left:0;width:100%}.dd-conv-result{grid-template-columns:20px minmax(90px,1fr) 68px}.dd-conv-result-state{grid-column:2/4;font-size:11px}.dd-conv-submit .cbi-button{width:100%!important}.dd-conv-airport{grid-template-columns:82px minmax(0,1fr)}.dd-conv-airport>*{max-width:none !important}.dd-conv-url-row{grid-template-columns:1fr}.dd-conv-url-row input,.dd-conv-url-row .dd-conv-ua,.dd-conv-url-row .cbi-button{width:100%;max-width:none}}'
].join('');
return baseclass.extend({ CSS: CSS });

View File

@ -461,6 +461,24 @@ msgstr ""
msgid "Fetch and parse"
msgstr ""
#: converter.js
msgid "Subscription User-Agent"
msgstr ""
#: converter.js
msgid "Auto User-Agent (recommended)"
msgstr ""
#: converter.js
msgid "Browser User-Agent"
msgstr ""
msgid "Fetcher returned an invalid response"
msgstr ""
msgid "Failed to read subscription data"
msgstr ""
#: converter.js
msgid "Or paste Clash YAML here"
msgstr ""
@ -498,7 +516,7 @@ msgid "Import selected nodes (%d)"
msgstr ""
#: converter.js
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d"
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d"
msgstr ""
#: converter.js
@ -562,10 +580,22 @@ msgstr ""
msgid "No top-level proxies list was found"
msgstr ""
#: converter.js
msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription."
msgstr ""
#: converter.js
msgid "Conversion preview is ready"
msgstr ""
#: converter.js
msgid "Conversion completed, but no supported nodes were found"
msgstr ""
#: converter.js
msgid "Unsupported protocol: %s"
msgstr ""
#: converter.js
msgid "Enter an HTTP or HTTPS subscription URL"
msgstr ""
@ -600,3 +630,111 @@ msgstr ""
msgid "Import complete: added %d, duplicates %d, failed %d"
msgstr ""
msgid "Create new airport"
msgstr ""
msgid "Existing airport"
msgstr ""
msgid "Airport name"
msgstr ""
msgid "Sync airport"
msgstr ""
msgid "Sync airport (%d nodes)"
msgstr ""
msgid "Already exists, will be reused"
msgstr ""
msgid "Enter an airport name"
msgstr ""
msgid "No usable nodes were imported"
msgstr ""
msgid "Synchronizing airport…"
msgstr ""
msgid "Sync complete: added %d, reused %d, failed %d"
msgstr ""
msgid "Sync failed: %s"
msgstr ""
msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?"
msgstr ""
msgid "3. Sync Airport Group"
msgstr ""
msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes."
msgstr ""
msgid "Detected"
msgstr ""
msgid "Compatible"
msgstr ""
msgid "Duplicates"
msgstr ""
msgid "Incompatible"
msgstr ""
msgid "Ignored info"
msgstr ""
msgid "No nodes to preview yet. Fetch or paste Clash YAML above."
msgstr ""
msgid "Group name"
msgstr ""
msgid "Import node group"
msgstr ""
msgid "Import node group (%d)"
msgstr ""
msgid "Update node group (%d)"
msgstr ""
msgid "Enter a group name to import the selected nodes."
msgstr ""
msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore."
msgstr ""
msgid "Update node group \"%s\": replace it with %d selected nodes on %s."
msgstr ""
msgid "Import %d selected nodes into group \"%s\" on %s."
msgstr ""
msgid "Enter a group name"
msgstr ""
msgid "Replace node group \"%s\" with %d selected nodes?"
msgstr ""
msgid "Importing node group…"
msgstr ""
msgid "Node group imported: added %d, reused %d, failed %d"
msgstr ""
msgid "Node group import failed: %s"
msgstr ""
msgid "3. Import Node Group"
msgstr ""
msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage."
msgstr ""
msgid "Node group \"%s\" is available on %s. Select nodes above to update it."
msgstr ""

View File

@ -146,10 +146,6 @@ msgstr "dae 没有内置 WebUI下面的 DSL 配置需要手工编辑(订阅
msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here."
msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。"
# ---- config.js: dae Parameters ----
msgid "dae Parameters"
msgstr "dae 参数"
msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI."
msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。"
@ -426,9 +422,6 @@ msgstr "已保存(启动 dae 后生效)"
msgid "Configuration Editor"
msgstr "配置编辑"
msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI."
msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。"
# ---- config.js: Proxy Health ----
msgid "Test YouTube"
msgstr "测试 YouTube"
@ -626,9 +619,6 @@ msgstr "区块"
msgid "DNS"
msgstr "DNS"
msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here."
msgstr "dae 配置文件为空。点击「从示例初始化」开始,或直接在此粘贴你的配置。"
# ---- log.js: filter dropdown ----
msgid "All"
msgstr "全部"
@ -698,12 +688,6 @@ msgstr "分组名称必须唯一"
msgid "Updating…"
msgstr "更新中…"
msgid "Subscriptions updated (dae reloaded)"
msgstr "订阅已更新dae 已重载)"
msgid "Save manual config"
msgstr "保存手动配置"
msgid "Saved · started"
msgstr "已保存并启动"
@ -726,6 +710,21 @@ msgstr "1. 输入 Clash YAML"
msgid "Fetch and parse"
msgstr "获取并解析"
msgid "Subscription User-Agent"
msgstr "订阅 User-Agent"
msgid "Auto User-Agent (recommended)"
msgstr "自动选择 User-Agent推荐"
msgid "Browser User-Agent"
msgstr "浏览器 User-Agent"
msgid "Fetcher returned an invalid response"
msgstr "获取程序返回了无效响应"
msgid "Failed to read subscription data"
msgstr "读取订阅数据失败"
msgid "Or paste Clash YAML here"
msgstr "或在此粘贴 Clash YAML"
@ -753,8 +752,8 @@ msgstr "目标后端"
msgid "Import selected nodes (%d)"
msgstr "导入所选节点(%d"
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d"
msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个"
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d"
msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个 · 已忽略信息 %d 个"
msgid "Ready to import"
msgstr "可导入"
@ -801,9 +800,18 @@ msgstr "YAML 解析失败:%s"
msgid "No top-level proxies list was found"
msgstr "未找到顶层 proxies 列表"
msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription."
msgstr "服务器返回的是 Base64/分享链接订阅,不是 Clash YAML。请尝试其他订阅 User-Agent或直接将该链接添加为原生订阅。"
msgid "Conversion preview is ready"
msgstr "转换预览已就绪"
msgid "Conversion completed, but no supported nodes were found"
msgstr "转换完成,但未找到支持的节点"
msgid "Unsupported protocol: %s"
msgstr "不支持的协议:%s"
msgid "Enter an HTTP or HTTPS subscription URL"
msgstr "请输入 HTTP 或 HTTPS 订阅链接"
@ -830,3 +838,111 @@ msgstr "导入失败:%s"
msgid "Import complete: added %d, duplicates %d, failed %d"
msgstr "导入完成:新增 %d重复 %d失败 %d"
msgid "Create new airport"
msgstr "新建机场"
msgid "Existing airport"
msgstr "已有机场"
msgid "Airport name"
msgstr "机场名称"
msgid "Sync airport"
msgstr "同步机场"
msgid "Sync airport (%d nodes)"
msgstr "同步机场(%d 个节点)"
msgid "Already exists, will be reused"
msgstr "已存在,将复用"
msgid "Enter an airport name"
msgstr "请输入机场名称"
msgid "No usable nodes were imported"
msgstr "未导入任何可用节点"
msgid "Synchronizing airport…"
msgstr "正在同步机场…"
msgid "Sync complete: added %d, reused %d, failed %d"
msgstr "同步完成:新增 %d复用 %d失败 %d"
msgid "Sync failed: %s"
msgstr "同步失败:%s"
msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?"
msgstr "将删除“%s”的旧节点并导入 %d 个新节点,是否继续?"
msgid "3. Sync Airport Group"
msgstr "3. 同步机场分组"
msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes."
msgstr "所选节点会作为一个机场分组管理。再次同步已有机场时,会安全替换该机场原有的受管节点。"
msgid "Detected"
msgstr "检测"
msgid "Compatible"
msgstr "可导入"
msgid "Duplicates"
msgstr "重复"
msgid "Incompatible"
msgstr "不兼容"
msgid "Ignored info"
msgstr "忽略信息"
msgid "No nodes to preview yet. Fetch or paste Clash YAML above."
msgstr "暂无可预览节点,请先在上方获取或粘贴 Clash YAML。"
msgid "Group name"
msgstr "组名"
msgid "Import node group"
msgstr "导入节点组"
msgid "Import node group (%d)"
msgstr "导入节点组(%d"
msgid "Update node group (%d)"
msgstr "更新节点组(%d"
msgid "Enter a group name to import the selected nodes."
msgstr "输入组名后,将所选节点作为一个节点组导入。"
msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore."
msgstr "dae 组名只能包含字母、数字、下划线和连字符,并且必须以字母或下划线开头。"
msgid "Update node group \"%s\": replace it with %d selected nodes on %s."
msgstr "更新节点组“%s”使用所选的 %d 个节点替换,目标后端为 %s。"
msgid "Import %d selected nodes into group \"%s\" on %s."
msgstr "将所选的 %d 个节点归入“%s”组并导入 %s。"
msgid "Enter a group name"
msgstr "请输入组名"
msgid "Replace node group \"%s\" with %d selected nodes?"
msgstr "使用当前所选节点替换节点组“%s”共 %d 个节点,是否继续?"
msgid "Importing node group…"
msgstr "正在导入节点组…"
msgid "Node group imported: added %d, reused %d, failed %d"
msgstr "节点组导入完成:新增 %d复用 %d失败 %d"
msgid "Node group import failed: %s"
msgstr "节点组导入失败:%s"
msgid "3. Import Node Group"
msgstr "3. 导入节点组"
msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage."
msgstr "将所选节点作为一个命名节点组导入,避免机场节点较多时难以管理。"
msgid "Node group \"%s\" is available on %s. Select nodes above to update it."
msgstr "节点组“%s”已导入 %s。重新选择上方节点即可更新此组。"

View File

@ -146,10 +146,6 @@ msgstr "dae 没有内置 WebUI下面的 DSL 配置需要手工编辑(订阅
msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here."
msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。"
# ---- config.js: dae Parameters ----
msgid "dae Parameters"
msgstr "dae 参数"
msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI."
msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。"
@ -426,9 +422,6 @@ msgstr "已保存(启动 dae 后生效)"
msgid "Configuration Editor"
msgstr "配置编辑"
msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI."
msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。"
# ---- config.js: Proxy Health ----
msgid "Test YouTube"
msgstr "测试 YouTube"
@ -626,9 +619,6 @@ msgstr "区块"
msgid "DNS"
msgstr "DNS"
msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here."
msgstr "dae 配置文件为空。点击「从示例初始化」开始,或直接在此粘贴你的配置。"
# ---- log.js: filter dropdown ----
msgid "All"
msgstr "全部"
@ -698,12 +688,6 @@ msgstr "分组名称必须唯一"
msgid "Updating…"
msgstr "更新中…"
msgid "Subscriptions updated (dae reloaded)"
msgstr "订阅已更新dae 已重载)"
msgid "Save manual config"
msgstr "保存手动配置"
msgid "Saved · started"
msgstr "已保存并启动"
@ -726,6 +710,21 @@ msgstr "1. 输入 Clash YAML"
msgid "Fetch and parse"
msgstr "获取并解析"
msgid "Subscription User-Agent"
msgstr "订阅 User-Agent"
msgid "Auto User-Agent (recommended)"
msgstr "自动选择 User-Agent推荐"
msgid "Browser User-Agent"
msgstr "浏览器 User-Agent"
msgid "Fetcher returned an invalid response"
msgstr "获取程序返回了无效响应"
msgid "Failed to read subscription data"
msgstr "读取订阅数据失败"
msgid "Or paste Clash YAML here"
msgstr "或在此粘贴 Clash YAML"
@ -753,8 +752,8 @@ msgstr "目标后端"
msgid "Import selected nodes (%d)"
msgstr "导入所选节点(%d"
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d"
msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个"
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d"
msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个 · 已忽略信息 %d 个"
msgid "Ready to import"
msgstr "可导入"
@ -801,9 +800,18 @@ msgstr "YAML 解析失败:%s"
msgid "No top-level proxies list was found"
msgstr "未找到顶层 proxies 列表"
msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription."
msgstr "服务器返回的是 Base64/分享链接订阅,不是 Clash YAML。请尝试其他订阅 User-Agent或直接将该链接添加为原生订阅。"
msgid "Conversion preview is ready"
msgstr "转换预览已就绪"
msgid "Conversion completed, but no supported nodes were found"
msgstr "转换完成,但未找到支持的节点"
msgid "Unsupported protocol: %s"
msgstr "不支持的协议:%s"
msgid "Enter an HTTP or HTTPS subscription URL"
msgstr "请输入 HTTP 或 HTTPS 订阅链接"
@ -830,3 +838,111 @@ msgstr "导入失败:%s"
msgid "Import complete: added %d, duplicates %d, failed %d"
msgstr "导入完成:新增 %d重复 %d失败 %d"
msgid "Create new airport"
msgstr "新建机场"
msgid "Existing airport"
msgstr "已有机场"
msgid "Airport name"
msgstr "机场名称"
msgid "Sync airport"
msgstr "同步机场"
msgid "Sync airport (%d nodes)"
msgstr "同步机场(%d 个节点)"
msgid "Already exists, will be reused"
msgstr "已存在,将复用"
msgid "Enter an airport name"
msgstr "请输入机场名称"
msgid "No usable nodes were imported"
msgstr "未导入任何可用节点"
msgid "Synchronizing airport…"
msgstr "正在同步机场…"
msgid "Sync complete: added %d, reused %d, failed %d"
msgstr "同步完成:新增 %d复用 %d失败 %d"
msgid "Sync failed: %s"
msgstr "同步失败:%s"
msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?"
msgstr "将删除“%s”的旧节点并导入 %d 个新节点,是否继续?"
msgid "3. Sync Airport Group"
msgstr "3. 同步机场分组"
msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes."
msgstr "所选节点会作为一个机场分组管理。再次同步已有机场时,会安全替换该机场原有的受管节点。"
msgid "Detected"
msgstr "检测"
msgid "Compatible"
msgstr "可导入"
msgid "Duplicates"
msgstr "重复"
msgid "Incompatible"
msgstr "不兼容"
msgid "Ignored info"
msgstr "忽略信息"
msgid "No nodes to preview yet. Fetch or paste Clash YAML above."
msgstr "暂无可预览节点,请先在上方获取或粘贴 Clash YAML。"
msgid "Group name"
msgstr "组名"
msgid "Import node group"
msgstr "导入节点组"
msgid "Import node group (%d)"
msgstr "导入节点组(%d"
msgid "Update node group (%d)"
msgstr "更新节点组(%d"
msgid "Enter a group name to import the selected nodes."
msgstr "输入组名后,将所选节点作为一个节点组导入。"
msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore."
msgstr "dae 组名只能包含字母、数字、下划线和连字符,并且必须以字母或下划线开头。"
msgid "Update node group \"%s\": replace it with %d selected nodes on %s."
msgstr "更新节点组“%s”使用所选的 %d 个节点替换,目标后端为 %s。"
msgid "Import %d selected nodes into group \"%s\" on %s."
msgstr "将所选的 %d 个节点归入“%s”组并导入 %s。"
msgid "Enter a group name"
msgstr "请输入组名"
msgid "Replace node group \"%s\" with %d selected nodes?"
msgstr "使用当前所选节点替换节点组“%s”共 %d 个节点,是否继续?"
msgid "Importing node group…"
msgstr "正在导入节点组…"
msgid "Node group imported: added %d, reused %d, failed %d"
msgstr "节点组导入完成:新增 %d复用 %d失败 %d"
msgid "Node group import failed: %s"
msgstr "节点组导入失败:%s"
msgid "3. Import Node Group"
msgstr "3. 导入节点组"
msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage."
msgstr "将所选节点作为一个命名节点组导入,避免机场节点较多时难以管理。"
msgid "Node group \"%s\" is available on %s. Select nodes above to update it."
msgstr "节点组“%s”已导入 %s。重新选择上方节点即可更新此组。"

View File

@ -3,7 +3,31 @@
set -eu
CHUNK_BYTES=16384
ACTION="${1:-}"
case "$ACTION" in
chunk)
TOKEN="${2:-}"
INDEX="${3:-}"
case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac
case "$INDEX" in *[!0-9]*|'') exit 2 ;; esac
FILE="/tmp/daede-clash-yaml-result.$TOKEN"
[ -f "$FILE" ] || exit 4
dd if="$FILE" bs="$CHUNK_BYTES" skip="$INDEX" count=1 2>/dev/null | hexdump -ve '1/1 "%02x"'
exit 0
;;
cleanup)
TOKEN="${2:-}"
case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac
rm -f "/tmp/daede-clash-yaml-result.$TOKEN"
exit 0
;;
esac
URL="${1:-}"
UA_MODE="${2:-auto}"
OUTPUT_MODE="${3:-stdout}"
MAX_BYTES=5242880
FETCH_BIN="${DAEDE_FETCH_BIN:-uclient-fetch}"
@ -16,22 +40,95 @@ case "$URL" in
esac
TMP="$(mktemp /tmp/daede-clash-yaml.XXXXXX)"
trap 'rm -f "$TMP"' EXIT INT TERM
FIRST="$(mktemp /tmp/daede-clash-yaml-first.XXXXXX)"
ERR="$(mktemp /tmp/daede-clash-yaml-error.XXXXXX)"
trap 'rm -f "$TMP" "$FIRST" "$ERR"' EXIT INT TERM
chmod 600 "$TMP"
chmod 600 "$FIRST"
chmod 600 "$ERR"
if ! "$FETCH_BIN" -q -T 20 -U "ClashMeta" -O "$TMP" "$URL"; then
echo "Failed to fetch subscription" >&2
exit 3
case "$UA_MODE" in
auto|browser|ClashMeta|clash-verge/v2.4.2|ClashForWindows/0.20.39|Clash) ;;
*)
echo "Unsupported subscription User-Agent" >&2
exit 2
;;
esac
case "$OUTPUT_MODE" in
stdout|handle) ;;
*)
echo "Unsupported output mode" >&2
exit 2
;;
esac
LAST_ERROR="Failed to fetch subscription"
emit_result() {
local source="$1" result token size
if [ "$OUTPUT_MODE" = "stdout" ]; then
cat "$source"
return
fi
# Keep each RPC response small. The browser reads this root-only temporary
# file through the chunk action and removes it in a finally handler.
find /tmp -maxdepth 1 -name 'daede-clash-yaml-result.*' -mmin +10 -delete 2>/dev/null || true
result="$(mktemp /tmp/daede-clash-yaml-result.XXXXXX)"
chmod 600 "$result"
cp "$source" "$result"
token="${result##*.}"
size="$(wc -c <"$result" | tr -d ' ')"
printf '%s\t%s\n' "$token" "$size"
}
fetch_one() {
local ua="$1" size response_error
if [ "$ua" = "browser" ]; then
ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36"
fi
: >"$TMP"
: >"$ERR"
if ! "$FETCH_BIN" -q -T 20 -U "$ua" -O "$TMP" "$URL" 2>"$ERR"; then
response_error="$(head -c 512 "$TMP" | tr '\r\n\t' ' ')"
LAST_ERROR="${response_error:-$(cat "$ERR")}"
[ -n "$LAST_ERROR" ] || LAST_ERROR="Failed to fetch subscription"
return 1
fi
size="$(wc -c <"$TMP" | tr -d ' ')"
if [ "$size" -eq 0 ]; then
LAST_ERROR="Subscription response is empty"
return 1
fi
if [ "$size" -gt "$MAX_BYTES" ]; then
LAST_ERROR="Subscription response is too large (maximum 5 MiB)"
return 1
fi
return 0
}
if [ "$UA_MODE" = "auto" ]; then
for ua in ClashMeta clash-verge/v2.4.2 ClashForWindows/0.20.39 Clash browser; do
if fetch_one "$ua"; then
[ -s "$FIRST" ] || cp "$TMP" "$FIRST"
if grep -Eq '^[[:space:]]*proxies[[:space:]]*:' "$TMP"; then
emit_result "$TMP"
exit 0
fi
fi
done
if [ -s "$FIRST" ]; then
emit_result "$FIRST"
exit 0
fi
else
if fetch_one "$UA_MODE"; then
emit_result "$TMP"
exit 0
fi
fi
SIZE="$(wc -c <"$TMP" | tr -d ' ')"
if [ "$SIZE" -eq 0 ]; then
echo "Subscription response is empty" >&2
exit 4
fi
if [ "$SIZE" -gt "$MAX_BYTES" ]; then
echo "Subscription response is too large (maximum 5 MiB)" >&2
exit 5
fi
cat "$TMP"
echo "$LAST_ERROR" >&2
exit 3

View File

@ -0,0 +1,22 @@
const assert = require('assert');
const airportSync = require('../htdocs/luci-static/resources/view/daede/airport-sync.js');
assert.strictEqual(airportSync.backendGroupName('fs-cloud', 'dae', 'airport_123'), 'fs-cloud');
assert.strictEqual(airportSync.backendGroupName('fs-cloud', 'daed', 'airport_123'), 'fs-cloud');
assert.strictEqual(airportSync.backendGroupName('白月光', 'daed', 'airport_123'), '白月光');
assert.strictEqual(airportSync.backendGroupName('白月光', 'dae', 'airport_123'), 'airport123');
assert.strictEqual(airportSync.backendGroupName('', 'dae', 'airport_123'), 'airport123');
assert.strictEqual(airportSync.isGroupNameValid('fs-cloud', 'dae'), true);
assert.strictEqual(airportSync.isGroupNameValid('白月光', 'dae'), false);
assert.strictEqual(airportSync.isGroupNameValid('白月光', 'daed'), true);
const managed = { group_id: 'group-1', name: 'Airport 1' };
assert.strictEqual(airportSync.findManagedGroup([
{ id: 'group-1', name: 'Airport 1' },
{ id: 'group-2', name: 'Airport 2' }
], managed).id, 'group-1');
assert.strictEqual(airportSync.findManagedGroup([
{ id: 'group-1', name: 'Unrelated group' }
], managed), null);
console.log('airport group name tests passed');

View File

@ -0,0 +1,20 @@
const assert = require('assert');
const airportSync = require('../htdocs/luci-static/resources/view/daede/airport-sync.js');
// dae: derived names must come out as valid dae group names (no manual fix).
assert.strictEqual(airportSync.sanitizeGroupName('sub.example.com', 'dae'), 'sub_example_com');
assert.strictEqual(airportSync.sanitizeGroupName('My Airport', 'dae'), 'My_Airport');
assert.strictEqual(airportSync.sanitizeGroupName('机场_1', 'dae'), '_1');
assert.strictEqual(airportSync.sanitizeGroupName('....', 'dae'), 'airport');
assert.strictEqual(airportSync.sanitizeGroupName('', 'dae'), '');
// whatever dae sanitize returns (non-empty) must pass the group-name validator.
['sub.example.com', 'My Airport', '机场_1', '1up.cloud', 'a.b.c.d'].forEach(function(raw) {
const out = airportSync.sanitizeGroupName(raw, 'dae');
assert.strictEqual(airportSync.isGroupNameValid(out, 'dae'), true, 'invalid dae name from: ' + raw + ' -> ' + out);
});
// daed: any non-empty name is allowed, keep it as-is (incl. CJK and dots).
assert.strictEqual(airportSync.sanitizeGroupName('白月光', 'daed'), '白月光');
assert.strictEqual(airportSync.sanitizeGroupName('sub.example.com', 'daed'), 'sub.example.com');
console.log('airport sanitize name tests passed');