mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 18:41:15 +08:00
🤞 Sync 2026-06-15 08:50:27
This commit is contained in:
parent
9475a645b7
commit
d2ec4a48f6
@ -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)
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
@ -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})/);
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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。重新选择上方节点即可更新此组。"
|
||||
|
||||
@ -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。重新选择上方节点即可更新此组。"
|
||||
|
||||
@ -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
|
||||
|
||||
22
luci-app-daede/tests/airport-group-name.js
Normal file
22
luci-app-daede/tests/airport-group-name.js
Normal 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');
|
||||
20
luci-app-daede/tests/airport-sanitize-name.js
Normal file
20
luci-app-daede/tests/airport-sanitize-name.js
Normal 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');
|
||||
Loading…
Reference in New Issue
Block a user