自动缩略图插件审核修改
This commit is contained in:
1 parent
f4e69707c3
commit
d02370d6bd
28 files changed
+2308
-50
No files matched your search
@@ -0,0 +1,197 @@
|
||||
// 复合后缀兼容修改:只生成计划并持久化原值;真正修改 Eagle 字段由插件在用户确认后执行。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
function fullName(name, ext) {
|
||||
return String(name || '') + (ext ? '.' + ext : '');
|
||||
}
|
||||
|
||||
/** 返回需要展示给用户的字段变更;不需要修改或被安全条件阻断时返回 null / blocked。 */
|
||||
function planChange(item, eagleItem, splitCompoundName) {
|
||||
const sourceName = String((item && item.entryName) || '');
|
||||
const currentName = String((eagleItem && eagleItem.name) || '');
|
||||
const currentExt = String((eagleItem && eagleItem.ext) || '');
|
||||
const currentFileName = fullName(currentName, currentExt);
|
||||
if (!sourceName || currentFileName !== sourceName) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: 'sourceMismatch',
|
||||
sourceName,
|
||||
currentName,
|
||||
currentExt,
|
||||
currentFileName,
|
||||
};
|
||||
}
|
||||
const parts = splitCompoundName(sourceName);
|
||||
if (!parts || (currentName === parts.name && currentExt === parts.ext)) return null;
|
||||
if (fullName(parts.name, parts.ext) !== sourceName) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: 'sourceMismatch',
|
||||
sourceName,
|
||||
currentName,
|
||||
currentExt,
|
||||
currentFileName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceName,
|
||||
currentName,
|
||||
currentExt,
|
||||
currentFileName,
|
||||
newName: parts.name,
|
||||
newExt: parts.ext,
|
||||
newFileName: fullName(parts.name, parts.ext),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStore() {
|
||||
return { version: STORE_VERSION, entries: {} };
|
||||
}
|
||||
|
||||
function readStore(file) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (!data || data.version !== STORE_VERSION || !data.entries || typeof data.entries !== 'object') {
|
||||
throw new Error('备份文件格式不正确');
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return emptyStore();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(file, data) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temp = file + '.' + process.pid + '.tmp';
|
||||
fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(temp, file);
|
||||
}
|
||||
|
||||
function createStore(userDataPath) {
|
||||
const file = path.join(userDataPath, 'SpineThumbnailGenerateServer', 'compound-name-backups.json');
|
||||
return {
|
||||
file,
|
||||
list() {
|
||||
return Object.values(readStore(file).entries)
|
||||
.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||
},
|
||||
remember(change) {
|
||||
const data = readStore(file);
|
||||
if (!data.entries[change.itemId]) {
|
||||
data.entries[change.itemId] = {
|
||||
itemId: change.itemId,
|
||||
sourceName: change.sourceName,
|
||||
folderPath: change.folderPath || '',
|
||||
originalName: change.currentName,
|
||||
originalExt: change.currentExt,
|
||||
appliedName: change.newName,
|
||||
appliedExt: change.newExt,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
writeStore(file, data);
|
||||
}
|
||||
return data.entries[change.itemId];
|
||||
},
|
||||
remove(itemId) {
|
||||
const data = readStore(file);
|
||||
if (!data.entries[itemId]) return;
|
||||
delete data.entries[itemId];
|
||||
writeStore(file, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为恢复对话框生成可审计列表,并标出哪些条目允许恢复。
|
||||
* 用户后来亲自改过的字段不会被覆盖。
|
||||
*/
|
||||
async function inspectBackups(store, getItem, sourceExists) {
|
||||
const entries = store.list();
|
||||
const rows = [];
|
||||
for (const entry of entries) {
|
||||
let item = null;
|
||||
try {
|
||||
item = await getItem(entry.itemId);
|
||||
if (!item) {
|
||||
let sourcePresent = false;
|
||||
try {
|
||||
sourcePresent = sourceExists ? await sourceExists(entry) : false;
|
||||
} catch (error) { /* 检查失败时保守按缺失处理 */ }
|
||||
if (sourcePresent) {
|
||||
rows.push({
|
||||
entry,
|
||||
item: null,
|
||||
currentName: entry.appliedName,
|
||||
currentExt: entry.appliedExt,
|
||||
status: 'pluginApplied',
|
||||
});
|
||||
} else {
|
||||
rows.push({ entry, item: null, status: 'missing' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const currentName = String(item.name || '');
|
||||
const currentExt = String(item.ext || '');
|
||||
if (currentName === entry.originalName && currentExt === entry.originalExt) {
|
||||
rows.push({ entry, item, currentName, currentExt, status: 'alreadyOriginal' });
|
||||
continue;
|
||||
}
|
||||
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
|
||||
rows.push({ entry, item, currentName, currentExt, status: 'changed' });
|
||||
continue;
|
||||
}
|
||||
rows.push({ entry, item, currentName, currentExt, status: 'ready' });
|
||||
} catch (error) {
|
||||
rows.push({ entry, item, status: 'error' });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function restoreBackups(store, selectedItemIds, getItem, saveItem) {
|
||||
const selected = new Set(selectedItemIds || []);
|
||||
const entries = store.list().filter((entry) => selected.has(entry.itemId));
|
||||
const result = { total: entries.length, success: 0, skipped: 0, failed: 0 };
|
||||
for (const entry of entries) {
|
||||
let item = null;
|
||||
try {
|
||||
item = await getItem(entry.itemId);
|
||||
if (!item) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const currentName = String(item.name || '');
|
||||
const currentExt = String(item.ext || '');
|
||||
if (currentName === entry.originalName && currentExt === entry.originalExt) {
|
||||
store.remove(entry.itemId);
|
||||
result.success += 1;
|
||||
continue;
|
||||
}
|
||||
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
item.name = entry.originalName;
|
||||
item.ext = entry.originalExt;
|
||||
await saveItem(item);
|
||||
store.remove(entry.itemId);
|
||||
result.success += 1;
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const api = { fullName, planChange, createStore, inspectBackups, restoreBackups, STORE_VERSION };
|
||||
if (typeof window !== 'undefined') window.SpineThumbCompoundCompat = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,127 @@
|
||||
// 多个 Eagle 插件窗口共用 localStorage,用它协调唯一的活动监听实例。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const KEY = 'spine-thumbnail-active-instance';
|
||||
const STALE_MS = 3500;
|
||||
|
||||
function createFileStorage(file) {
|
||||
return {
|
||||
file,
|
||||
getItem(key) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return typeof data[key] === 'string' ? data[key] : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key, value) {
|
||||
let data = {};
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (existing && typeof existing === 'object') data = existing;
|
||||
} catch (error) { /* first write or damaged file */ }
|
||||
data[key] = String(value);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temp = file + '.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(16).slice(2) + '.tmp';
|
||||
fs.writeFileSync(temp, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(temp, file);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readState(storage) {
|
||||
try {
|
||||
const state = JSON.parse(storage.getItem(KEY));
|
||||
if (!state || typeof state.id !== 'string' || !Number.isFinite(state.updatedAt)) return null;
|
||||
return state;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(storage, id, visible, now) {
|
||||
const state = { id, visible, updatedAt: now };
|
||||
try { storage.setItem(KEY, JSON.stringify(state)); } catch (error) { /* 无本地存储时仍可运行 */ }
|
||||
return state;
|
||||
}
|
||||
|
||||
function createCoordinator(options) {
|
||||
const storage = options.storage;
|
||||
const id = options.id;
|
||||
const visible = options.isVisible || (() => true);
|
||||
const hide = options.hide || (() => {});
|
||||
const now = options.now || Date.now;
|
||||
const staleMs = options.staleMs || STALE_MS;
|
||||
const intervalMs = options.intervalMs || 1000;
|
||||
let active = false;
|
||||
let started = false;
|
||||
let hiddenForDuplicate = false;
|
||||
let timer = null;
|
||||
|
||||
function claim() {
|
||||
writeState(storage, id, visible(), now());
|
||||
hiddenForDuplicate = false;
|
||||
if (!active) {
|
||||
active = true;
|
||||
if (options.onActiveChanged) options.onActiveChanged(true);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate() {
|
||||
const state = readState(storage);
|
||||
const currentVisible = visible();
|
||||
const canClaim = !state ||
|
||||
state.id === id ||
|
||||
now() - state.updatedAt > staleMs;
|
||||
const nextActive = canClaim;
|
||||
if (nextActive) {
|
||||
claim();
|
||||
} else if (currentVisible && !hiddenForDuplicate) {
|
||||
hiddenForDuplicate = true;
|
||||
try { hide(); } catch (error) { /* 隐藏失败不影响主窗口 */ }
|
||||
}
|
||||
if (nextActive !== active) {
|
||||
active = nextActive;
|
||||
if (options.onActiveChanged) options.onActiveChanged(active);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
if (visible()) claim();
|
||||
else evaluate();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return active;
|
||||
started = true;
|
||||
const state = readState(storage);
|
||||
const canStart = visible() ||
|
||||
!state ||
|
||||
state.id === id ||
|
||||
now() - state.updatedAt > staleMs;
|
||||
if (canStart) claim();
|
||||
else evaluate();
|
||||
timer = setInterval(evaluate, intervalMs);
|
||||
return active;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
started = false;
|
||||
}
|
||||
|
||||
return { start, stop, evaluate, handleVisibility, isActive: () => active };
|
||||
}
|
||||
|
||||
const api = { createCoordinator, createFileStorage, KEY, STALE_MS };
|
||||
if (typeof window !== 'undefined') window.SpineThumbInstanceCoordinator = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||||
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||||
const VERSION = 'v68';
|
||||
const VERSION = 'v72';
|
||||
const SHOW_DEV_VERSION = false;
|
||||
|
||||
const path = require('path');
|
||||
@@ -16,7 +16,7 @@ const REQUIRED = [
|
||||
'SpineThumbI18n',
|
||||
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
|
||||
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
|
||||
'SpineThumbProgress',
|
||||
'SpineThumbProgress', 'SpineThumbCompoundCompat', 'SpineThumbInstanceCoordinator',
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !window[name]);
|
||||
if (missing.length) {
|
||||
@@ -30,6 +30,8 @@ const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
|
||||
const settingsStore = window.SpineThumbSettings;
|
||||
const utils = window.SpineUtils;
|
||||
const progress = window.SpineThumbProgress;
|
||||
const compoundCompat = window.SpineThumbCompoundCompat;
|
||||
const instanceCoordinatorModule = window.SpineThumbInstanceCoordinator;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const libraryEl = document.getElementById('library');
|
||||
@@ -37,9 +39,18 @@ const statsEl = document.getElementById('stats');
|
||||
const logEl = document.getElementById('log');
|
||||
const toggleEl = document.getElementById('toggle');
|
||||
const clearEl = document.getElementById('clear');
|
||||
const restoreNamesEl = document.getElementById('restoreNames');
|
||||
const filtersEl = document.getElementById('filters');
|
||||
const modeEl = document.getElementById('mode');
|
||||
const strategyEl = document.getElementById('strategy');
|
||||
const compatDialogEl = document.getElementById('compatDialog');
|
||||
const compatListEl = document.getElementById('compatList');
|
||||
const compatConfirmEl = document.getElementById('compatConfirm');
|
||||
const compatSkipEl = document.getElementById('compatSkip');
|
||||
const restoreDialogEl = document.getElementById('restoreDialog');
|
||||
const restoreListEl = document.getElementById('restoreList');
|
||||
const restoreCancelEl = document.getElementById('restoreCancel');
|
||||
const restoreApplyEl = document.getElementById('restoreApply');
|
||||
|
||||
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
|
||||
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
|
||||
@@ -49,6 +60,9 @@ const PROGRESS_MIN_DYNAMIC = 10;
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
let settings = { ...settingsStore.DEFAULTS };
|
||||
let compatStore = null;
|
||||
let instanceCoordinator = null;
|
||||
let pluginUserDataPath = null;
|
||||
let libraryPath = null;
|
||||
let imagesDir = null;
|
||||
const queue = [];
|
||||
@@ -114,10 +128,19 @@ function loadFolderPaths() {
|
||||
} catch (error) { /* 读不到就只显示未分类 */ }
|
||||
}
|
||||
|
||||
function folderPathOf(folderIds) {
|
||||
function optionalFolderPathOf(folderIds) {
|
||||
if (!folderPaths) loadFolderPaths();
|
||||
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
|
||||
return (id && folderPaths.get(id)) || t('thumb.uncategorized');
|
||||
return (id && folderPaths.get(id)) || '';
|
||||
}
|
||||
|
||||
function folderPathOf(folderIds) {
|
||||
return optionalFolderPathOf(folderIds) || t('thumb.uncategorized');
|
||||
}
|
||||
|
||||
function backupSourceExists(entry) {
|
||||
if (!imagesDir || !entry.sourceName) return false;
|
||||
return fs.existsSync(path.join(imagesDir, entry.itemId + '.info', entry.sourceName));
|
||||
}
|
||||
|
||||
/** 日志里的渲染结果描述。 */
|
||||
@@ -161,9 +184,247 @@ function countRun(outcome, animated, name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
|
||||
function compoundMatchesApproval(plan, approved) {
|
||||
return plan && !plan.blocked &&
|
||||
plan.currentName === approved.currentName &&
|
||||
plan.currentExt === approved.currentExt &&
|
||||
plan.newName === approved.newName &&
|
||||
plan.newExt === approved.newExt;
|
||||
}
|
||||
|
||||
function showCompoundConfirmation(changes) {
|
||||
compatListEl.textContent = '';
|
||||
const checkboxes = [];
|
||||
const updateConfirm = () => {
|
||||
const count = checkboxes.filter((input) => input.checked).length;
|
||||
compatConfirmEl.disabled = count === 0;
|
||||
compatConfirmEl.textContent = t('thumb.compoundConfirmSelected', { count });
|
||||
};
|
||||
for (const change of changes) {
|
||||
const plan = change.plan;
|
||||
const row = document.createElement('label');
|
||||
row.className = 'compat-row';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = true;
|
||||
input.dataset.itemId = itemIdOf(change.item.dirName);
|
||||
input.addEventListener('change', updateConfirm);
|
||||
checkboxes.push(input);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'compat-row-content';
|
||||
const file = document.createElement('div');
|
||||
file.className = 'compat-file';
|
||||
file.textContent = plan.sourceName;
|
||||
const location = document.createElement('div');
|
||||
location.className = 'compat-path';
|
||||
location.textContent = optionalFolderPathOf(change.item.folders);
|
||||
const values = document.createElement('div');
|
||||
values.className = 'compat-change';
|
||||
const oldValue = document.createElement('code');
|
||||
oldValue.textContent = 'name=' + JSON.stringify(plan.currentName) + '; ext=' + JSON.stringify(plan.currentExt);
|
||||
const newValue = document.createElement('code');
|
||||
newValue.textContent = 'name=' + JSON.stringify(plan.newName) + '; ext=' + JSON.stringify(plan.newExt);
|
||||
values.append(
|
||||
t('thumb.compoundOldValue') + ' ', oldValue,
|
||||
' → ', t('thumb.compoundNewValue') + ' ', newValue,
|
||||
);
|
||||
content.append(file);
|
||||
if (location.textContent) content.append(location);
|
||||
content.append(values);
|
||||
row.append(input, content);
|
||||
compatListEl.appendChild(row);
|
||||
}
|
||||
updateConfirm();
|
||||
compatDialogEl.classList.remove('hidden');
|
||||
try {
|
||||
if (eagle.window && eagle.window.show) {
|
||||
Promise.resolve(eagle.window.show())
|
||||
.then(() => eagle.window.focus && eagle.window.focus())
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (error) { /* 窗口显示失败时仍可处理后续批次 */ }
|
||||
return new Promise((resolve) => {
|
||||
const finish = (selected) => {
|
||||
compatDialogEl.classList.add('hidden');
|
||||
compatConfirmEl.removeEventListener('click', confirm);
|
||||
compatSkipEl.removeEventListener('click', skip);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(selected);
|
||||
};
|
||||
const confirm = () => finish(checkboxes
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.dataset.itemId));
|
||||
const skip = () => finish([]);
|
||||
const keydown = (event) => {
|
||||
if (event.key === 'Escape') skip();
|
||||
};
|
||||
compatConfirmEl.addEventListener('click', confirm);
|
||||
compatSkipEl.addEventListener('click', skip);
|
||||
document.addEventListener('keydown', keydown);
|
||||
checkboxes[0].focus();
|
||||
});
|
||||
}
|
||||
|
||||
function fieldsText(name, ext) {
|
||||
return 'name=' + JSON.stringify(name) + '; ext=' + JSON.stringify(ext);
|
||||
}
|
||||
|
||||
function showRestoreSelection(rows) {
|
||||
restoreListEl.textContent = '';
|
||||
const checkboxes = [];
|
||||
const statusKeys = {
|
||||
ready: 'restoreStatusReady',
|
||||
pluginApplied: 'restoreStatusReady',
|
||||
alreadyOriginal: 'restoreStatusAlreadyOriginal',
|
||||
changed: 'restoreStatusChanged',
|
||||
missing: 'restoreStatusMissing',
|
||||
error: 'restoreStatusError',
|
||||
};
|
||||
const updateApply = () => {
|
||||
const count = checkboxes.filter((input) => !input.disabled && input.checked).length;
|
||||
restoreApplyEl.disabled = count === 0;
|
||||
restoreApplyEl.textContent = t('thumb.restoreSelected', { count });
|
||||
};
|
||||
for (const row of rows) {
|
||||
const entry = row.entry;
|
||||
const canRestore = row.status === 'ready' || row.status === 'alreadyOriginal';
|
||||
const label = document.createElement('label');
|
||||
label.className = 'restore-row' + (canRestore ? '' : ' disabled');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = canRestore;
|
||||
input.disabled = !canRestore;
|
||||
input.dataset.itemId = entry.itemId;
|
||||
input.addEventListener('change', updateApply);
|
||||
checkboxes.push(input);
|
||||
const details = document.createElement('div');
|
||||
const file = document.createElement('div');
|
||||
file.className = 'compat-file';
|
||||
file.textContent = entry.sourceName || entry.itemId;
|
||||
const location = document.createElement('div');
|
||||
location.className = 'compat-path';
|
||||
location.textContent = entry.folderPath || '';
|
||||
const current = document.createElement('div');
|
||||
current.className = 'restore-current';
|
||||
current.textContent = t('thumb.restoreCurrent') + ' ' + fieldsText(
|
||||
row.currentName === undefined ? '' : row.currentName,
|
||||
row.currentExt === undefined ? '' : row.currentExt,
|
||||
);
|
||||
const target = document.createElement('div');
|
||||
target.className = 'restore-target';
|
||||
target.textContent = t('thumb.restoreTarget') + ' ' + fieldsText(entry.originalName, entry.originalExt);
|
||||
const status = document.createElement('div');
|
||||
status.className = 'restore-status';
|
||||
if (row.status === 'ready' || row.status === 'pluginApplied') {
|
||||
status.classList.add('applied');
|
||||
status.textContent = t('thumb.restoreStatusReady', {
|
||||
fields: fieldsText(row.currentName, row.currentExt),
|
||||
});
|
||||
} else {
|
||||
status.textContent = t('thumb.' + statusKeys[row.status]);
|
||||
}
|
||||
details.append(file);
|
||||
if (location.textContent) details.append(location);
|
||||
details.append(current, target, status);
|
||||
label.append(input, details);
|
||||
restoreListEl.appendChild(label);
|
||||
}
|
||||
updateApply();
|
||||
restoreDialogEl.classList.remove('hidden');
|
||||
try {
|
||||
if (eagle.window && eagle.window.show) {
|
||||
Promise.resolve(eagle.window.show())
|
||||
.then(() => eagle.window.focus && eagle.window.focus())
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (error) { /* 列表仍可通过已打开的面板操作 */ }
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value) => {
|
||||
restoreDialogEl.classList.add('hidden');
|
||||
restoreCancelEl.removeEventListener('click', cancel);
|
||||
restoreApplyEl.removeEventListener('click', apply);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(value);
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
const apply = () => finish(checkboxes
|
||||
.filter((input) => !input.disabled && input.checked)
|
||||
.map((input) => input.dataset.itemId));
|
||||
const keydown = (event) => {
|
||||
if (event.key === 'Escape') cancel();
|
||||
};
|
||||
restoreCancelEl.addEventListener('click', cancel);
|
||||
restoreApplyEl.addEventListener('click', apply);
|
||||
document.addEventListener('keydown', keydown);
|
||||
const first = checkboxes.find((input) => !input.disabled);
|
||||
(first || restoreCancelEl).focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改前统一检查已有缩略图、真实文件名和用户确认,避免任何静默元数据变更。 */
|
||||
async function prepareCompoundCompatibility(items) {
|
||||
const changes = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
const eagleItem = await eagle.item.getById(itemIdOf(item.dirName));
|
||||
if (!eagleItem || eagleItem.customThumbnail === true) continue;
|
||||
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
|
||||
if (!plan) continue;
|
||||
const label = item.entryName + ' | ' + folderPathOf(item.folders);
|
||||
if (plan.blocked) {
|
||||
item.compoundBlocked = plan;
|
||||
log(t('thumb.compoundSourceMismatch', {
|
||||
label,
|
||||
current: plan.currentFileName,
|
||||
source: plan.sourceName,
|
||||
}), 'warn');
|
||||
continue;
|
||||
}
|
||||
changes.push({ item, plan });
|
||||
} catch (error) {
|
||||
item.compoundBlocked = { error };
|
||||
log(t('thumb.compoundCheckFailed', {
|
||||
label: item.entryName,
|
||||
error: (error && error.message) || error,
|
||||
}), 'warn');
|
||||
}
|
||||
}
|
||||
if (!changes.length) return;
|
||||
if (!compatStore) {
|
||||
for (const change of changes) change.item.compoundBlocked = { error: 'backup unavailable' };
|
||||
log(t('thumb.compoundBackupUnavailable'), 'warn');
|
||||
return;
|
||||
}
|
||||
const selectedIds = new Set(await showCompoundConfirmation(changes));
|
||||
for (const change of changes) {
|
||||
if (selectedIds.has(itemIdOf(change.item.dirName))) change.item.compoundApproval = change.plan;
|
||||
else change.item.compoundDeclined = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackCompoundChange(eagleItem, backup, label) {
|
||||
if (!backup) return false;
|
||||
try {
|
||||
eagleItem.name = backup.originalName;
|
||||
eagleItem.ext = backup.originalExt;
|
||||
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
|
||||
compatStore.remove(backup.itemId);
|
||||
log(t('thumb.compoundRolledBack', { label }), 'warn');
|
||||
return true;
|
||||
} catch (error) {
|
||||
log(t('thumb.compoundRollbackFailed', {
|
||||
label,
|
||||
error: (error && error.message) || error,
|
||||
}), 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个素材的缩略图生成流程:查重 → 确认兼容变更 → 渲染 → 写临时文件 → 设置 → 清理。 */
|
||||
async function generateOne(item) {
|
||||
let tempPath = null;
|
||||
let compatibilityAttempted = false;
|
||||
let compatibilityBackup = null;
|
||||
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
|
||||
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
|
||||
const label = item.entryName + ' | ' + folderPathOf(item.folders);
|
||||
@@ -176,21 +437,6 @@ async function generateOne(item) {
|
||||
log(t('thumb.missingItem', { label }), 'warn');
|
||||
return;
|
||||
}
|
||||
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
|
||||
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
|
||||
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
|
||||
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
|
||||
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
|
||||
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
|
||||
try {
|
||||
eagleItem.name = parts.name;
|
||||
eagleItem.ext = parts.ext;
|
||||
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
|
||||
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
|
||||
} catch (error) {
|
||||
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
}
|
||||
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
|
||||
if (eagleItem.customThumbnail === true) {
|
||||
stats.skipped += 1;
|
||||
@@ -198,6 +444,47 @@ async function generateOne(item) {
|
||||
log(t('thumb.existingThumbnail', { label }), 'dim');
|
||||
return;
|
||||
}
|
||||
if (item.compoundDeclined || item.compoundBlocked) {
|
||||
stats.skipped += 1;
|
||||
countRun('skipped', false, item.entryName);
|
||||
log(t('thumb.compoundSkipped', { label }), 'warn');
|
||||
return;
|
||||
}
|
||||
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
|
||||
if (plan && plan.blocked) {
|
||||
stats.skipped += 1;
|
||||
countRun('skipped', false, item.entryName);
|
||||
log(t('thumb.compoundSourceMismatch', {
|
||||
label,
|
||||
current: plan.currentFileName,
|
||||
source: plan.sourceName,
|
||||
}), 'warn');
|
||||
return;
|
||||
}
|
||||
if (plan) {
|
||||
const approved = item.compoundApproval;
|
||||
if (!approved || !compoundMatchesApproval(plan, approved)) {
|
||||
stats.skipped += 1;
|
||||
countRun('skipped', false, item.entryName);
|
||||
log(t('thumb.compoundNeedsConfirmation', { label }), 'warn');
|
||||
return;
|
||||
}
|
||||
if (!compatStore) throw new Error(t('thumb.compoundBackupUnavailable'));
|
||||
compatibilityBackup = compatStore.remember({
|
||||
itemId,
|
||||
sourceName: plan.sourceName,
|
||||
folderPath: folderPathOf(item.folders),
|
||||
currentName: plan.currentName,
|
||||
currentExt: plan.currentExt,
|
||||
newName: plan.newName,
|
||||
newExt: plan.newExt,
|
||||
});
|
||||
eagleItem.name = plan.newName;
|
||||
eagleItem.ext = plan.newExt;
|
||||
compatibilityAttempted = true;
|
||||
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
|
||||
log(t('thumb.suffixFixed', { label, name: plan.newFileName }), 'dim');
|
||||
}
|
||||
const version = utils.detectVersion(item.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
|
||||
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
|
||||
@@ -228,6 +515,9 @@ async function generateOne(item) {
|
||||
countRun('generated', rendered.mode === 'animated', item.entryName);
|
||||
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
|
||||
} catch (error) {
|
||||
if (compatibilityAttempted) {
|
||||
await rollbackCompoundChange(eagleItem, compatibilityBackup, label);
|
||||
}
|
||||
stats.failed += 1;
|
||||
countRun('failed', false, item.entryName);
|
||||
log(t('thumb.generateFailed', { label, error: (error && error.message) || error }), 'fail');
|
||||
@@ -400,6 +690,7 @@ async function handleBatchAsync({ added, total, reason }) {
|
||||
setStatus(t('thumb.statusAutoSkipped'));
|
||||
return;
|
||||
}
|
||||
await prepareCompoundCompatibility(ready);
|
||||
queue.push(...ready);
|
||||
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
|
||||
if (runStats) runStats.total += ready.length;
|
||||
@@ -450,12 +741,57 @@ async function start() {
|
||||
}
|
||||
|
||||
function startSafely() {
|
||||
if (instanceCoordinator && !instanceCoordinator.isActive()) return;
|
||||
start().catch((error) => {
|
||||
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
|
||||
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeInstanceCoordinator(plugin) {
|
||||
if (instanceCoordinator) {
|
||||
instanceCoordinator.evaluate();
|
||||
return;
|
||||
}
|
||||
await initializeCompatStore();
|
||||
const stateFile = pluginUserDataPath
|
||||
? path.join(pluginUserDataPath, 'SpineThumbnailGenerateServer', 'active-instance.json')
|
||||
: path.join(os.tmpdir(), 'spine-thumbnail-active-instance.json');
|
||||
const storage = instanceCoordinatorModule.createFileStorage(stateFile);
|
||||
const id = String(window.windowID || (plugin && plugin.webContentsID) || Date.now());
|
||||
instanceCoordinator = instanceCoordinatorModule.createCoordinator({
|
||||
storage,
|
||||
id,
|
||||
isVisible: () => document.visibilityState === 'visible',
|
||||
hide: () => eagle.window && eagle.window.hide && eagle.window.hide(),
|
||||
onActiveChanged(active) {
|
||||
if (active) {
|
||||
startSafely();
|
||||
return;
|
||||
}
|
||||
libraryWatcher.stop();
|
||||
queue.length = 0;
|
||||
try { if (eagle.window && eagle.window.hide) eagle.window.hide(); } catch (error) { /* already hidden */ }
|
||||
},
|
||||
intervalMs: 250,
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => instanceCoordinator.handleVisibility());
|
||||
window.addEventListener('beforeunload', () => instanceCoordinator.stop());
|
||||
instanceCoordinator.start();
|
||||
}
|
||||
|
||||
async function initializeCompatStore() {
|
||||
if (compatStore) return true;
|
||||
try {
|
||||
pluginUserDataPath = await eagle.app.getPath('userData');
|
||||
compatStore = compoundCompat.createStore(pluginUserDataPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log(t('thumb.compoundBackupUnavailable') + ': ' + ((error && error.message) || error), 'warn');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await settingsStore.read();
|
||||
@@ -490,6 +826,44 @@ clearEl.addEventListener('click', () => {
|
||||
logEl.textContent = '';
|
||||
});
|
||||
|
||||
restoreNamesEl.addEventListener('click', async () => {
|
||||
if (!compatStore) {
|
||||
log(t('thumb.compoundBackupUnavailable'), 'warn');
|
||||
return;
|
||||
}
|
||||
let rows;
|
||||
try {
|
||||
rows = await compoundCompat.inspectBackups(
|
||||
compatStore,
|
||||
(itemId) => eagle.item.getById(itemId),
|
||||
backupSourceExists,
|
||||
);
|
||||
} catch (error) {
|
||||
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
log(t('thumb.restoreEmpty'), 'dim');
|
||||
return;
|
||||
}
|
||||
const selected = await showRestoreSelection(rows);
|
||||
if (!selected || !selected.length) return;
|
||||
restoreNamesEl.disabled = true;
|
||||
try {
|
||||
const result = await compoundCompat.restoreBackups(
|
||||
compatStore,
|
||||
selected,
|
||||
(itemId) => eagle.item.getById(itemId),
|
||||
(item) => withTimeout(item.save(), 20000, t('thumb.restoreNames')),
|
||||
);
|
||||
log(t('thumb.restoreComplete', result), result.failed ? 'warn' : 'ok');
|
||||
} catch (error) {
|
||||
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
|
||||
} finally {
|
||||
restoreNamesEl.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modeEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
|
||||
try {
|
||||
@@ -535,12 +909,13 @@ if (typeof eagle === 'undefined') {
|
||||
log(t('thumb.pluginCreated', {
|
||||
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
|
||||
}));
|
||||
await initializeCompatStore();
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
startSafely();
|
||||
initializeInstanceCoordinator(plugin);
|
||||
});
|
||||
eagle.onLibraryChanged((changedPath) => {
|
||||
log(t('thumb.switchingLibrary', { path: changedPath }));
|
||||
@@ -552,7 +927,8 @@ if (typeof eagle === 'undefined') {
|
||||
if (i18n) i18n.setLocale(i18n.requestedLocale());
|
||||
logVersion();
|
||||
log(t('thumb.noCreateEvent'), 'dim');
|
||||
loadSettings().catch(() => {}).then(startSafely);
|
||||
Promise.all([initializeCompatStore(), loadSettings().catch(() => {})])
|
||||
.then(() => initializeInstanceCoordinator());
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
@@ -203,7 +203,7 @@ function splitCompoundName(fileName) {
|
||||
if (!suffix) return null;
|
||||
const name = String(fileName).slice(0, fileName.length - suffix.length);
|
||||
if (!name) return null;
|
||||
return { name, ext: suffix.slice(1) };
|
||||
return { name, ext: String(fileName).slice(-suffix.length + 1) };
|
||||
}
|
||||
|
||||
/** 从 JSON 文本中读取 spine 版本 */
|
||||
|
||||
Reference in new issue
Block a user