diff --git a/lang/en_us.yml b/lang/en_us.yml index 53c1991..db5ee42 100644 --- a/lang/en_us.yml +++ b/lang/en_us.yml @@ -42,6 +42,17 @@ prime_backup: deleted: Deleted {} deleted.skipped: Skipped {} (missed) done: Done, {} backups deleted, freed {} + backup_diff: + name: diff backup + no_diff: No difference between {} and {} + found_diff: 'Found {0} differences from {1} to {2}: {3}' + diff: + mode: mode changed + blob: blob changed + link_target: symlink target changed + owner: uid/gid changed + mtime: mtime changed + other: other backup_export: name: export backup already_exists: File {} already exists @@ -222,6 +233,7 @@ prime_backup: §7{prefix} export §6 §7[...]§r: Export the given backup. See §7{prefix} help export§r for detailed help §7{prefix} import §3 §7[...]§r: Import backup from an external file. See §7{prefix} help import§r for detailed help §7{prefix} prune §6§r: Manually trigger a backup prune + §7{prefix} diff §6 §6§r: Show file differences between two backups §7{prefix} crontab §a §7[...]§r: Crontab job operations. See §7{prefix} help crontab§r for help §7{prefix} tag §6 §7[...]§r: Tag operation on the given backup §7{prefix} database [...]§r: Database operations. See §7{prefix} help database§r for help diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml index d81d1a8..527ebd1 100644 --- a/lang/zh_cn.yml +++ b/lang/zh_cn.yml @@ -42,6 +42,17 @@ prime_backup: deleted: 已删除{} deleted.skipped: 已跳过{} (备份不存在) done: 任务完成, 共删除{}个备份, 共释放{} + backup_diff: + name: 对比备份 + no_diff: 备份{}与{}间没有任何差异 + found_diff: '备份{1}与{2}间存在{0}个差异: {3}' + diff: + mode: 模式变动 + blob: 数据变动 + link_target: 符号链接目标变动 + owner: uid/gid变动 + mtime: 修改日期变动 + other: 其他 backup_import: name: 导入备份 file_not_found: 文件{}不存在 @@ -222,6 +233,7 @@ prime_backup: §7{prefix} export §6<备份ID> §7[...]§r: 导出给定备份到文件。详见§7{prefix} help export§r §7{prefix} import §3<文件路径> §7[...]§r: 导入外部的备份文件。详见§7{prefix} help import§r §7{prefix} prune §6<备份ID>§r: 手动触发一次备份清理 + §7{prefix} diff §6<旧备份ID> §6<新备份ID>§r: 展示两个备份之间的文件差异 §7{prefix} crontab §a<作业ID> §7[...]§r: 操作定时作业。详见§7{prefix} help crontab§r §7{prefix} tag §6<备份ID> §7[...]§r: 操作给定备份的标签。详见§7{prefix} help tag§r §7{prefix} database [...]§r: 操作数据库。详见§7{prefix} help database§r diff --git a/prime_backup/action/diff_backup_action.py b/prime_backup/action/diff_backup_action.py new file mode 100644 index 0000000..a5f66ae --- /dev/null +++ b/prime_backup/action/diff_backup_action.py @@ -0,0 +1,66 @@ +import dataclasses +from typing import List, Dict, Tuple + +from prime_backup.action import Action +from prime_backup.db.access import DbAccess +from prime_backup.db.session import DbSession +from prime_backup.types.file_info import FileInfo + + +@dataclasses.dataclass(frozen=True) +class DiffResult: + added: List[FileInfo] = dataclasses.field(default_factory=list) + deleted: List[FileInfo] = dataclasses.field(default_factory=list) + changed: List[Tuple[FileInfo, FileInfo]] = dataclasses.field(default_factory=list) # (old, new) + + @property + def diff_count(self) -> int: + return len(self.added) + len(self.changed) + len(self.deleted) + + +class DiffBackupAction(Action[DiffResult]): + def __init__(self, backup_id_old: int, backup_id_new: int, *, compare_status: bool): + super().__init__() + self.backup_id_old = backup_id_old + self.backup_id_new = backup_id_new + self.compare_status = compare_status + + @classmethod + def __get_files_from_backup(cls, session: DbSession, backup_id: int) -> Dict[str, FileInfo]: + files = {} + for file in session.get_backup(backup_id).files: + files[file.path] = FileInfo.of(file) + return files + + def __compare_files(self, a: FileInfo, b: FileInfo) -> bool: + return ( + True + and a.path == b.path + and a.mode == b.mode + and getattr(a.blob, 'hash', None) == getattr(b.blob, 'hash', None) + and a.content == b.content + and (not self.compare_status or ( + True + and a.uid == b.uid + and a.gid == b.gid + and a.mtime_ns == b.mtime_ns + )) + ) + + def run(self) -> DiffResult: + with DbAccess.open_session() as session: + files_old = self.__get_files_from_backup(session, self.backup_id_old) + files_new = self.__get_files_from_backup(session, self.backup_id_new) + + result = DiffResult() + for path, file in files_old.items(): + if path in files_new: + new_file = files_new[path] + if not self.__compare_files(file, new_file): + result.changed.append((file, new_file)) + else: + result.deleted.append(file) + for path, file in files_new.items(): + if path not in files_old: + result.added.append(file) + return result diff --git a/prime_backup/config/command_config.py b/prime_backup/config/command_config.py index e8d4d39..7295fe1 100644 --- a/prime_backup/config/command_config.py +++ b/prime_backup/config/command_config.py @@ -11,6 +11,7 @@ class CommandPermissions(Serializable): database: int = 4 delete: int = 2 delete_range: int = 3 + diff: int = 4 export: int = 4 # import: int = 4 # see the __add_import_permission() function below list: int = 1 diff --git a/prime_backup/mcdr/command/commands.py b/prime_backup/mcdr/command/commands.py index eb14b42..657db89 100644 --- a/prime_backup/mcdr/command/commands.py +++ b/prime_backup/mcdr/command/commands.py @@ -13,6 +13,7 @@ from prime_backup.mcdr.task.backup.create_backup_task import CreateBackupTask from prime_backup.mcdr.task.backup.delete_backup_range_task import DeleteBackupRangeTask from prime_backup.mcdr.task.backup.delete_backup_task import DeleteBackupTask +from prime_backup.mcdr.task.backup.diff_backup_task import DiffBackupTask from prime_backup.mcdr.task.backup.export_backup_task import ExportBackupTask from prime_backup.mcdr.task.backup.import_backup_task import ImportBackupTask from prime_backup.mcdr.task.backup.list_backup_task import ListBackupTask @@ -175,6 +176,11 @@ def cmd_crontab_resume(self, source: CommandSource, context: CommandContext): def cmd_prune(self, source: CommandSource, _: CommandContext): self.task_manager.add_task(PruneAllBackupTask(source)) + def cmd_diff(self, source: CommandSource, context: CommandContext): + backup_id_old = context['backup_id_old'] + backup_id_new = context['backup_id_new'] + self.task_manager.add_task(DiffBackupTask(source, backup_id_old, backup_id_new)) + def cmd_confirm(self, source: CommandSource, _: CommandContext): self.task_manager.do_confirm(source) @@ -243,8 +249,10 @@ def create_backup_id(arg: str = 'backup_id', clazz: Type[Integer] = Integer) -> builder.command('rename ', self.cmd_rename) builder.command('delete_range ', self.cmd_delete_range) builder.command('prune', self.cmd_prune) + builder.command('diff ', self.cmd_diff) - builder.arg('backup_id', create_backup_id) + for arg in ['backup_id', 'backup_id_old', 'backup_id_new']: + builder.arg(arg, create_backup_id) builder.arg('backup_id_range', IdRangeNode) builder.arg('comment', GreedyText) builder.arg('file_path', QuotableText) diff --git a/prime_backup/mcdr/task/backup/diff_backup_task.py b/prime_backup/mcdr/task/backup/diff_backup_task.py new file mode 100644 index 0000000..0360967 --- /dev/null +++ b/prime_backup/mcdr/task/backup/diff_backup_task.py @@ -0,0 +1,136 @@ +import stat +from typing import Callable, List + +from mcdreforged.api.all import * + +from prime_backup.action.diff_backup_action import DiffBackupAction +from prime_backup.mcdr.task.basic_task import LightTask +from prime_backup.mcdr.text_components import TextComponents, TextColors +from prime_backup.types.file_info import FileInfo +from prime_backup.utils import conversion_utils + + +class DiffBackupTask(LightTask[None]): + def __init__(self, source: CommandSource, backup_id_old: int, backup_id_new: int): + super().__init__(source) + self.backup_id_old = backup_id_old + self.backup_id_new = backup_id_new + + @property + def id(self) -> str: + return 'backup_diff' + + def run(self) -> None: + result = DiffBackupAction(self.backup_id_old, self.backup_id_new, compare_status=False).run() + + t_bid_old = TextComponents.backup_id(self.backup_id_old) + t_bid_new = TextComponents.backup_id(self.backup_id_new) + t_na = RText('N/A', RColor.gray) + if result.diff_count == 0: + self.reply(self.tr('no_diff', t_bid_old, t_bid_new)) + return + + self.reply(self.tr( + 'found_diff', + TextComponents.number(result.diff_count), + t_bid_old, t_bid_new, + RTextBase.join(' ', [ + RText(f'+{len(result.added)}', RColor.green), + RText(f'-{len(result.deleted)}', RColor.red), + RText(f'*{len(result.changed)}', RColor.yellow), + ]) + )) + + def pretty_mode(mode: int) -> RTextBase: + if stat.S_ISREG(mode): + type_flag = '-' + color = RColor.light_purple + elif stat.S_ISDIR(mode): + type_flag = 'd' + color = RColor.blue + elif stat.S_ISLNK(mode): + type_flag = 'l' + color = RColor.aqua + else: + type_flag = '?' + color = RColor.gray + + permissions = '' + for i in range(9): + permissions += 'rwx'[i % 3] if (mode >> (8 - i)) & 1 == 1 else '-' + + return RText(type_flag + permissions, color) + + def reply_single(f: FileInfo, head: RTextBase): + text = RTextBase.format( + '{} {} {}', + head, + pretty_mode(file.mode), + RText(file.path, TextColors.file), + ) + if f.is_link(): + if f.content is not None: + target = RText(f.content.decode('utf8'), TextColors.file) + else: + target = RText('?', RColor.gray) + text = RTextList(text, ' -> ', target) + self.reply(text) + + for file in sorted(result.added): + reply_single(file, RText('[+]', RColor.green)) + for file in sorted(result.deleted): + reply_single(file, RText('[-]', RColor.red)) + + for old_file, new_file in sorted(result.changed, key=lambda f: f[0]): + def make_hover(what_old, what_new, what_mapper: Callable = lambda x: x): + nonlocal hover_lines + hover_lines = [ + RTextBase.format('{}: {}', t_bid_old, what_mapper(what_old) if what_old is not None else t_na), + RTextBase.format('{}: {}', t_bid_new, what_mapper(what_new) if what_new is not None else t_na), + ] + + def map_or_none(value, maper: Callable): + return maper(value) if value is not None else None + + hover_lines: List[RTextBase] = [] + if old_file.mode != new_file.mode: + t_change = self.tr('diff.mode') + make_hover(pretty_mode(old_file.mode), pretty_mode(new_file.mode)) + elif (h1 := map_or_none(old_file.blob, lambda b: b.hash)) != (h2 := map_or_none(new_file.blob, lambda b: b.hash)): + t_change = self.tr('diff.blob') + n = 8 + if h1 is not None and h2 is not None: + while n < min(len(h1), len(h2)): + if h1[:n] != h2[:n]: + break + n += 8 + make_hover(h1, h2, lambda h: h[:n]) + elif old_file.content != new_file.content: + # currently only symlink uses the content + t_change = self.tr('diff.link_target') + make_hover(old_file.content, new_file.content, lambda t: t.decode('utf8')) + elif old_file.uid != new_file.uid or old_file.gid != new_file.gid: + def format_owner(f: FileInfo): + return RTextBase.format('uid={} gid={}', TextComponents.number(f.uid), TextComponents.number(f.gid)) + t_change = self.tr('diff.owner') + make_hover(format_owner(old_file), format_owner(new_file)) + elif old_file.mtime_ns != new_file.mtime_ns: + t_change = self.tr('diff.mtime') + make_hover(old_file.mtime_ns, new_file.mtime_ns, lambda mt: TextComponents.date(conversion_utils.timestamp_to_local_date(mt))) + else: + t_change = self.tr('diff.other').set_color(RColor.gray) + + if len(hover_lines) > 0 and self.source.has_permission(PermissionLevel.PHYSICAL_SERVER_CONTROL_LEVEL): + if self.source.is_player: + t_change.h(RTextBase.join('\n', hover_lines)) + else: + t_change = RTextList(t_change, ' (', RTextBase.join(', ', hover_lines), ')') + + self.reply(RTextBase.format( + '{} {} {}: {}', + RText('[*]', RColor.yellow), + pretty_mode(new_file.mode), + RText(old_file.path, TextColors.file), + t_change, + )) + diff --git a/prime_backup/types/file_info.py b/prime_backup/types/file_info.py index f067a53..ab0f17d 100644 --- a/prime_backup/types/file_info.py +++ b/prime_backup/types/file_info.py @@ -72,3 +72,11 @@ def is_dir(self) -> bool: def is_link(self) -> bool: return stat.S_ISLNK(self.mode) + + @functools.cached_property + def __cmp_key(self) -> tuple: + parts = [(part.lower(), part) for part in self.path.split('/')] + return self.backup_id, *parts + + def __lt__(self, other: 'FileInfo') -> bool: + return self.__cmp_key < other.__cmp_key