-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patharchive_db.py
More file actions
283 lines (229 loc) · 10.4 KB
/
Copy patharchive_db.py
File metadata and controls
283 lines (229 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import logging
import os
import re
import sqlite3
# ---------------------------------------------------------------------------
# Private helpers (copies of the equivalents in wa_media_archiver.py)
# ---------------------------------------------------------------------------
def _sanitize_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', '_', name).strip()
def _escape_like(s: str) -> str:
return s.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
def _format_phone(number: str) -> str:
return '00' + number if number else ''
def _build_contact_folder_name(display_name: str, number: str) -> str:
formatted = _format_phone(number)
if not display_name or display_name == number:
label = f"Unknown ({formatted})"
else:
label = f"{display_name} ({formatted})"
return _sanitize_filename(label)
# ---------------------------------------------------------------------------
# Open / schema
# ---------------------------------------------------------------------------
def open_archive_db(output_root: str) -> sqlite3.Connection:
db_path = os.path.join(output_root, '.wa_media_archiver.db')
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA auto_vacuum = INCREMENTAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript("""
CREATE TABLE IF NOT EXISTS contacts (
number TEXT PRIMARY KEY,
folder TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS groups (
chat_row_id TEXT PRIMARY KEY,
folder TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS files (
original_path TEXT PRIMARY KEY,
md5 BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_copies (
original_path TEXT NOT NULL REFERENCES files(original_path),
archive_path TEXT NOT NULL,
PRIMARY KEY (original_path, archive_path)
);
CREATE INDEX IF NOT EXISTS idx_files_md5 ON files(md5);
""")
return conn
def check_db_health(conn: sqlite3.Connection, logger: logging.Logger):
results = conn.execute("PRAGMA quick_check").fetchall()
if results != [('ok',)]:
for row in results:
logger.error(f"Database integrity issue: {row[0]}")
raise SystemExit(1)
issues = conn.execute("PRAGMA foreign_key_check").fetchall()
if issues:
for table, rowid, parent, fkid in issues:
logger.error(
f"Foreign key violation in '{table}': rowid={rowid}, "
f"references '{parent}' (fk #{fkid})"
)
raise SystemExit(1)
logger.debug("Database health check passed.")
# ---------------------------------------------------------------------------
# Contacts persistence
# ---------------------------------------------------------------------------
def load_contacts_from_db(conn: sqlite3.Connection) -> dict:
return {row[0]: (row[1], row[2])
for row in conn.execute("SELECT number, folder, display_name FROM contacts")}
def save_contacts_to_db(conn: sqlite3.Connection, index: dict):
conn.executemany(
"INSERT OR REPLACE INTO contacts (number, folder, display_name) VALUES (?, ?, ?)",
((number, folder, display_name) for number, (folder, display_name) in index.items())
)
# ---------------------------------------------------------------------------
# Groups persistence
# ---------------------------------------------------------------------------
def load_groups_from_db(conn: sqlite3.Connection) -> dict:
return {
row[0]: {'folder': row[1], 'subject': row[2]}
for row in conn.execute(
"SELECT chat_row_id, folder, subject FROM groups"
)
}
def save_groups_to_db(conn: sqlite3.Connection, index: dict):
conn.executemany(
"INSERT OR REPLACE INTO groups (chat_row_id, folder, subject) VALUES (?, ?, ?)",
((key, val['folder'], val['subject']) for key, val in index.items())
)
# ---------------------------------------------------------------------------
# File archive tracking
# ---------------------------------------------------------------------------
def record_file_archived(cursor: sqlite3.Cursor,
original_path: str, md5: bytes, archive_path: str):
cursor.execute(
"INSERT INTO files (original_path, md5) VALUES (?, ?) "
"ON CONFLICT(original_path) DO UPDATE SET md5 = excluded.md5",
(original_path, md5)
)
cursor.execute(
"INSERT OR IGNORE INTO archive_copies (original_path, archive_path) VALUES (?, ?)",
(original_path, archive_path)
)
# ---------------------------------------------------------------------------
# Folder name sync and resolution
# ---------------------------------------------------------------------------
def _unique_group_name(desired: str, existing: set) -> str:
if desired not in existing:
return desired
counter = 2
while True:
candidate = f"{desired} ({counter})"
if candidate not in existing:
return candidate
counter += 1
def sync_group_names(group_subjects: dict, output_root: str,
group_index: dict, logger: logging.Logger,
conn: sqlite3.Connection | None = None,
dry_run: bool = False) -> dict:
groups_root = os.path.join(output_root, 'Groups')
updated = dict(group_index)
for key, current_subject in group_subjects.items():
if key not in updated:
continue
entry = updated[key]
old_folder = entry['folder']
old_subject = entry.get('subject', '')
if current_subject == old_subject:
continue
desired = _sanitize_filename(current_subject) if current_subject \
else f"Unknown Group ({key})"
existing = {v['folder'] for k2, v in updated.items() if k2 != key}
new_folder = _unique_group_name(desired, existing)
old_path = os.path.join(groups_root, old_folder)
new_path = os.path.join(groups_root, new_folder)
if os.path.exists(old_path):
if os.path.exists(new_path):
logger.warning(
f"RENAME skipped — target already exists: "
f"{old_folder} -> {new_folder}"
)
else:
if dry_run:
logger.info(f"[DRY RUN] Would rename group folder: {old_folder} -> {new_folder}")
continue
else:
os.rename(old_path, new_path)
logger.info(f"RENAMED group folder: {old_folder} -> {new_folder}")
if conn is not None:
old_prefix = f"Groups/{old_folder}/"
new_prefix = f"Groups/{new_folder}/"
conn.execute(
"UPDATE archive_copies "
"SET archive_path = ? || SUBSTR(archive_path, ?) "
"WHERE archive_path LIKE ? ESCAPE '\\'",
(new_prefix, len(old_prefix) + 1,
f"{_escape_like(old_prefix)}%")
)
else:
logger.debug(
f"Group folder name changed but no folder on disk yet: "
f"{old_folder} -> {new_folder}"
)
updated[key] = {'folder': new_folder, 'subject': current_subject}
return updated
def resolve_group_folder(chat_row_id: int, chat_subject: str | None,
group_index: dict) -> str:
key = str(chat_row_id)
if key in group_index:
return group_index[key]['folder']
desired = _sanitize_filename(chat_subject) if chat_subject \
else f"Unknown Group ({chat_row_id})"
existing = {v['folder'] for v in group_index.values()}
folder = _unique_group_name(desired, existing)
group_index[key] = {'folder': folder, 'subject': chat_subject or ''}
return folder
def sync_folder_names(contacts: dict, number_map: dict, output_root: str,
folder_index: dict, logger: logging.Logger,
conn: sqlite3.Connection | None = None,
dry_run: bool = False) -> dict:
contacts_root = os.path.join(output_root, 'Contacts')
updated_index = dict(folder_index)
for number, display_name in contacts.items():
canonical = number_map.get(number, number)
new_folder = _build_contact_folder_name(display_name, canonical)
entry = folder_index.get(canonical)
old_folder = entry[0] if entry is not None else None
if old_folder is None:
updated_index[canonical] = (new_folder, display_name)
continue
if old_folder == new_folder:
updated_index[canonical] = (new_folder, display_name)
continue
old_path = os.path.join(contacts_root, old_folder)
new_path = os.path.join(contacts_root, new_folder)
if os.path.exists(old_path):
if os.path.exists(new_path):
logger.warning(
f"RENAME skipped — target already exists: "
f"{old_folder} -> {new_folder}"
)
else:
if dry_run:
logger.info(f"[DRY RUN] Would rename contact folder: {old_folder} -> {new_folder}")
continue
else:
os.rename(old_path, new_path)
logger.info(f"RENAMED contact folder: {old_folder} -> {new_folder}")
if conn is not None:
old_prefix = f"Contacts/{old_folder}/"
new_prefix = f"Contacts/{new_folder}/"
conn.execute(
"UPDATE archive_copies "
"SET archive_path = ? || SUBSTR(archive_path, ?) "
"WHERE archive_path LIKE ? ESCAPE '\\'",
(new_prefix, len(old_prefix) + 1,
f"{_escape_like(old_prefix)}%")
)
else:
logger.debug(
f"Folder name changed but no folder on disk yet: "
f"{old_folder} -> {new_folder}"
)
updated_index[canonical] = (new_folder, display_name)
return updated_index