feat(export): 支持 CSV 计划导出和稳定导出索引 (#114)

两个核心增强:

1. **CSV 计划导出工作流** (`--write-plan-csv` / `--from-plan-csv`)
   - 支持 blacklist (export=0 跳过) / whitelist (export=1 导出) 两种模式
   - `--size-mode estimate|scan` 控制是否扫本地附件
   - UTF-8 BOM 编码, Excel/WPS 直接打开
   - 解决了之前"动不动全量导出"的痛点

2. **`_export_index.json` 稳定导出索引**
   - 用 username 追踪当前 JSON 文件
   - 联系人备注/群名变化时自动重命名旧文件 (而不是产生孤儿)
   - 同名联系人冲突时自动追加 `__<username>` 后缀
   - atomic write (tmp + os.replace), bootstrap from existing files

3. **JSON metadata 扩展**
   - 新增 `date_first_msg / date_last_msg / contact_remark / contact_nick_name / contact_tags / contact_memo`

测试: 717 行, 20 tests pass, 覆盖 CRUD 索引、黑白名单、命名冲突、incremental rename、UTF-8 BOM。
This commit is contained in:
phoenixray2000
2026-05-19 02:20:30 +08:00
committed by GitHub
parent 4a29841ec1
commit 728dbb72df
8 changed files with 1885 additions and 56 deletions

View File

@@ -222,7 +222,7 @@ atexit.register(_cache.cleanup)
# ============ 联系人缓存 ============
_contact_names = None # {username: display_name}
_contact_full = None # [{username, nick_name, remark}]
_contact_full = None # [{username, nick_name, remark, alias, description, phone}]
_contact_tags = None # {label_id: {name, sort_order, members: [{username, display_name}]}}
_self_username = None
_contact_db_mtime = 0 # mtime of the decrypted contact.db when caches were last populated
@@ -240,19 +240,55 @@ _QUERY_LIMIT_MAX = 500
_HISTORY_QUERY_BATCH_SIZE = 500
def _load_contacts_from(db_path):
names = {}
full = []
conn = sqlite3.connect(db_path)
try:
for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall():
uname, nick, remark = r
display = remark if remark else nick if nick else uname
names[uname] = display
full.append({'username': uname, 'nick_name': nick or '', 'remark': remark or ''})
finally:
conn.close()
return names, full
def _load_contacts_from(db_path):
names = {}
full = []
conn = sqlite3.connect(db_path)
try:
columns = {
row[1] for row in conn.execute("PRAGMA table_info(contact)").fetchall()
}
optional_columns = {
"alias": "",
"description": "",
"phone": "",
"phone_number": "",
"mobile": "",
"mobile_phone": "",
"telephone": "",
}
select_columns = ["username", "nick_name", "remark"]
select_columns.extend(
col for col in optional_columns
if col in columns and col not in select_columns
)
rows = conn.execute(
"SELECT " + ", ".join(f"[{col}]" for col in select_columns)
+ " FROM contact"
).fetchall()
for r in rows:
data = dict(zip(select_columns, r))
uname = data.get("username")
nick = data.get("nick_name")
remark = data.get("remark")
display = remark if remark else nick if nick else uname
names[uname] = display
phone = ""
for col in ("phone", "phone_number", "mobile", "mobile_phone", "telephone"):
if data.get(col):
phone = data.get(col) or ""
break
full.append({
'username': uname,
'nick_name': nick or '',
'remark': remark or '',
'alias': data.get("alias") or '',
'description': data.get("description") or '',
'phone': phone,
})
finally:
conn.close()
return names, full
def _get_contact_db_path():
@@ -301,9 +337,23 @@ def get_contact_names():
return {}
def get_contact_full():
get_contact_names()
return _contact_full or []
def get_contact_full():
get_contact_names()
return _contact_full or []
def get_contact_tag_names_by_username():
tags = _load_contact_tags()
by_username = {}
for tag in tags.values():
name = tag.get('name') or ''
if not name:
continue
for member in tag.get('members', []):
username = member.get('username')
if username:
by_username.setdefault(username, []).append(name)
return by_username
def _extract_pb_field_30(data):