mirror of
https://github.com/haperone/local-image-compress.git
synced 2026-07-22 06:44:26 +00:00
1061 lines
42 KiB
TypeScript
1061 lines
42 KiB
TypeScript
import * as obsidian from "obsidian";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { t } from "./i18n";
|
|
import { ConcurrencyLimiter } from "./concurrency-limiter";
|
|
import { getInternalWorkerPoolSize } from "./settings";
|
|
import { getErrorCode, getErrorMessage, getLogTag, getPluginName, getVaultBasePath, getVaultFileByPath, isAllowedByRoots, isInsideOutputFolder, normalizeVaultPath, normalizeVaultPathForComparison, randomHexSuffix, streamHashSha256 } from "./utils";
|
|
import type LocalImageCompressPlugin from "./plugin";
|
|
|
|
type CompressedFileRecord = {
|
|
compressedPath: string;
|
|
relativePath?: string;
|
|
name: string;
|
|
size: number;
|
|
originalPath?: string;
|
|
compressedSha256?: string;
|
|
originalSizeBeforeMove?: number;
|
|
originalMtimeMsBeforeMove?: number;
|
|
originalSha256BeforeMove?: string;
|
|
moveSkipReason?: string;
|
|
moveCandidates?: string[];
|
|
};
|
|
|
|
type OriginalFileLookup = {
|
|
byName: Map<string, obsidian.TFile[]>;
|
|
};
|
|
|
|
type BackupPreflightTask = {
|
|
compressedFile: CompressedFileRecord;
|
|
skipped: boolean;
|
|
originalPath?: string;
|
|
backupFilePath?: string;
|
|
compressedBackupPath?: string;
|
|
originalSize?: number;
|
|
originalMtimeMs?: number;
|
|
originalSha256?: string;
|
|
compressedSize?: number;
|
|
compressedMtimeMs?: number;
|
|
compressedSha256?: string;
|
|
};
|
|
|
|
type CompleteBackupPreflightTask = BackupPreflightTask & {
|
|
skipped: false;
|
|
originalPath: string;
|
|
backupFilePath: string;
|
|
compressedBackupPath: string;
|
|
originalSize: number;
|
|
originalMtimeMs: number;
|
|
originalSha256: string;
|
|
compressedSize: number;
|
|
compressedMtimeMs: number;
|
|
compressedSha256: string;
|
|
};
|
|
|
|
export class MoveService {
|
|
private readonly plugin: LocalImageCompressPlugin;
|
|
// True while a move operation runs; runCompressionBatch defers compression to it.
|
|
moveOperationInProgress = false;
|
|
|
|
constructor(plugin: LocalImageCompressPlugin) {
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
getVaultBasePath() {
|
|
return getVaultBasePath(this.plugin.app);
|
|
}
|
|
|
|
isAllowedOriginalPath(filePath: string) {
|
|
if (isInsideOutputFolder(filePath, this.plugin.getOutputFolder())) {
|
|
return false;
|
|
}
|
|
return isAllowedByRoots(filePath, this.plugin.settings.allowedRoots || []);
|
|
}
|
|
|
|
isCandidateOriginalFile(file: unknown): file is obsidian.TFile {
|
|
const candidate = file as Partial<obsidian.TFile> | null;
|
|
return !!candidate
|
|
&& typeof candidate.path === "string"
|
|
&& typeof candidate.name === "string"
|
|
&& this.plugin.isImageFile(candidate)
|
|
&& this.isAllowedOriginalPath(candidate.path);
|
|
}
|
|
|
|
getIOConcurrency() {
|
|
const activeWorkerCount = getInternalWorkerPoolSize(
|
|
this.plugin.getActiveWindow().navigator?.hardwareConcurrency
|
|
);
|
|
return Math.max(1, Math.min(activeWorkerCount * 2, 16));
|
|
}
|
|
|
|
getMoveText(key: string) {
|
|
return t(this.plugin.app, key);
|
|
}
|
|
|
|
isUnloading() {
|
|
return !!this.plugin.isUnloading;
|
|
}
|
|
|
|
skipForUnload(compressedFile: CompressedFileRecord) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
|
|
buildOriginalFileLookup() {
|
|
const byName = new Map<string, obsidian.TFile[]>();
|
|
for (const file of this.plugin.app.vault.getFiles()) {
|
|
if (!this.isCandidateOriginalFile(file)) {
|
|
continue;
|
|
}
|
|
if (!byName.has(file.name)) {
|
|
byName.set(file.name, []);
|
|
}
|
|
const entries = byName.get(file.name);
|
|
if (entries) {
|
|
entries.push(file);
|
|
}
|
|
}
|
|
return { byName };
|
|
}
|
|
|
|
getOriginalFileCandidatesByName(fileName: string) {
|
|
const allFiles = this.plugin.app.vault.getFiles();
|
|
return allFiles.filter((file): file is obsidian.TFile => {
|
|
if (file.name !== fileName) return false;
|
|
return this.isCandidateOriginalFile(file);
|
|
});
|
|
}
|
|
|
|
async pathExists(filePath: string) {
|
|
try {
|
|
await fs.promises.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async findOriginalFileForCompressed(compressedFile: CompressedFileRecord, originalLookup: OriginalFileLookup | null = null) {
|
|
const relativePath = compressedFile.relativePath ? normalizeVaultPath(compressedFile.relativePath) : "";
|
|
if (relativePath && this.isAllowedOriginalPath(relativePath)) {
|
|
const file = getVaultFileByPath(this.plugin.app.vault, relativePath);
|
|
if (file && this.plugin.isImageFile(file)) {
|
|
return path.join(this.getVaultBasePath(), file.path);
|
|
}
|
|
|
|
const absolutePath = path.join(this.getVaultBasePath(), relativePath);
|
|
if (await this.pathExists(absolutePath)) {
|
|
return absolutePath;
|
|
}
|
|
}
|
|
|
|
return await this.findOriginalFile(compressedFile.name, compressedFile, originalLookup);
|
|
}
|
|
|
|
async findOriginalFile(fileName: string, compressedFile: CompressedFileRecord | null = null, originalLookup: OriginalFileLookup | null = null) {
|
|
const candidates = originalLookup?.byName?.get(fileName) || this.getOriginalFileCandidatesByName(fileName);
|
|
|
|
if (candidates.length === 1) {
|
|
const [candidate] = candidates;
|
|
return candidate ? path.join(this.getVaultBasePath(), candidate.path) : null;
|
|
}
|
|
|
|
if (candidates.length === 0 && compressedFile) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.noOriginalCandidate");
|
|
}
|
|
|
|
if (candidates.length > 1 && compressedFile) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.ambiguousOriginal");
|
|
compressedFile.moveCandidates = candidates.map((file) => file.path);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async getCompressedFilesCount() {
|
|
try {
|
|
const compressedFolderPath = path.join(this.getVaultBasePath(), this.plugin.getOutputFolder());
|
|
|
|
if (!await this.pathExists(compressedFolderPath)) {
|
|
return 0;
|
|
}
|
|
|
|
const allCompressedFiles = await this.getCompressedFilesAsync(compressedFolderPath);
|
|
|
|
if (allCompressedFiles.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
let movableCount = 0;
|
|
const originalLookup = this.buildOriginalFileLookup();
|
|
|
|
for (const compressedFile of allCompressedFiles) {
|
|
const originalPath = await this.findOriginalFileForCompressed(compressedFile, originalLookup);
|
|
if (originalPath) {
|
|
movableCount++;
|
|
}
|
|
}
|
|
|
|
return movableCount;
|
|
} catch (error) {
|
|
console.error(getLogTag(this.plugin), "Compressed files count error:", error);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async moveCompressedToFiles() {
|
|
if (this.moveOperationInProgress) {
|
|
return;
|
|
}
|
|
this.moveOperationInProgress = true;
|
|
try {
|
|
await this.plugin.waitForCompressionIdle();
|
|
await this.plugin.withCompressionGuards(
|
|
async () => {
|
|
try {
|
|
const compressedFolderPath = path.join(this.getVaultBasePath(), this.plugin.getOutputFolder());
|
|
|
|
if (!await this.pathExists(compressedFolderPath)) {
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${t(this.plugin.app, "move.noCompressedFolder")}`);
|
|
return;
|
|
}
|
|
|
|
const allCompressedFiles = await this.getCompressedFilesAsync(compressedFolderPath);
|
|
|
|
if (allCompressedFiles.length === 0) {
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${t(this.plugin.app, "move.noneToMove")}`);
|
|
return;
|
|
}
|
|
|
|
const compressedFiles: CompressedFileRecord[] = [];
|
|
let skippedCount = 0;
|
|
const originalLookup = this.buildOriginalFileLookup();
|
|
for (const compressedFile of allCompressedFiles) {
|
|
const originalPath = await this.findOriginalFileForCompressed(compressedFile, originalLookup);
|
|
if (originalPath) {
|
|
compressedFile.originalPath = originalPath;
|
|
compressedFiles.push(compressedFile);
|
|
} else if (compressedFile.moveSkipReason) {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
|
|
if (compressedFiles.length === 0) {
|
|
const skippedReasonText = this.getMoveSkipReasonGroups(allCompressedFiles)
|
|
.map((group) => `${group.reason}: ${group.count}`)
|
|
.join("; ");
|
|
const skippedDetails = skippedReasonText ? `; ${skippedReasonText}` : "";
|
|
const skippedText = skippedCount > 0 ? ` (${t(this.plugin.app, "progress.skipped")}: ${skippedCount}${skippedDetails})` : "";
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${t(this.plugin.app, "move.noneWithOriginals")}${skippedText}`);
|
|
return;
|
|
}
|
|
|
|
const backupResult = await this.createBackupBeforeMove(compressedFiles);
|
|
skippedCount += backupResult.skippedCount;
|
|
let errorCount = backupResult.errorCount;
|
|
const filesToMove = backupResult.files;
|
|
|
|
if (filesToMove.length === 0) {
|
|
this.showMoveResult(0, errorCount, backupResult.backupCreated, skippedCount, allCompressedFiles);
|
|
await this.plugin.rebuildImageIndex("move-compressed");
|
|
await this.plugin.statusBarController.update();
|
|
return;
|
|
}
|
|
|
|
const progressModal = this.showMoveProgressModal(filesToMove.length);
|
|
|
|
let successCount = 0;
|
|
|
|
try {
|
|
for (let i = 0; i < filesToMove.length; i++) {
|
|
const compressedFile = filesToMove[i];
|
|
if (!compressedFile) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
progressModal.updateProgress(i + 1, filesToMove.length, compressedFile.name);
|
|
await this.moveSingleFile(compressedFile);
|
|
if (compressedFile.moveSkipReason) {
|
|
skippedCount++;
|
|
} else {
|
|
successCount++;
|
|
}
|
|
await new Promise((resolve) => this.plugin.setWindowTimeout(resolve, 50));
|
|
} catch (error) {
|
|
console.error(getLogTag(this.plugin), `Move error for ${compressedFile.name}:`, getErrorMessage(error));
|
|
errorCount++;
|
|
}
|
|
}
|
|
} finally {
|
|
// III2-A-3: guarantee the progress modal closes even if the loop body
|
|
// throws an unexpected error, so it can never stay open indefinitely.
|
|
progressModal.close();
|
|
}
|
|
this.showMoveResult(successCount, errorCount, backupResult.backupCreated, skippedCount, allCompressedFiles);
|
|
await this.plugin.rebuildImageIndex("move-compressed");
|
|
await this.plugin.statusBarController.update();
|
|
} catch (error) {
|
|
console.error(getLogTag(this.plugin), "Error while moving files:", error);
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${t(this.plugin.app, "progress.error")}`);
|
|
}
|
|
}
|
|
);
|
|
} finally {
|
|
this.moveOperationInProgress = false;
|
|
}
|
|
}
|
|
|
|
async getCompressedFilesAsync(compressedFolderPath: string): Promise<CompressedFileRecord[]> {
|
|
const files: CompressedFileRecord[] = [];
|
|
let visitedCount = 0;
|
|
const seenDirectories = new Set<string>();
|
|
const walk = async (dirPath: string, relativePath = "") => {
|
|
try {
|
|
const realPath = normalizeVaultPathForComparison(await fs.promises.realpath(dirPath));
|
|
if (seenDirectories.has(realPath)) {
|
|
return;
|
|
}
|
|
seenDirectories.add(realPath);
|
|
} catch (e) {
|
|
console.error(getLogTag(this.plugin), "Failed to resolve directory:", dirPath, e);
|
|
return;
|
|
}
|
|
let items;
|
|
try {
|
|
items = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
} catch (e) {
|
|
console.error(getLogTag(this.plugin), "Failed to read directory:", dirPath, e);
|
|
return;
|
|
}
|
|
for (const entry of items) {
|
|
const name = entry.name;
|
|
const fullPath = path.join(dirPath, name);
|
|
const relativeItemPath = path.join(relativePath, name);
|
|
if (entry.isSymbolicLink && entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
await walk(fullPath, relativeItemPath);
|
|
} else if (this.plugin.isImageFile({ extension: path.extname(name).slice(1).toLowerCase() })) {
|
|
try {
|
|
const stats = await fs.promises.stat(fullPath);
|
|
files.push({ compressedPath: fullPath, relativePath: relativeItemPath, name, size: stats.size });
|
|
} catch (e) {
|
|
console.error(getLogTag(this.plugin), "Failed to stat path:", fullPath, e);
|
|
}
|
|
}
|
|
visitedCount++;
|
|
if (visitedCount % 200 === 0) {
|
|
await new Promise((r) => this.plugin.setWindowTimeout(r, 0));
|
|
}
|
|
}
|
|
};
|
|
await walk(compressedFolderPath);
|
|
return files;
|
|
}
|
|
|
|
isCompleteBackupTask(task: BackupPreflightTask): task is CompleteBackupPreflightTask {
|
|
return task.skipped === false &&
|
|
typeof task.originalPath === "string" &&
|
|
task.originalPath.length > 0 &&
|
|
typeof task.backupFilePath === "string" &&
|
|
task.backupFilePath.length > 0 &&
|
|
typeof task.compressedBackupPath === "string" &&
|
|
task.compressedBackupPath.length > 0 &&
|
|
typeof task.originalSize === "number" &&
|
|
Number.isFinite(task.originalSize) &&
|
|
typeof task.originalMtimeMs === "number" &&
|
|
Number.isFinite(task.originalMtimeMs) &&
|
|
typeof task.originalSha256 === "string" &&
|
|
task.originalSha256.length > 0 &&
|
|
typeof task.compressedSize === "number" &&
|
|
Number.isFinite(task.compressedSize) &&
|
|
typeof task.compressedMtimeMs === "number" &&
|
|
Number.isFinite(task.compressedMtimeMs) &&
|
|
typeof task.compressedSha256 === "string" &&
|
|
task.compressedSha256.length > 0;
|
|
}
|
|
|
|
async createBackupBeforeMove(compressedFiles: CompressedFileRecord[]) {
|
|
const result = {
|
|
backupCreated: false,
|
|
files: [] as CompressedFileRecord[],
|
|
skippedCount: 0,
|
|
errorCount: 0
|
|
};
|
|
try {
|
|
if (this.isUnloading()) {
|
|
for (const compressedFile of compressedFiles) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
}
|
|
result.skippedCount = compressedFiles.length;
|
|
return result;
|
|
}
|
|
const backupDir = this.plugin.getBackupStoragePaths().originalFilesBackups;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const randomSuffix = await randomHexSuffix(16);
|
|
const backupPath = path.join(backupDir, `backup-${timestamp}-${randomSuffix}`);
|
|
await fs.promises.mkdir(backupPath, { recursive: true });
|
|
|
|
const prepassLimiter = new ConcurrencyLimiter(this.getIOConcurrency());
|
|
const tasks = await Promise.all(compressedFiles.map((compressedFile) => prepassLimiter.run(async (): Promise<BackupPreflightTask> => {
|
|
if (this.isUnloading()) {
|
|
return this.skipForUnload(compressedFile);
|
|
}
|
|
const originalPath = compressedFile.originalPath || await this.findOriginalFileForCompressed(compressedFile);
|
|
if (this.isUnloading()) {
|
|
return this.skipForUnload(compressedFile);
|
|
}
|
|
if (!originalPath) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalMissingBeforeBackup");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
if (await this.pathsReferToSameFile(compressedFile.compressedPath, originalPath)) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.selfMove");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
const [originalStats, compressedStats] = await Promise.all([
|
|
fs.promises.stat(originalPath).catch(() => null),
|
|
fs.promises.stat(compressedFile.compressedPath).catch(() => null)
|
|
]);
|
|
if (this.isUnloading()) {
|
|
return this.skipForUnload(compressedFile);
|
|
}
|
|
if (!originalStats) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalMissingBeforeBackup");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
if (!compressedStats) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.compressedMissingBeforeBackup");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
const relativeToVault = path.relative(this.getVaultBasePath(), originalPath);
|
|
const compressedRelativePath = path.relative(this.getVaultBasePath(), compressedFile.compressedPath);
|
|
// Defensive: originals/compressed are expected inside the vault (allowed roots). Refuse to
|
|
// back up a path that escapes the vault root, so an edge/crafted path cannot traverse out of
|
|
// the timestamped backup directory.
|
|
const pathEscapesVault = (relativePath: string) =>
|
|
path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((segment) => segment === "..");
|
|
if (pathEscapesVault(relativeToVault) || pathEscapesVault(compressedRelativePath)) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalMissingBeforeBackup");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
const pendingMove = await this.plugin.cache.resolvePendingMoveEntry({
|
|
path: normalizeVaultPath(relativeToVault),
|
|
stat: { mtime: originalStats.mtimeMs, size: originalStats.size }
|
|
}, normalizeVaultPath(compressedRelativePath));
|
|
if (pendingMove.status === "conflict") {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
if (pendingMove.status === "match" && pendingMove.entry) {
|
|
const outputMatchesStats = this.plugin.cache.normalizeMtime(pendingMove.entry.outputMtime) === this.plugin.cache.normalizeMtime(compressedStats.mtimeMs)
|
|
&& Number(pendingMove.entry.outputSize) === compressedStats.size;
|
|
if (!outputMatchesStats) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
return { compressedFile, skipped: true };
|
|
}
|
|
}
|
|
const [originalSha256, compressedSha256] = await Promise.all([
|
|
streamHashSha256(originalPath),
|
|
streamHashSha256(compressedFile.compressedPath)
|
|
]);
|
|
if (this.isUnloading()) {
|
|
return this.skipForUnload(compressedFile);
|
|
}
|
|
return {
|
|
compressedFile,
|
|
originalPath,
|
|
skipped: false,
|
|
backupFilePath: path.join(backupPath, "originals", relativeToVault),
|
|
compressedBackupPath: path.join(backupPath, "compressed", compressedRelativePath),
|
|
originalSize: originalStats.size,
|
|
originalMtimeMs: originalStats.mtimeMs,
|
|
originalSha256,
|
|
compressedSize: compressedStats.size,
|
|
compressedMtimeMs: compressedStats.mtimeMs,
|
|
compressedSha256
|
|
};
|
|
})));
|
|
|
|
const backupDirs = new Set<string>();
|
|
for (const task of tasks) {
|
|
if (this.isCompleteBackupTask(task)) {
|
|
backupDirs.add(path.dirname(task.backupFilePath));
|
|
backupDirs.add(path.dirname(task.compressedBackupPath));
|
|
}
|
|
}
|
|
await Promise.all(Array.from(backupDirs).map((dir) => fs.promises.mkdir(dir, { recursive: true })));
|
|
|
|
const limiter = new ConcurrencyLimiter(this.getIOConcurrency());
|
|
await Promise.all(tasks.map((task) => limiter.run(async () => {
|
|
const compressedFile = task.compressedFile;
|
|
if (task.skipped) {
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
if (!this.isCompleteBackupTask(task)) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.invalidBackupTask");
|
|
result.errorCount++;
|
|
console.error(getLogTag(this.plugin), "Invalid backup task; missing required paths or metadata:", {
|
|
name: compressedFile.name,
|
|
hasOriginalPath: Boolean(task.originalPath),
|
|
hasBackupFilePath: Boolean(task.backupFilePath),
|
|
hasCompressedBackupPath: Boolean(task.compressedBackupPath)
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const [currentOriginalStats, currentCompressedStats] = await Promise.all([
|
|
fs.promises.stat(task.originalPath),
|
|
fs.promises.stat(compressedFile.compressedPath)
|
|
]);
|
|
if (currentOriginalStats.size !== task.originalSize || currentOriginalStats.mtimeMs !== task.originalMtimeMs) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalModifiedDuringBackup");
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
if (currentCompressedStats.size !== task.compressedSize || currentCompressedStats.mtimeMs !== task.compressedMtimeMs) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.compressedModifiedDuringBackup");
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
const [currentOriginalSha256, currentCompressedSha256] = await Promise.all([
|
|
streamHashSha256(task.originalPath),
|
|
streamHashSha256(compressedFile.compressedPath)
|
|
]);
|
|
if (currentOriginalSha256 !== task.originalSha256) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalContentChangedDuringBackup");
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
if (currentCompressedSha256 !== task.compressedSha256) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.compressedContentChangedDuringBackup");
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
await Promise.all([
|
|
fs.promises.copyFile(task.originalPath, task.backupFilePath),
|
|
fs.promises.copyFile(compressedFile.compressedPath, task.compressedBackupPath)
|
|
]);
|
|
const [backupOriginalSha256, backupCompressedSha256] = await Promise.all([
|
|
streamHashSha256(task.backupFilePath),
|
|
streamHashSha256(task.compressedBackupPath)
|
|
]);
|
|
if (backupOriginalSha256 !== task.originalSha256 || backupCompressedSha256 !== task.compressedSha256) {
|
|
await this.cleanupBackupTaskFiles(task);
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.contentChangedDuringCopy");
|
|
result.skippedCount++;
|
|
return;
|
|
}
|
|
compressedFile.originalPath = task.originalPath;
|
|
// BR-H1: carry the backup-verified content hash so the destructive overwrite
|
|
// (moveSingleFile) can re-verify content, not just byte length.
|
|
compressedFile.compressedSha256 = task.compressedSha256;
|
|
compressedFile.originalSizeBeforeMove = task.originalSize;
|
|
compressedFile.originalMtimeMsBeforeMove = task.originalMtimeMs;
|
|
compressedFile.originalSha256BeforeMove = task.originalSha256;
|
|
result.files.push(compressedFile);
|
|
} catch (error) {
|
|
result.errorCount++;
|
|
// PPP2-A-2: a copy failure (ENOSPC/EMFILE/EIO) can leave one of the two
|
|
// If one backup write failed after the other succeeded, remove partial backup output.
|
|
// so a disk-full/handle-exhaustion error cannot orphan files on disk.
|
|
await this.cleanupBackupTaskFiles(task);
|
|
console.error(getLogTag(this.plugin), `Backup creation error for ${compressedFile.name}:`, error);
|
|
}
|
|
})));
|
|
|
|
if (result.files.length > 0) {
|
|
result.backupCreated = true;
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${t(this.plugin.app, "move.backup.createdCount", { count: result.files.length })}`);
|
|
if (this.plugin.settings.autoBackupsRetentionEnabled) {
|
|
await this.applyBackupsRetention(backupDir);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
result.errorCount += compressedFiles.length;
|
|
console.error(getLogTag(this.plugin), "Backup creation error:", error);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
async applyBackupsRetention(backupDir: string) {
|
|
try {
|
|
const days = this.plugin.settings.autoBackupsRetentionDays;
|
|
if (!Number.isInteger(days) || days < 1 || days > 365) {
|
|
console.warn(getLogTag(this.plugin), "Invalid backups retention days; skipping cleanup:", days);
|
|
return;
|
|
}
|
|
const exists = await fs.promises.access(backupDir).then(() => true).catch(() => false);
|
|
if (!exists) return;
|
|
|
|
const now = Date.now();
|
|
const ttlMs = days * 24 * 60 * 60 * 1000;
|
|
|
|
const entries: Array<{ name: string; path: string; mtime: number }> = [];
|
|
for (const entry of await fs.promises.readdir(backupDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(backupDir, entry.name);
|
|
const stats = await fs.promises.stat(entryPath);
|
|
if (now - stats.mtimeMs > ttlMs) {
|
|
entries.push({ name: entry.name, path: entryPath, mtime: stats.mtimeMs });
|
|
}
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
try {
|
|
await this.deleteDirectoryRecursiveAsync(entry.path);
|
|
} catch (err) {
|
|
console.error(getLogTag(this.plugin), "Expired backup removal error:", entry.path, err);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(getLogTag(this.plugin), "Backups retention apply error:", err);
|
|
}
|
|
}
|
|
|
|
async moveSingleFile(compressedFile: CompressedFileRecord) {
|
|
let tempOriginalPath: string | null = null;
|
|
try {
|
|
if (this.isUnloading()) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
return;
|
|
}
|
|
const originalPath = compressedFile.originalPath || await this.findOriginalFileForCompressed(compressedFile);
|
|
|
|
if (!originalPath) {
|
|
throw new Error(`Original file not found: ${compressedFile.name}`);
|
|
}
|
|
if (this.isUnloading()) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
return;
|
|
}
|
|
if (await this.pathsReferToSameFile(compressedFile.compressedPath, originalPath)) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.selfMove");
|
|
return;
|
|
}
|
|
|
|
let originalStats;
|
|
try {
|
|
originalStats = await fs.promises.stat(originalPath);
|
|
} catch (statError) {
|
|
if (getErrorCode(statError) === "ENOENT") {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalNotFoundAtMoveTime");
|
|
return;
|
|
}
|
|
throw statError;
|
|
}
|
|
if (this.isUnloading()) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
return;
|
|
}
|
|
if (compressedFile.originalSizeBeforeMove !== undefined
|
|
&& compressedFile.originalMtimeMsBeforeMove !== undefined
|
|
&& (originalStats.size !== compressedFile.originalSizeBeforeMove || originalStats.mtimeMs !== compressedFile.originalMtimeMsBeforeMove)) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
return;
|
|
}
|
|
if (compressedFile.originalSha256BeforeMove) {
|
|
const currentOriginalSha256 = await streamHashSha256(originalPath);
|
|
if (currentOriginalSha256 !== compressedFile.originalSha256BeforeMove) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
return;
|
|
}
|
|
}
|
|
const vaultBasePath = this.getVaultBasePath();
|
|
const originalRelativePath = normalizeVaultPath(path.relative(vaultBasePath, originalPath));
|
|
const compressedRelativePath = normalizeVaultPath(path.relative(vaultBasePath, compressedFile.compressedPath));
|
|
|
|
if (compressedFile.size >= originalStats.size) {
|
|
if (compressedFile.size === originalStats.size && await this.filesHaveSameContent(compressedFile.compressedPath, originalPath)) {
|
|
await this.plugin.cache.markProcessedFileSkippedIdentical(originalRelativePath, originalStats, originalStats.size, compressedRelativePath);
|
|
try {
|
|
await fs.promises.unlink(compressedFile.compressedPath);
|
|
} catch (cleanupError) {
|
|
console.error(getLogTag(this.plugin), `Compressed output cleanup failed for ${compressedFile.name}:`, getErrorMessage(cleanupError));
|
|
}
|
|
return;
|
|
}
|
|
throw new Error(`Compressed file is larger or equal to original: ${compressedFile.name}`);
|
|
}
|
|
|
|
const randomSuffix = await randomHexSuffix(16);
|
|
tempOriginalPath = path.join(
|
|
path.dirname(originalPath),
|
|
`.${path.basename(originalPath)}.tinylocal-${Date.now()}-${randomSuffix}.tmp`
|
|
);
|
|
await fs.promises.copyFile(compressedFile.compressedPath, tempOriginalPath);
|
|
const tempStats = await fs.promises.stat(tempOriginalPath);
|
|
if (tempStats.size !== compressedFile.size) {
|
|
throw new Error(`Staged compressed file size mismatch: ${compressedFile.name}`);
|
|
}
|
|
if (tempStats.size >= originalStats.size) {
|
|
throw new Error(`Staged compressed file is larger or equal to original: ${compressedFile.name}`);
|
|
}
|
|
// BR-H1: the destructive overwrite must verify CONTENT, not just byte length. Re-hash the
|
|
// staged temp bytes (the exact bytes that get renamed over the original) and compare to the
|
|
// backup-verified compressedSha256, so a same-size-but-different-content compressed file
|
|
// (corruption / sync substitution between backup and move) cannot silently replace the original.
|
|
if (compressedFile.compressedSha256) {
|
|
const stagedSha256 = await streamHashSha256(tempOriginalPath);
|
|
if (stagedSha256 !== compressedFile.compressedSha256) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
const message = t(this.plugin.app, "move.warning.externalModification", { name: compressedFile.name });
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${message}`, 10000);
|
|
console.warn(getLogTag(this.plugin), `Compressed content changed before move (hash mismatch): ${compressedFile.name}`);
|
|
try {
|
|
await fs.promises.unlink(tempOriginalPath);
|
|
tempOriginalPath = null;
|
|
} catch (cleanupError) {
|
|
console.warn(getLogTag(this.plugin), `Temporary move cleanup failed for ${compressedFile.name}:`, cleanupError);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (this.isUnloading()) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
|
|
try {
|
|
await fs.promises.unlink(tempOriginalPath);
|
|
tempOriginalPath = null;
|
|
} catch (cleanupError) {
|
|
console.warn(getLogTag(this.plugin), `Temporary move cleanup failed for ${compressedFile.name}:`, cleanupError);
|
|
}
|
|
return;
|
|
}
|
|
await fs.promises.rename(tempOriginalPath, originalPath);
|
|
tempOriginalPath = null;
|
|
|
|
const processedStats = await fs.promises.stat(originalPath);
|
|
if (processedStats.size !== compressedFile.size) {
|
|
compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
|
|
const message = t(this.plugin.app, "move.warning.externalModification", { name: compressedFile.name });
|
|
new obsidian.Notice(`${getPluginName(this.plugin)}: ${message}`, 10000);
|
|
console.warn(getLogTag(this.plugin), `External modification detected during move: ${compressedFile.name}`);
|
|
return;
|
|
}
|
|
await this.plugin.cache.markProcessedFileMoved(originalRelativePath, processedStats, originalStats.size, compressedRelativePath);
|
|
try {
|
|
await fs.promises.unlink(compressedFile.compressedPath);
|
|
} catch (cleanupError) {
|
|
console.error(getLogTag(this.plugin), `Compressed output cleanup failed for ${compressedFile.name}:`, getErrorMessage(cleanupError));
|
|
}
|
|
} catch (error) {
|
|
if (tempOriginalPath) {
|
|
try {
|
|
await fs.promises.unlink(tempOriginalPath);
|
|
} catch (cleanupError) {
|
|
console.warn(getLogTag(this.plugin), `Temporary move cleanup failed for ${compressedFile.name}:`, cleanupError);
|
|
}
|
|
}
|
|
console.error(getLogTag(this.plugin), `moveSingleFile error for ${compressedFile.name}:`, getErrorMessage(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async pathsReferToSameFile(leftPath: string, rightPath: string) {
|
|
const [leftResolved, rightResolved] = await Promise.all([
|
|
this.resolvePathForSelfMoveComparison(leftPath),
|
|
this.resolvePathForSelfMoveComparison(rightPath)
|
|
]);
|
|
return leftResolved === rightResolved;
|
|
}
|
|
|
|
private async resolvePathForSelfMoveComparison(filePath: string) {
|
|
try {
|
|
return normalizeVaultPathForComparison(await fs.promises.realpath(filePath));
|
|
} catch {
|
|
return normalizeVaultPathForComparison(path.resolve(filePath));
|
|
}
|
|
}
|
|
|
|
async filesHaveSameContent(leftPath: string, rightPath: string) {
|
|
const chunkSize = 64 * 1024;
|
|
let leftHandle: fs.promises.FileHandle | null = null;
|
|
let rightHandle: fs.promises.FileHandle | null = null;
|
|
try {
|
|
[leftHandle, rightHandle] = await Promise.all([
|
|
fs.promises.open(leftPath, "r"),
|
|
fs.promises.open(rightPath, "r")
|
|
]);
|
|
const leftBuffer = Buffer.alloc(chunkSize);
|
|
const rightBuffer = Buffer.alloc(chunkSize);
|
|
let position = 0;
|
|
while (true) {
|
|
const [leftRead, rightRead] = await Promise.all([
|
|
leftHandle.read(leftBuffer, 0, chunkSize, position),
|
|
rightHandle.read(rightBuffer, 0, chunkSize, position)
|
|
]);
|
|
if (leftRead.bytesRead !== rightRead.bytesRead) {
|
|
return false;
|
|
}
|
|
if (leftRead.bytesRead === 0) {
|
|
return true;
|
|
}
|
|
if (Buffer.compare(leftBuffer.subarray(0, leftRead.bytesRead), rightBuffer.subarray(0, rightRead.bytesRead)) !== 0) {
|
|
return false;
|
|
}
|
|
position += leftRead.bytesRead;
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Content comparison failed for ${path.basename(leftPath)} and ${path.basename(rightPath)}: ${getErrorMessage(error)}`);
|
|
} finally {
|
|
const closeWithLog = async (handle: fs.promises.FileHandle | null, label: string) => {
|
|
try {
|
|
await handle?.close();
|
|
} catch (error) {
|
|
console.warn(getLogTag(this.plugin), `Failed to close ${label} comparison handle:`, error);
|
|
}
|
|
};
|
|
await Promise.all([
|
|
closeWithLog(leftHandle, "left"),
|
|
closeWithLog(rightHandle, "right")
|
|
]);
|
|
}
|
|
}
|
|
|
|
async cleanupBackupTaskFiles(task: BackupPreflightTask) {
|
|
const cleanupTargets = [task.backupFilePath, task.compressedBackupPath].filter((filePath): filePath is string => Boolean(filePath));
|
|
await Promise.all(cleanupTargets.map(async (filePath) => {
|
|
try {
|
|
await fs.promises.unlink(filePath);
|
|
} catch (error) {
|
|
if (getErrorCode(error) !== "ENOENT") {
|
|
console.warn(getLogTag(this.plugin), "Backup verification cleanup failed:", filePath, error);
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
showMoveProgressModal(totalFiles: number) {
|
|
const pluginName = getPluginName(this.plugin);
|
|
const owner = this.plugin;
|
|
const modal = new class extends obsidian.Modal {
|
|
totalFiles: number;
|
|
currentFile: number;
|
|
progressText!: HTMLElement;
|
|
currentFileText!: HTMLElement;
|
|
progressBar!: HTMLElement;
|
|
progressFill!: HTMLElement;
|
|
returnFocusTo: HTMLElement | null;
|
|
|
|
constructor(app: obsidian.App, totalFiles: number) {
|
|
super(app);
|
|
this.totalFiles = totalFiles;
|
|
this.currentFile = 0;
|
|
this.returnFocusTo = owner.captureModalFocusTarget();
|
|
this.titleEl.setText(`${pluginName}: ${t(this.app, "move.title")}`);
|
|
}
|
|
|
|
override onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.addClass("tiny-local-move-progress-modal");
|
|
|
|
this.progressText = contentEl.createEl("p", {
|
|
text: `${t(this.app, "progress.processing")}: 0 / ${this.totalFiles}`
|
|
});
|
|
this.progressText.setAttribute("role", "status");
|
|
this.progressText.setAttribute("aria-live", "polite");
|
|
this.progressText.setAttribute("aria-atomic", "true");
|
|
|
|
this.currentFileText = contentEl.createEl("p", {
|
|
text: t(this.app, "common.refresh")
|
|
});
|
|
|
|
this.progressBar = contentEl.createDiv({
|
|
cls: "tiny-local-progress-bar"
|
|
});
|
|
this.progressBar.setAttribute("role", "progressbar");
|
|
this.progressBar.setAttribute("aria-label", t(this.app, "move.title"));
|
|
this.progressBar.setAttribute("aria-valuemin", "0");
|
|
this.progressBar.setAttribute("aria-valuemax", String(this.totalFiles));
|
|
this.progressBar.setAttribute("aria-valuenow", "0");
|
|
this.progressBar.setAttribute("aria-valuetext", `${t(this.app, "progress.processing")}: 0 / ${this.totalFiles}`);
|
|
|
|
this.progressFill = contentEl.createDiv({
|
|
cls: "tiny-local-progress-fill"
|
|
});
|
|
|
|
this.progressBar.appendChild(this.progressFill);
|
|
owner.scheduleElementFocus(this.modalEl.querySelector<HTMLElement>(".modal-close-button"));
|
|
}
|
|
|
|
updateProgress(current: number, total: number, fileName: string) {
|
|
this.currentFile = current;
|
|
const percentage = (current / total) * 100;
|
|
|
|
this.progressText.setText(`${t(this.app, "progress.processing")}: ${current} / ${total}`);
|
|
this.currentFileText.setText(`${t(this.app, "progress.processing")}: ${fileName}`);
|
|
this.progressBar.setAttribute("aria-valuemax", String(Math.max(0, total)));
|
|
this.progressBar.setAttribute("aria-valuenow", String(Math.max(0, Math.min(current, total))));
|
|
this.progressBar.setAttribute("aria-valuetext", `${t(this.app, "progress.processing")}: ${current} / ${total}. ${fileName}`);
|
|
// dynamic: required at runtime
|
|
this.progressFill.setCssProps({
|
|
"--local-image-compress-progress-width": `${percentage}%`
|
|
});
|
|
}
|
|
|
|
override onClose() {
|
|
const { contentEl } = this;
|
|
owner.untrackManagedModal(this);
|
|
contentEl.empty();
|
|
owner.restoreModalFocus(this.returnFocusTo);
|
|
}
|
|
}(this.plugin.app, totalFiles);
|
|
|
|
this.plugin.trackManagedModal(modal);
|
|
modal.open();
|
|
return modal;
|
|
}
|
|
|
|
getMoveSkipReasonGroups(compressedFiles: CompressedFileRecord[] = []) {
|
|
const groups = new Map<string, number>();
|
|
for (const compressedFile of compressedFiles) {
|
|
const reason = compressedFile.moveSkipReason?.trim();
|
|
if (!reason) {
|
|
continue;
|
|
}
|
|
groups.set(reason, (groups.get(reason) || 0) + 1);
|
|
}
|
|
return Array.from(groups.entries()).map(([reason, count]) => ({ reason, count }));
|
|
}
|
|
|
|
showMoveResult(successCount: number, errorCount: number, backupCreated: boolean, skippedCount = 0, compressedFiles: CompressedFileRecord[] = []) {
|
|
const pluginName = getPluginName(this.plugin);
|
|
const owner = this.plugin;
|
|
const skipReasonGroups = this.getMoveSkipReasonGroups(compressedFiles);
|
|
const groupedSkippedCount = skipReasonGroups.reduce((total, group) => total + group.count, 0);
|
|
const displaySkippedCount = groupedSkippedCount > 0 ? groupedSkippedCount : skippedCount;
|
|
const modal = new class extends obsidian.Modal {
|
|
successCount: number;
|
|
errorCount: number;
|
|
backupCreated: boolean;
|
|
skippedCount: number;
|
|
skipReasonGroups: Array<{ reason: string; count: number }>;
|
|
listenerCleanups: Array<() => void>;
|
|
returnFocusTo: HTMLElement | null;
|
|
|
|
constructor(
|
|
app: obsidian.App,
|
|
successCount: number,
|
|
errorCount: number,
|
|
backupCreated: boolean,
|
|
skippedCount: number,
|
|
skipReasonGroups: Array<{ reason: string; count: number }>
|
|
) {
|
|
super(app);
|
|
this.successCount = successCount;
|
|
this.errorCount = errorCount;
|
|
this.backupCreated = backupCreated;
|
|
this.skippedCount = skippedCount;
|
|
this.skipReasonGroups = skipReasonGroups;
|
|
this.listenerCleanups = [];
|
|
this.returnFocusTo = owner.captureModalFocusTarget();
|
|
this.titleEl.setText(`${pluginName}: ${t(this.app, "move.title")}`);
|
|
}
|
|
|
|
override onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.addClass("tiny-local-move-result-modal");
|
|
|
|
if (this.successCount > 0) {
|
|
contentEl.createEl("p", {
|
|
text: `\u2705 ${t(this.app, "move.button")}: ${this.successCount}`,
|
|
cls: "tiny-local-success"
|
|
});
|
|
}
|
|
|
|
if (this.errorCount > 0) {
|
|
contentEl.createEl("p", {
|
|
text: `\u274C ${t(this.app, "progress.error")}: ${this.errorCount}`,
|
|
cls: "tiny-local-error"
|
|
});
|
|
}
|
|
|
|
if (this.skippedCount > 0) {
|
|
contentEl.createEl("p", {
|
|
text: `${t(this.app, "progress.skipped")}: ${this.skippedCount}`,
|
|
cls: "tiny-local-info"
|
|
});
|
|
if (this.skipReasonGroups.length > 0) {
|
|
const reasonList = contentEl.createEl("ul", {
|
|
cls: "tiny-local-move-skip-reasons"
|
|
});
|
|
for (const group of this.skipReasonGroups) {
|
|
reasonList.createEl("li", {
|
|
text: `${group.reason}: ${group.count}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.backupCreated) {
|
|
contentEl.createEl("p", {
|
|
text: `\u{1F4BE} ${t(this.app, "move.backupCreated")}`,
|
|
cls: "tiny-local-info"
|
|
});
|
|
}
|
|
|
|
const closeButton = contentEl.createEl("button", {
|
|
text: t(this.app, "common.close"),
|
|
cls: "mod-cta"
|
|
});
|
|
closeButton.type = "button";
|
|
closeButton.setAttribute("aria-label", t(this.app, "common.close"));
|
|
|
|
const onCloseClick = () => {
|
|
this.close();
|
|
};
|
|
// modal-scoped: cleaned in onClose() — registerDomEvent unavailable on Modal
|
|
closeButton.addEventListener("click", onCloseClick);
|
|
this.listenerCleanups.push(() => closeButton.removeEventListener("click", onCloseClick));
|
|
owner.scheduleElementFocus(closeButton);
|
|
}
|
|
|
|
override onClose() {
|
|
const { contentEl } = this;
|
|
for (const cleanup of this.listenerCleanups) {
|
|
cleanup();
|
|
}
|
|
this.listenerCleanups = [];
|
|
owner.untrackManagedModal(this);
|
|
contentEl.empty();
|
|
owner.restoreModalFocus(this.returnFocusTo);
|
|
}
|
|
}(this.plugin.app, successCount, errorCount, backupCreated, displaySkippedCount, skipReasonGroups);
|
|
|
|
this.plugin.trackManagedModal(modal);
|
|
modal.open();
|
|
}
|
|
|
|
async deleteDirectoryRecursiveAsync(dirPath: string) {
|
|
try {
|
|
const entries = await fs.promises.readdir(dirPath);
|
|
for (const entry of entries) {
|
|
const curPath = path.join(dirPath, entry);
|
|
const stats = await fs.promises.lstat(curPath);
|
|
if (stats.isDirectory()) {
|
|
await this.deleteDirectoryRecursiveAsync(curPath);
|
|
} else {
|
|
try {
|
|
await fs.promises.unlink(curPath);
|
|
} catch (e) {
|
|
console.warn("Failed to delete file:", curPath, e);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
await fs.promises.rmdir(dirPath);
|
|
} catch (e) {
|
|
console.warn("Failed to remove directory:", dirPath, e);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to remove directory tree:", dirPath, e);
|
|
}
|
|
}
|
|
}
|