Skip to content

Commit

Permalink
Added !!pb diff command to show differences between two backups
Browse files Browse the repository at this point in the history
  • Loading branch information
Fallen-Breath committed Dec 14, 2023
1 parent 54f452e commit 940d886
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 1 deletion.
12 changes: 12 additions & 0 deletions lang/en_us.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -222,6 +233,7 @@ prime_backup:
§7{prefix} export §6<backup_id> §7[...]§r: Export the given backup. See §7{prefix} help export§r for detailed help
§7{prefix} import §3<file_path> §7[...]§r: Import backup from an external file. See §7{prefix} help import§r for detailed help
§7{prefix} prune §6<backup_id>§r: Manually trigger a backup prune
§7{prefix} diff §6<backup_id_old> §6<backup_id_new>§r: Show file differences between two backups
§7{prefix} crontab §a<job_id> §7[...]§r: Crontab job operations. See §7{prefix} help crontab§r for help
§7{prefix} tag §6<backup_id> §7[...]§r: Tag operation on the given backup
§7{prefix} database [...]§r: Database operations. See §7{prefix} help database§r for help
Expand Down
12 changes: 12 additions & 0 deletions lang/zh_cn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 文件{}不存在
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions prime_backup/action/diff_backup_action.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions prime_backup/config/command_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion prime_backup/mcdr/command/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -243,8 +249,10 @@ def create_backup_id(arg: str = 'backup_id', clazz: Type[Integer] = Integer) ->
builder.command('rename <backup_id> <comment>', self.cmd_rename)
builder.command('delete_range <backup_id_range>', self.cmd_delete_range)
builder.command('prune', self.cmd_prune)
builder.command('diff <backup_id_old> <backup_id_new>', 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)
Expand Down
136 changes: 136 additions & 0 deletions prime_backup/mcdr/task/backup/diff_backup_task.py
Original file line number Diff line number Diff line change
@@ -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,
))

8 changes: 8 additions & 0 deletions prime_backup/types/file_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 940d886

Please sign in to comment.