mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
Fix manual backup manifest filename handling
This commit is contained in:
18
index.js
18
index.js
@@ -13123,6 +13123,7 @@ async function onManageServerBackups() {
|
||||
async function onDeleteServerBackupEntry(payload = {}) {
|
||||
const chatId = String(payload?.chatId || "").trim();
|
||||
const filename = String(payload?.filename || "").trim();
|
||||
const serverPath = String(payload?.serverPath || "").trim();
|
||||
if (!chatId) {
|
||||
return {
|
||||
deleted: false,
|
||||
@@ -13138,12 +13139,27 @@ async function onDeleteServerBackupEntry(payload = {}) {
|
||||
buildBmeSyncRuntimeOptions({
|
||||
reason: "delete-backup",
|
||||
trigger: "panel:delete-backup",
|
||||
filename,
|
||||
serverPath,
|
||||
}),
|
||||
);
|
||||
|
||||
const currentChatId = getCurrentChatId();
|
||||
if (
|
||||
deleteResult?.deleted &&
|
||||
currentChatId &&
|
||||
normalizeChatIdCandidate(currentChatId) ===
|
||||
normalizeChatIdCandidate(chatId)
|
||||
) {
|
||||
await syncIndexedDbMetaToPersistenceState(chatId, {
|
||||
syncState: "idle",
|
||||
lastSyncError: "",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...deleteResult,
|
||||
filename,
|
||||
filename: deleteResult?.filename || filename,
|
||||
handledToast: true,
|
||||
skipDashboardRefresh: true,
|
||||
};
|
||||
|
||||
286
sync/bme-sync.js
286
sync/bme-sync.js
@@ -368,56 +368,207 @@ async function upsertBackupManifestEntry(entry, options = {}) {
|
||||
await writeBackupManifest(filteredEntries, options);
|
||||
}
|
||||
|
||||
function normalizeSelectedBackupFilename(filename) {
|
||||
const normalized = String(filename ?? "")
|
||||
.trim()
|
||||
.replace(/^\/+/, "");
|
||||
if (
|
||||
!normalized
|
||||
|| normalized === BME_BACKUP_MANIFEST_FILENAME
|
||||
|| /[\\/]/.test(normalized)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSelectedBackupServerPath(serverPath, fallbackFilename = "") {
|
||||
const normalizedPath = String(serverPath ?? "")
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+/, "");
|
||||
if (normalizedPath && !normalizedPath.includes("..")) {
|
||||
return `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
const normalizedFilename = normalizeSelectedBackupFilename(fallbackFilename);
|
||||
return normalizedFilename ? `/user/files/${normalizedFilename}` : "";
|
||||
}
|
||||
|
||||
function sortBackupManifestEntries(entries = []) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const timeDelta =
|
||||
normalizeTimestamp(right.backupTime, 0) -
|
||||
normalizeTimestamp(left.backupTime, 0);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
|
||||
const modifiedDelta =
|
||||
normalizeTimestamp(right.lastModified, 0) -
|
||||
normalizeTimestamp(left.lastModified, 0);
|
||||
if (modifiedDelta !== 0) return modifiedDelta;
|
||||
|
||||
return String(left.filename || "").localeCompare(
|
||||
String(right.filename || ""),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBackupLookupContext(chatId, options = {}) {
|
||||
const normalizedChatId = normalizeChatId(chatId);
|
||||
const explicitFilename = normalizeSelectedBackupFilename(
|
||||
options.filename || options.backupFilename,
|
||||
);
|
||||
const explicitServerPath = normalizeSelectedBackupServerPath(
|
||||
options.serverPath,
|
||||
explicitFilename,
|
||||
);
|
||||
|
||||
let manifestEntries = [];
|
||||
let manifestError = null;
|
||||
try {
|
||||
manifestEntries = sortBackupManifestEntries(await fetchBackupManifest(options));
|
||||
} catch (error) {
|
||||
manifestError = error;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
const candidateIndexByFilename = new Map();
|
||||
const pushCandidate = (filename, serverPath = "") => {
|
||||
const normalizedFilename = normalizeSelectedBackupFilename(filename);
|
||||
if (!normalizedFilename) return;
|
||||
|
||||
const normalizedServerPath = normalizeSelectedBackupServerPath(
|
||||
serverPath,
|
||||
normalizedFilename,
|
||||
);
|
||||
const existingIndex = candidateIndexByFilename.get(normalizedFilename);
|
||||
if (existingIndex != null) {
|
||||
if (
|
||||
normalizedServerPath &&
|
||||
!candidates[existingIndex].serverPath
|
||||
) {
|
||||
candidates[existingIndex].serverPath = normalizedServerPath;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
candidateIndexByFilename.set(normalizedFilename, candidates.length);
|
||||
candidates.push({
|
||||
filename: normalizedFilename,
|
||||
serverPath: normalizedServerPath,
|
||||
});
|
||||
};
|
||||
|
||||
pushCandidate(explicitFilename, explicitServerPath);
|
||||
|
||||
if (explicitFilename) {
|
||||
for (const entry of manifestEntries) {
|
||||
if (entry.filename === explicitFilename) {
|
||||
pushCandidate(entry.filename, entry.serverPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of manifestEntries) {
|
||||
if (normalizeChatId(entry.chatId) === normalizedChatId) {
|
||||
pushCandidate(entry.filename, entry.serverPath);
|
||||
}
|
||||
}
|
||||
|
||||
pushCandidate(buildBackupFilename(normalizedChatId));
|
||||
|
||||
return {
|
||||
explicitFilename,
|
||||
manifestEntries,
|
||||
manifestError,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBackupEnvelope(chatId, options = {}) {
|
||||
const normalizedChatId = normalizeChatId(chatId);
|
||||
const backupFilename = buildBackupFilename(normalizedChatId);
|
||||
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
|
||||
const fetchImpl = getFetch(options);
|
||||
const fallbackFilename = buildBackupFilename(normalizedChatId);
|
||||
let lastMissingFilename = lookup.candidates[0]?.filename || fallbackFilename;
|
||||
|
||||
for (const candidate of lookup.candidates) {
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
`${candidate.serverPath || `/user/files/${encodeURIComponent(candidate.filename)}`}?t=${Date.now()}`,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (response.status === 404) {
|
||||
lastMissingFilename = candidate.filename;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => response.statusText);
|
||||
return {
|
||||
exists: false,
|
||||
filename: candidate.filename,
|
||||
envelope: null,
|
||||
reason: "backup-read-error",
|
||||
error: new Error(errorText || `HTTP ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
|
||||
if (!envelope) {
|
||||
return {
|
||||
exists: false,
|
||||
filename: candidate.filename,
|
||||
envelope: null,
|
||||
reason: "invalid-backup",
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
filename: candidate.filename,
|
||||
envelope,
|
||||
reason: "ok",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: false,
|
||||
filename: candidate.filename,
|
||||
envelope: null,
|
||||
reason: "backup-read-error",
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exists: false,
|
||||
filename: lastMissingFilename,
|
||||
envelope: null,
|
||||
reason: "not-found",
|
||||
manifestError: lookup.manifestError,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncDeletedBackupMeta(chatId, remainingEntry, options = {}) {
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
`/user/files/${encodeURIComponent(backupFilename)}?t=${Date.now()}`,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
exists: false,
|
||||
filename: backupFilename,
|
||||
envelope: null,
|
||||
reason: "not-found",
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => response.statusText);
|
||||
throw new Error(errorText || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
|
||||
if (!envelope) {
|
||||
return {
|
||||
exists: false,
|
||||
filename: backupFilename,
|
||||
envelope: null,
|
||||
reason: "invalid-backup",
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
filename: backupFilename,
|
||||
envelope,
|
||||
reason: "ok",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: false,
|
||||
filename: backupFilename,
|
||||
envelope: null,
|
||||
reason: "backup-read-error",
|
||||
error,
|
||||
};
|
||||
const db = await getDb(chatId, options);
|
||||
await patchDbMeta(db, {
|
||||
lastBackupUploadedAt: remainingEntry
|
||||
? normalizeTimestamp(
|
||||
remainingEntry.backupTime || remainingEntry.lastModified,
|
||||
0,
|
||||
)
|
||||
: 0,
|
||||
lastBackupFilename: remainingEntry
|
||||
? String(remainingEntry.filename || "")
|
||||
: "",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1891,7 +2042,18 @@ export async function deleteServerBackup(chatId, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const filename = buildBackupFilename(normalizedChatId);
|
||||
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
|
||||
const targetCandidate = lookup.candidates[0] || {
|
||||
filename: buildBackupFilename(normalizedChatId),
|
||||
serverPath: normalizeSelectedBackupServerPath(
|
||||
"",
|
||||
buildBackupFilename(normalizedChatId),
|
||||
),
|
||||
};
|
||||
const filename = targetCandidate.filename;
|
||||
const serverPath =
|
||||
targetCandidate.serverPath ||
|
||||
normalizeSelectedBackupServerPath("", filename);
|
||||
const fetchImpl = getFetch(options);
|
||||
|
||||
try {
|
||||
@@ -1902,7 +2064,7 @@ export async function deleteServerBackup(chatId, options = {}) {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: `/user/files/${filename}`,
|
||||
path: serverPath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1912,11 +2074,33 @@ export async function deleteServerBackup(chatId, options = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const existingEntries = await fetchBackupManifest(options);
|
||||
const existingEntries =
|
||||
lookup.manifestError == null
|
||||
? lookup.manifestEntries
|
||||
: await fetchBackupManifest(options);
|
||||
const filteredEntries = existingEntries.filter(
|
||||
(entry) => entry.filename !== filename,
|
||||
);
|
||||
await writeBackupManifest(filteredEntries, options);
|
||||
|
||||
const remainingEntry =
|
||||
sortBackupManifestEntries(
|
||||
filteredEntries.filter(
|
||||
(entry) => normalizeChatId(entry.chatId) === normalizedChatId,
|
||||
),
|
||||
)[0] || null;
|
||||
const localMetaUpdated = await syncDeletedBackupMeta(
|
||||
normalizedChatId,
|
||||
remainingEntry,
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
chatId: normalizedChatId,
|
||||
filename,
|
||||
localMetaUpdated,
|
||||
};
|
||||
} catch (manifestError) {
|
||||
return {
|
||||
deleted: false,
|
||||
@@ -1927,12 +2111,6 @@ export async function deleteServerBackup(chatId, options = {}) {
|
||||
error: manifestError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
chatId: normalizedChatId,
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] 删除服务端备份失败:", error);
|
||||
return {
|
||||
|
||||
@@ -714,12 +714,15 @@ async function testManualBackupAndRestoreFlow() {
|
||||
|
||||
const deleteResult = await deleteServerBackup("chat-backup-flow", runtime);
|
||||
assert.equal(deleteResult.deleted, true);
|
||||
assert.equal(deleteResult.localMetaUpdated, true);
|
||||
const manifestAfterDelete = await listServerBackups(runtime);
|
||||
assert.equal(manifestAfterDelete.entries.length, 0);
|
||||
assert.equal(
|
||||
Array.from(remoteFiles.keys()).some((key) => key.startsWith("ST-BME_backup_")),
|
||||
false,
|
||||
);
|
||||
assert.equal(db.meta.get("lastBackupUploadedAt"), 0);
|
||||
assert.equal(db.meta.get("lastBackupFilename"), "");
|
||||
}
|
||||
|
||||
async function testBackupManifestReadFailureDoesNotOverwriteManifest() {
|
||||
@@ -806,6 +809,130 @@ async function testRestoreValidationDoesNotCreateSafetySnapshot() {
|
||||
assert.equal(safetyStatus.exists, false);
|
||||
}
|
||||
|
||||
async function testRestoreUsesManifestFilenameWhenCurrentFilenameDrifts() {
|
||||
const { fetch, remoteFiles } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
const db = new FakeDb("chat-filename-drift");
|
||||
const safetyDb = new FakeDb(buildRestoreSafetyChatId("chat-filename-drift"));
|
||||
dbByChatId.set("chat-filename-drift", db);
|
||||
|
||||
const legacyFilename = "ST-BME_backup_chat-filename-drift-legacy.json";
|
||||
remoteFiles.set(legacyFilename, {
|
||||
kind: "st-bme-backup",
|
||||
version: 1,
|
||||
chatId: "chat-filename-drift",
|
||||
createdAt: 123,
|
||||
sourceDeviceId: "remote-device",
|
||||
snapshot: {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-filename-drift",
|
||||
revision: 7,
|
||||
lastModified: 70,
|
||||
deviceId: "remote-device",
|
||||
nodeCount: 1,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [{ id: "restored-from-drift", updatedAt: 70 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 5,
|
||||
extractionCount: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
remoteFiles.set("ST-BME_BackupManifest.json", [
|
||||
{
|
||||
filename: legacyFilename,
|
||||
serverPath: `user/files/${legacyFilename}`,
|
||||
chatId: "chat-filename-drift",
|
||||
revision: 7,
|
||||
lastModified: 70,
|
||||
backupTime: 123,
|
||||
size: 256,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const runtime = {
|
||||
...buildRuntimeOptions({ dbByChatId, fetch }),
|
||||
getSafetyDb: async () => safetyDb,
|
||||
};
|
||||
|
||||
const restoreResult = await restoreFromServer("chat-filename-drift", runtime);
|
||||
assert.equal(restoreResult.restored, true);
|
||||
assert.equal(restoreResult.filename, legacyFilename);
|
||||
assert.equal(db.snapshot.nodes[0].id, "restored-from-drift");
|
||||
}
|
||||
|
||||
async function testDeleteUsesExplicitManifestFilenameAndClearsLocalBackupMeta() {
|
||||
const { fetch, remoteFiles } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
const db = new FakeDb("chat-delete-drift");
|
||||
db.meta.set("lastBackupUploadedAt", 999);
|
||||
db.meta.set("lastBackupFilename", "ST-BME_backup_chat-delete-drift-stale.json");
|
||||
dbByChatId.set("chat-delete-drift", db);
|
||||
|
||||
const driftFilename = "ST-BME_backup_chat-delete-drift-legacy.json";
|
||||
remoteFiles.set(driftFilename, {
|
||||
kind: "st-bme-backup",
|
||||
version: 1,
|
||||
chatId: "chat-delete-drift",
|
||||
createdAt: 321,
|
||||
sourceDeviceId: "remote-device",
|
||||
snapshot: {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-delete-drift",
|
||||
revision: 3,
|
||||
lastModified: 30,
|
||||
deviceId: "remote-device",
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: -1,
|
||||
extractionCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
remoteFiles.set("ST-BME_BackupManifest.json", [
|
||||
{
|
||||
filename: driftFilename,
|
||||
serverPath: `user/files/${driftFilename}`,
|
||||
chatId: "chat-delete-drift",
|
||||
revision: 3,
|
||||
lastModified: 30,
|
||||
backupTime: 321,
|
||||
size: 128,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
const deleteResult = await deleteServerBackup("chat-delete-drift", {
|
||||
...runtime,
|
||||
filename: driftFilename,
|
||||
serverPath: `user/files/${driftFilename}`,
|
||||
});
|
||||
|
||||
assert.equal(deleteResult.deleted, true);
|
||||
assert.equal(deleteResult.filename, driftFilename);
|
||||
assert.equal(deleteResult.localMetaUpdated, true);
|
||||
assert.equal(remoteFiles.has(driftFilename), false);
|
||||
assert.equal(db.meta.get("lastBackupUploadedAt"), 0);
|
||||
assert.equal(db.meta.get("lastBackupFilename"), "");
|
||||
|
||||
const manifestResult = await listServerBackups(runtime);
|
||||
assert.equal(manifestResult.entries.length, 0);
|
||||
}
|
||||
|
||||
async function testSyncNowLockAndAutoSync() {
|
||||
const { fetch, remoteFiles, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
@@ -1079,6 +1206,8 @@ async function main() {
|
||||
await testManualBackupAndRestoreFlow();
|
||||
await testBackupManifestReadFailureDoesNotOverwriteManifest();
|
||||
await testRestoreValidationDoesNotCreateSafetySnapshot();
|
||||
await testRestoreUsesManifestFilenameWhenCurrentFilenameDrifts();
|
||||
await testDeleteUsesExplicitManifestFilenameAndClearsLocalBackupMeta();
|
||||
await testSyncNowLockAndAutoSync();
|
||||
await testDeleteRemoteSyncFile();
|
||||
await testDeleteRemoteSyncFileFallsBackToLegacyFilename();
|
||||
|
||||
@@ -9889,12 +9889,13 @@ function _buildCloudBackupManagerHtml(state = {}) {
|
||||
</div>
|
||||
<div class="bme-cloud-backup-card__filename">${_escHtml(filename)}</div>
|
||||
<div class="bme-cloud-backup-card__actions">
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
class="bme-cloud-backup-modal__btn bme-cloud-backup-card__danger"
|
||||
data-bme-backup-action="delete"
|
||||
data-chat-id="${_escHtml(chatId)}"
|
||||
data-filename="${_escHtml(filename)}"
|
||||
data-server-path="${_escHtml(String(entry?.serverPath || ""))}"
|
||||
${state.busy ? "disabled" : ""}
|
||||
>
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
@@ -9970,7 +9971,7 @@ async function _openServerBackupManagerModal() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEntry = async (chatId, filename) => {
|
||||
const deleteEntry = async (chatId, filename, serverPath = "") => {
|
||||
if (typeof _actionHandlers.deleteServerBackupEntry !== "function") {
|
||||
toastr.error("\u5f53\u524d\u8fd0\u884c\u65f6\u6ca1\u6709\u63a5\u5165\u5220\u9664\u670d\u52a1\u5668\u5907\u4efd\u5165\u53e3", "ST-BME");
|
||||
return;
|
||||
@@ -9986,6 +9987,7 @@ async function _openServerBackupManagerModal() {
|
||||
const result = await _actionHandlers.deleteServerBackupEntry({
|
||||
chatId,
|
||||
filename,
|
||||
serverPath,
|
||||
});
|
||||
if (!result?.deleted) {
|
||||
const message =
|
||||
@@ -10005,6 +10007,7 @@ async function _openServerBackupManagerModal() {
|
||||
} finally {
|
||||
state.busy = false;
|
||||
render();
|
||||
_refreshRuntimeStatus();
|
||||
void _refreshCloudBackupManualUi();
|
||||
}
|
||||
};
|
||||
@@ -10021,6 +10024,7 @@ async function _openServerBackupManagerModal() {
|
||||
await deleteEntry(
|
||||
String(button.dataset.chatId || "").trim(),
|
||||
String(button.dataset.filename || "").trim(),
|
||||
String(button.dataset.serverPath || "").trim(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user