sbackup-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sbackup/__init__.py +193 -0
- sbackup/__main__.py +5 -0
- sbackup/auto_save.py +336 -0
- sbackup/compression.py +555 -0
- sbackup/config.py +134 -0
- sbackup/i18n.py +73 -0
- sbackup_cli-1.0.0.dist-info/METADATA +444 -0
- sbackup_cli-1.0.0.dist-info/RECORD +12 -0
- sbackup_cli-1.0.0.dist-info/WHEEL +5 -0
- sbackup_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sbackup_cli-1.0.0.dist-info/licenses/LICENSE +674 -0
- sbackup_cli-1.0.0.dist-info/top_level.txt +1 -0
sbackup/__init__.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
from sbackup.auto_save import BackupManager
|
|
7
|
+
from sbackup.i18n import set_locale, t
|
|
8
|
+
from sbackup.config import load_config, save_lang, save_format
|
|
9
|
+
from sbackup.compression import restore_backup
|
|
10
|
+
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocalizedArgumentParser(argparse.ArgumentParser):
|
|
16
|
+
"""本地化错误输出的 ArgumentParser 子类"""
|
|
17
|
+
|
|
18
|
+
def error(self, message: str) -> NoReturn:
|
|
19
|
+
# 将 argparse 生成的英文错误关键词替换为本地化文本
|
|
20
|
+
localized = message
|
|
21
|
+
localized = localized.replace(
|
|
22
|
+
"invalid choice: ", t("err.argparse.invalid_choice")
|
|
23
|
+
)
|
|
24
|
+
localized = localized.replace("choose from", t("err.argparse.choose_from"))
|
|
25
|
+
localized = localized.replace(
|
|
26
|
+
"invalid float value: ", t("err.argparse.invalid_float")
|
|
27
|
+
)
|
|
28
|
+
localized = localized.replace(
|
|
29
|
+
"invalid int value: ", t("err.argparse.invalid_int")
|
|
30
|
+
)
|
|
31
|
+
localized = localized.replace(
|
|
32
|
+
"unrecognized arguments: ", t("err.argparse.unrecognized_args")
|
|
33
|
+
)
|
|
34
|
+
localized = localized.replace("required", t("err.argparse.required"))
|
|
35
|
+
self.print_usage(sys.stderr)
|
|
36
|
+
sys.stderr.write(f"{self.prog}: {localized}\n")
|
|
37
|
+
sys.exit(2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _detect_lang_from_argv() -> str | None:
|
|
41
|
+
"""从 sys.argv 中提取 --lang 参数值"""
|
|
42
|
+
for i, arg in enumerate(sys.argv):
|
|
43
|
+
if arg == "--lang" and i + 1 < len(sys.argv):
|
|
44
|
+
return sys.argv[i + 1]
|
|
45
|
+
if arg.startswith("--lang="):
|
|
46
|
+
return arg.split("=", 1)[1]
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
51
|
+
parser = LocalizedArgumentParser(
|
|
52
|
+
prog="sbackup",
|
|
53
|
+
description=t("cli.description", version=VERSION),
|
|
54
|
+
epilog=t("cli.epilog"),
|
|
55
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
56
|
+
add_help=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
parser.add_argument("--debug", action="store_true", help=t("cli.help.debug"))
|
|
60
|
+
parser.add_argument("-h", "--help", action="help", help=t("cli.help.help"))
|
|
61
|
+
parser.add_argument("--lang", default=None, help=t("cli.help.lang"))
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--format",
|
|
64
|
+
default=None,
|
|
65
|
+
choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"],
|
|
66
|
+
help=t("cli.help.format"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
subparsers = parser.add_subparsers(dest="command", help=t("cli.help.subcommands"))
|
|
70
|
+
|
|
71
|
+
add_parser = subparsers.add_parser("add", help=t("cli.help.add"))
|
|
72
|
+
add_parser.add_argument("source", help=t("cli.help.add.source"))
|
|
73
|
+
add_parser.add_argument("dest", help=t("cli.help.add.dest"))
|
|
74
|
+
add_parser.add_argument(
|
|
75
|
+
"-i", "--ignore", default=".git,__pycache__", help=t("cli.help.add.ignore")
|
|
76
|
+
)
|
|
77
|
+
add_parser.add_argument(
|
|
78
|
+
"--format",
|
|
79
|
+
default=None,
|
|
80
|
+
choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"],
|
|
81
|
+
help=t("cli.help.add.format"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
rm_parser = subparsers.add_parser("rm", aliases=["remove"], help=t("cli.help.rm"))
|
|
85
|
+
rm_parser.add_argument("path", help=t("cli.help.rm.path"))
|
|
86
|
+
|
|
87
|
+
subparsers.add_parser("all", help=t("cli.help.all"))
|
|
88
|
+
|
|
89
|
+
save_parser = subparsers.add_parser("save", help=t("cli.help.save"))
|
|
90
|
+
save_parser.add_argument(
|
|
91
|
+
"--keep", type=int, default=0, help=t("cli.help.save.keep")
|
|
92
|
+
)
|
|
93
|
+
save_parser.add_argument("--password", default="", help=t("cli.help.save.password"))
|
|
94
|
+
|
|
95
|
+
watch_parser = subparsers.add_parser("watch", help=t("cli.help.watch"))
|
|
96
|
+
watch_parser.add_argument(
|
|
97
|
+
"--interval", type=float, default=60, help=t("cli.help.watch.interval")
|
|
98
|
+
)
|
|
99
|
+
watch_parser.add_argument(
|
|
100
|
+
"--keep", type=int, default=0, help=t("cli.help.watch.keep")
|
|
101
|
+
)
|
|
102
|
+
watch_parser.add_argument(
|
|
103
|
+
"--password", default="", help=t("cli.help.watch.password")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
|
|
107
|
+
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
|
|
108
|
+
restore_parser.add_argument("target_dir", help=t("cli.help.restore.dir"))
|
|
109
|
+
|
|
110
|
+
subparsers.add_parser("version", help=t("cli.help.version"))
|
|
111
|
+
|
|
112
|
+
return parser
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_path(path_str: str) -> str:
|
|
116
|
+
return os.path.expanduser(path_str.strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run() -> int:
|
|
120
|
+
# 先检测 --lang 参数,初始化语言环境,再创建本地化 parser
|
|
121
|
+
lang_from_argv = _detect_lang_from_argv()
|
|
122
|
+
config = load_config()
|
|
123
|
+
current_lang = lang_from_argv if lang_from_argv is not None else config.lang
|
|
124
|
+
set_locale(current_lang)
|
|
125
|
+
|
|
126
|
+
parser = get_parser()
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
|
|
129
|
+
# 持久化语言设置
|
|
130
|
+
if lang_from_argv is not None:
|
|
131
|
+
save_lang(lang_from_argv)
|
|
132
|
+
|
|
133
|
+
# 持久化格式设置
|
|
134
|
+
if args.format is not None:
|
|
135
|
+
save_format(args.format)
|
|
136
|
+
config.compression_format = args.format.upper().replace(".", "_")
|
|
137
|
+
|
|
138
|
+
if args.debug:
|
|
139
|
+
logging.basicConfig(
|
|
140
|
+
level=logging.DEBUG,
|
|
141
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
142
|
+
datefmt="%H:%M:%S",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if args.command is None:
|
|
146
|
+
parser.print_help()
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
if args.command == "version":
|
|
150
|
+
print(t("cli.version", version=VERSION))
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
manager = BackupManager(data_file=config.data_file)
|
|
154
|
+
|
|
155
|
+
if args.command == "add":
|
|
156
|
+
source = parse_path(args.source)
|
|
157
|
+
dest = parse_path(args.dest)
|
|
158
|
+
# 条目级格式:用户在 add 时指定的 --format 仅作用于该条目
|
|
159
|
+
entry_fmt = args.format.upper().replace(".", "_") if args.format else ""
|
|
160
|
+
success = manager.add_folder(source, dest, args.ignore, entry_fmt)
|
|
161
|
+
if success:
|
|
162
|
+
print(t("cmd.add.success", source=source, dest=dest))
|
|
163
|
+
return 0
|
|
164
|
+
return 1
|
|
165
|
+
elif args.command in ("rm", "remove"):
|
|
166
|
+
path = parse_path(args.path)
|
|
167
|
+
success = manager.rm_folder(path)
|
|
168
|
+
if success:
|
|
169
|
+
print(t("cmd.rm.success", path=path))
|
|
170
|
+
return 0
|
|
171
|
+
return 1
|
|
172
|
+
elif args.command == "all":
|
|
173
|
+
print(manager.list_folder_table())
|
|
174
|
+
return 0
|
|
175
|
+
elif args.command == "save":
|
|
176
|
+
manager.execute_backups(keep=args.keep, password=args.password)
|
|
177
|
+
return 0
|
|
178
|
+
elif args.command == "watch":
|
|
179
|
+
import time as _time
|
|
180
|
+
|
|
181
|
+
interval_sec = args.interval * 60
|
|
182
|
+
print(t("cmd.watch.start", interval=args.interval))
|
|
183
|
+
try:
|
|
184
|
+
while True:
|
|
185
|
+
manager.execute_backups(keep=args.keep, password=args.password)
|
|
186
|
+
_time.sleep(interval_sec)
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
return 0
|
|
189
|
+
elif args.command == "restore":
|
|
190
|
+
result = restore_backup(args.backup_file, args.target_dir)
|
|
191
|
+
return 0 if result["success"] else 1
|
|
192
|
+
|
|
193
|
+
return 0
|
sbackup/__main__.py
ADDED
sbackup/auto_save.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from sbackup.config import (
|
|
8
|
+
Config,
|
|
9
|
+
load_config,
|
|
10
|
+
get_default_data_file,
|
|
11
|
+
DEFAULT_SKIP_PATTERNS,
|
|
12
|
+
)
|
|
13
|
+
from sbackup.compression import create_compressor
|
|
14
|
+
from sbackup.i18n import t
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BackupEntry:
|
|
21
|
+
"""备份策略条目"""
|
|
22
|
+
|
|
23
|
+
mtime: float
|
|
24
|
+
target: str
|
|
25
|
+
skip_patterns: list[str]
|
|
26
|
+
compression_format: str = "" # 空字符串表示使用全局默认格式
|
|
27
|
+
|
|
28
|
+
def to_list(self) -> list:
|
|
29
|
+
"""转为 JSON 兼容的列表格式"""
|
|
30
|
+
return [self.mtime, self.target, self.skip_patterns, self.compression_format]
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def from_list(data: list) -> "BackupEntry":
|
|
34
|
+
"""从 JSON 兼容的列表格式创建(向后兼容旧格式)"""
|
|
35
|
+
fmt = data[3] if len(data) > 3 else ""
|
|
36
|
+
return BackupEntry(
|
|
37
|
+
mtime=data[0], target=data[1], skip_patterns=data[2], compression_format=fmt
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BackupManager:
|
|
42
|
+
"""
|
|
43
|
+
管理备份策略的类,封装状态和读写操作
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, data_file: str = ""):
|
|
47
|
+
self.data_file: str = data_file or get_default_data_file()
|
|
48
|
+
self.data: dict[str, list] = {}
|
|
49
|
+
self.load()
|
|
50
|
+
|
|
51
|
+
def load(self):
|
|
52
|
+
"""
|
|
53
|
+
从 JSON 文件加载数据到内存
|
|
54
|
+
"""
|
|
55
|
+
logger.debug(t("log.data.read"), self.data_file)
|
|
56
|
+
if not os.path.exists(self.data_file):
|
|
57
|
+
logger.debug(t("log.data.create"), self.data_file)
|
|
58
|
+
self.save(initial=True)
|
|
59
|
+
else:
|
|
60
|
+
logger.debug(t("log.data.load"), self.data_file)
|
|
61
|
+
try:
|
|
62
|
+
with open(self.data_file, "r", encoding="utf-8") as f:
|
|
63
|
+
self.data = json.load(f)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
print(t("warn.json.decode.error", path=self.data_file))
|
|
66
|
+
# 备份损坏文件,避免数据丢失
|
|
67
|
+
backup_path = self.data_file + ".bak"
|
|
68
|
+
try:
|
|
69
|
+
shutil.copy2(self.data_file, backup_path)
|
|
70
|
+
print(t("warn.json.backup", path=backup_path))
|
|
71
|
+
except OSError:
|
|
72
|
+
# 备份失败时重命名损坏文件,避免下次再次触发
|
|
73
|
+
try:
|
|
74
|
+
os.rename(self.data_file, self.data_file + ".corrupted")
|
|
75
|
+
print(
|
|
76
|
+
t("warn.json.renamed", path=self.data_file + ".corrupted")
|
|
77
|
+
)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
self.data = {}
|
|
81
|
+
|
|
82
|
+
def save(self, initial: bool = False):
|
|
83
|
+
"""
|
|
84
|
+
将内存数据写入 JSON 文件
|
|
85
|
+
"""
|
|
86
|
+
if not initial:
|
|
87
|
+
logger.debug(t("log.data.write"), self.data_file)
|
|
88
|
+
|
|
89
|
+
data_dir = os.path.dirname(self.data_file)
|
|
90
|
+
if data_dir:
|
|
91
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
with open(self.data_file, "w", encoding="utf-8") as f:
|
|
94
|
+
json.dump(self.data, f, ensure_ascii=False, indent=4)
|
|
95
|
+
|
|
96
|
+
def _get_entry(self, key: str) -> BackupEntry | None:
|
|
97
|
+
"""获取指定路径的备份策略条目"""
|
|
98
|
+
raw = self.data.get(key)
|
|
99
|
+
if raw is None:
|
|
100
|
+
return None
|
|
101
|
+
return BackupEntry.from_list(raw)
|
|
102
|
+
|
|
103
|
+
def _set_entry(self, key: str, entry: BackupEntry):
|
|
104
|
+
"""设置指定路径的备份策略条目"""
|
|
105
|
+
self.data[key] = entry.to_list()
|
|
106
|
+
|
|
107
|
+
def add_folder(
|
|
108
|
+
self,
|
|
109
|
+
folder_path: str,
|
|
110
|
+
target_folder: str,
|
|
111
|
+
skip_patterns: str | None = None,
|
|
112
|
+
compression_format: str = "",
|
|
113
|
+
):
|
|
114
|
+
"""
|
|
115
|
+
添加备份策略
|
|
116
|
+
:param compression_format: 条目级打包格式,空字符串使用全局默认
|
|
117
|
+
"""
|
|
118
|
+
if skip_patterns is None:
|
|
119
|
+
skip_patterns = ",".join(DEFAULT_SKIP_PATTERNS)
|
|
120
|
+
skip_list = (
|
|
121
|
+
[s.strip() for s in skip_patterns.split(",") if s.strip()]
|
|
122
|
+
if skip_patterns
|
|
123
|
+
else []
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not os.path.isdir(folder_path):
|
|
127
|
+
print(t("err.folder.invalid", path=folder_path))
|
|
128
|
+
return False
|
|
129
|
+
if not os.path.isdir(target_folder):
|
|
130
|
+
print(t("err.dest.invalid", path=target_folder))
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
abs_path = os.path.abspath(folder_path)
|
|
134
|
+
if abs_path in self.data:
|
|
135
|
+
print(t("info.already.added", path=abs_path))
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
entry = BackupEntry(
|
|
140
|
+
mtime=os.stat(abs_path).st_mtime,
|
|
141
|
+
target=os.path.abspath(target_folder),
|
|
142
|
+
skip_patterns=skip_list,
|
|
143
|
+
compression_format=compression_format,
|
|
144
|
+
)
|
|
145
|
+
except OSError as e:
|
|
146
|
+
print(t("err.os", error=e))
|
|
147
|
+
return False
|
|
148
|
+
self._set_entry(abs_path, entry)
|
|
149
|
+
self.save()
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def rm_folder(self, folder_path: str) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
删除备份策略
|
|
155
|
+
"""
|
|
156
|
+
abs_path = os.path.abspath(folder_path)
|
|
157
|
+
if abs_path in self.data:
|
|
158
|
+
del self.data[abs_path]
|
|
159
|
+
self.save()
|
|
160
|
+
return True
|
|
161
|
+
else:
|
|
162
|
+
print(t("warn.no.strategy.found", path=abs_path))
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def execute_backups(self, keep: int = 0, password: str = ""):
|
|
166
|
+
"""
|
|
167
|
+
执行所有备份策略
|
|
168
|
+
:param keep: 保留最近 N 个备份文件,0 表示不清理
|
|
169
|
+
:param password: 加密密码(仅 7z 格式支持)
|
|
170
|
+
"""
|
|
171
|
+
config = load_config()
|
|
172
|
+
backup_count = 0
|
|
173
|
+
skip_count = 0
|
|
174
|
+
for key, raw in list(self.data.items()):
|
|
175
|
+
if key == "_history":
|
|
176
|
+
continue
|
|
177
|
+
if not os.path.exists(key):
|
|
178
|
+
print(t("warn.source.missing", path=key))
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
current_mtime = os.stat(key).st_mtime
|
|
182
|
+
except OSError as e:
|
|
183
|
+
print(t("err.os", error=e))
|
|
184
|
+
continue
|
|
185
|
+
entry = BackupEntry.from_list(raw)
|
|
186
|
+
if entry.mtime != current_mtime:
|
|
187
|
+
# 条目级格式优先,否则使用全局配置
|
|
188
|
+
fmt = entry.compression_format or config.compression_format
|
|
189
|
+
config_instance = Config(
|
|
190
|
+
folder_path=key,
|
|
191
|
+
zipfile_path=entry.target,
|
|
192
|
+
skip_patterns=entry.skip_patterns,
|
|
193
|
+
compression_format=fmt,
|
|
194
|
+
compression_algorithm=config.compression_algorithm,
|
|
195
|
+
compression_level=config.compression_level,
|
|
196
|
+
password=password,
|
|
197
|
+
)
|
|
198
|
+
result = create_compressor(config_instance).compress()
|
|
199
|
+
if result["success"]:
|
|
200
|
+
entry.mtime = current_mtime
|
|
201
|
+
self._set_entry(key, entry)
|
|
202
|
+
self._add_history(key, result["size_mb"], result["files_count"])
|
|
203
|
+
if keep > 0:
|
|
204
|
+
self._cleanup_old_backups(entry.target, keep)
|
|
205
|
+
backup_count += 1
|
|
206
|
+
else:
|
|
207
|
+
skip_count += 1
|
|
208
|
+
if backup_count > 0:
|
|
209
|
+
self.save()
|
|
210
|
+
print(t("cmd.save.completed", count=backup_count))
|
|
211
|
+
elif skip_count > 0:
|
|
212
|
+
print(t("cmd.save.uptodate"))
|
|
213
|
+
|
|
214
|
+
# 向后兼容别名
|
|
215
|
+
save_folder = execute_backups
|
|
216
|
+
|
|
217
|
+
def _add_history(self, source: str, size_mb: float, files_count: int):
|
|
218
|
+
"""记录备份历史"""
|
|
219
|
+
from datetime import datetime
|
|
220
|
+
|
|
221
|
+
history = self.data.setdefault("_history", [])
|
|
222
|
+
history.append(
|
|
223
|
+
{
|
|
224
|
+
"time": datetime.now().isoformat(timespec="seconds"),
|
|
225
|
+
"source": source,
|
|
226
|
+
"size_mb": round(size_mb, 2),
|
|
227
|
+
"files_count": files_count,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
# 保留最近 100 条记录
|
|
231
|
+
if len(history) > 100:
|
|
232
|
+
self.data["_history"] = history[-100:]
|
|
233
|
+
|
|
234
|
+
def get_history(self) -> list[dict]:
|
|
235
|
+
"""获取备份历史记录"""
|
|
236
|
+
return self.data.get("_history", [])
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _cleanup_old_backups(target_dir: str, keep: int):
|
|
240
|
+
"""清理旧备份文件,仅保留最近 keep 个(keep=0 时不清理)"""
|
|
241
|
+
if keep <= 0:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
target = Path(target_dir)
|
|
245
|
+
if not target.is_dir():
|
|
246
|
+
return
|
|
247
|
+
# 收集所有备份文件(.zip / .tar.* / .7z)
|
|
248
|
+
patterns = [
|
|
249
|
+
"*.zip",
|
|
250
|
+
"*.tar",
|
|
251
|
+
"*.tar.gz",
|
|
252
|
+
"*.tar.bz2",
|
|
253
|
+
"*.tar.xz",
|
|
254
|
+
"*.tar.zst",
|
|
255
|
+
"*.7z",
|
|
256
|
+
]
|
|
257
|
+
files = []
|
|
258
|
+
for pat in patterns:
|
|
259
|
+
files.extend(target.glob(pat))
|
|
260
|
+
if len(files) <= keep:
|
|
261
|
+
return
|
|
262
|
+
# 按修改时间排序,删除旧的
|
|
263
|
+
files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
|
264
|
+
for old_file in files[keep:]:
|
|
265
|
+
try:
|
|
266
|
+
old_file.unlink()
|
|
267
|
+
logger.debug(t("log.cleanup.delete"), old_file)
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
def all_folder(self) -> dict[str, str]:
|
|
272
|
+
"""
|
|
273
|
+
查看所有备份策略
|
|
274
|
+
"""
|
|
275
|
+
return {
|
|
276
|
+
key: BackupEntry.from_list(raw).target for key, raw in self.data.items()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _display_width(s: str) -> int:
|
|
281
|
+
"""计算字符串的终端显示宽度(中文字符算2,英文字符算1)"""
|
|
282
|
+
if not isinstance(s, str):
|
|
283
|
+
return len(str(s))
|
|
284
|
+
width = 0
|
|
285
|
+
for ch in s:
|
|
286
|
+
if ord(ch) > 0x2E80:
|
|
287
|
+
width += 2
|
|
288
|
+
else:
|
|
289
|
+
width += 1
|
|
290
|
+
return width
|
|
291
|
+
|
|
292
|
+
def list_folder_table(self) -> str:
|
|
293
|
+
"""
|
|
294
|
+
生成对齐的文本表格
|
|
295
|
+
"""
|
|
296
|
+
if not self.data:
|
|
297
|
+
return t("cmd.all.empty")
|
|
298
|
+
|
|
299
|
+
headers = [
|
|
300
|
+
t("table.header.source"),
|
|
301
|
+
t("table.header.dest"),
|
|
302
|
+
t("table.header.format"),
|
|
303
|
+
t("table.header.ignore"),
|
|
304
|
+
]
|
|
305
|
+
rows = []
|
|
306
|
+
for path, raw in self.data.items():
|
|
307
|
+
if path == "_history":
|
|
308
|
+
continue
|
|
309
|
+
entry = BackupEntry.from_list(raw)
|
|
310
|
+
fmt_display = (
|
|
311
|
+
entry.compression_format
|
|
312
|
+
if entry.compression_format
|
|
313
|
+
else t("table.cell.default")
|
|
314
|
+
)
|
|
315
|
+
skip = (
|
|
316
|
+
", ".join(entry.skip_patterns)
|
|
317
|
+
if entry.skip_patterns
|
|
318
|
+
else t("table.cell.none")
|
|
319
|
+
)
|
|
320
|
+
rows.append([path, entry.target, fmt_display, skip])
|
|
321
|
+
|
|
322
|
+
col_widths = [self._display_width(h) for h in headers]
|
|
323
|
+
for row in rows:
|
|
324
|
+
for i, cell in enumerate(row):
|
|
325
|
+
col_widths[i] = max(col_widths[i], self._display_width(cell))
|
|
326
|
+
|
|
327
|
+
fmt = " | ".join(["{:<" + str(w) + "}" for w in col_widths])
|
|
328
|
+
sep = "-+-".join(["-" * w for w in col_widths])
|
|
329
|
+
|
|
330
|
+
lines = []
|
|
331
|
+
lines.append(fmt.format(*headers))
|
|
332
|
+
lines.append(sep)
|
|
333
|
+
for row in rows:
|
|
334
|
+
lines.append(fmt.format(*row))
|
|
335
|
+
|
|
336
|
+
return "\n".join(lines)
|