Download sync.py from plokmii/pradsa: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/spaces/plokmii/pradsa/resolve/main/sync.py
- Command line
-
hf download hf://spaces/plokmii/pradsa/sync.py
-
curl -L -o sync.py https://huggingface.co/spaces/plokmii/pradsa/resolve/main/sync.py
11.5 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| 雙向同步: | |
| - pull: 雲端 → Local(雲端為主,覆蓋 Local) | |
| - push: Local → 雲端(Append-only merge,不刪雲端資料) | |
| 用法: python sync.py # pull(預設) | |
| python sync.py push # push | |
| """ | |
| import configparser | |
| import sqlite3 | |
| import shutil | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| from datetime import datetime | |
| TOKEN = os.environ.get('HF_TOKEN', '') | |
| USERNAME = os.environ.get('HF_USERNAME', '') | |
| if not TOKEN: | |
| cfg = configparser.ConfigParser() | |
| cfg.read('Myconfig.ini') | |
| try: | |
| TOKEN = cfg['Hugging Face']['Token'].strip() | |
| USERNAME = cfg['Hugging Face']['Username'].strip() | |
| except KeyError: | |
| pass | |
| REPO_ID = f'{USERNAME}/pradsa' | |
| LOCAL_DB = Path('pra_data.db') | |
| BACKUP_DIR = LOCAL_DB.parent / 'backups' | |
| def get_db_info(db_path): | |
| if not Path(db_path).exists(): | |
| return {'patients': 0, 'reports': 0, 'users': 0, 'latest': ''} | |
| conn = sqlite3.connect(str(db_path)) | |
| conn.row_factory = sqlite3.Row | |
| try: | |
| patients = conn.execute('SELECT COUNT(*) as c FROM patients').fetchone()['c'] | |
| reports = conn.execute('SELECT COUNT(*) as c FROM reports WHERE COALESCE(is_deleted,0)=0').fetchone()['c'] | |
| users = conn.execute('SELECT COUNT(*) as c FROM users').fetchone()['c'] | |
| latest = conn.execute( | |
| "SELECT MAX(COALESCE(updated_at, created_at)) as t FROM reports" | |
| ).fetchone()['t'] or '' | |
| except Exception: | |
| patients, reports, users, latest = 0, 0, 0, '' | |
| conn.close() | |
| return {'patients': patients, 'reports': reports, 'users': users, 'latest': latest} | |
| def download_cloud_db(): | |
| from huggingface_hub import hf_hub_download | |
| try: | |
| path = hf_hub_download( | |
| repo_id=REPO_ID, filename='pra_data.db', | |
| repo_type='space', token=TOKEN, | |
| local_dir=tempfile.gettempdir(), | |
| force_download=True, | |
| ) | |
| return path | |
| except Exception as e: | |
| print(f' 雲端 DB 下載失敗: {e}') | |
| return None | |
| def upload_db(db_path): | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=TOKEN) | |
| api.upload_file( | |
| path_or_fileobj=str(db_path), | |
| path_in_repo='pra_data.db', | |
| repo_id=REPO_ID, | |
| repo_type='space', | |
| ) | |
| def _backup_local(tag='sync'): | |
| """備份 Local DB(當天最多 5 個,總共最多 15 個)""" | |
| if not LOCAL_DB.exists(): | |
| return None | |
| BACKUP_DIR.mkdir(exist_ok=True) | |
| today = datetime.now().strftime('%Y%m%d') | |
| ts = datetime.now().strftime('%Y%m%d_%H%M%S') | |
| bk = BACKUP_DIR / f'pra_data_before_{tag}_{ts}.db' | |
| shutil.copy2(str(LOCAL_DB), str(bk)) | |
| print(f'已備份 Local → {bk.name}') | |
| today_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_{today}_*.db')) | |
| for old in today_bks[:-5]: | |
| old.unlink() | |
| all_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_*.db')) | |
| for old in all_bks[:-15]: | |
| old.unlink() | |
| return bk | |
| def merge_local_into_cloud(local_path, cloud_path, merged_path): | |
| """ | |
| Append-only merge:以雲端為底,把 Local 的新增/更新資料合併進去。 | |
| 絕不刪除雲端已有的 patients / reports / users。 | |
| """ | |
| shutil.copy2(cloud_path, merged_path) | |
| merged = sqlite3.connect(str(merged_path)) | |
| merged.row_factory = sqlite3.Row | |
| local = sqlite3.connect(str(local_path)) | |
| local.row_factory = sqlite3.Row | |
| # --- patients: 以 chart_no 為 key,UPSERT --- | |
| local_patients = local.execute('SELECT * FROM patients').fetchall() | |
| for p in local_patients: | |
| existing = merged.execute('SELECT id FROM patients WHERE chart_no=?', (p['chart_no'],)).fetchone() | |
| if existing: | |
| # 更新 patient_name, donor_hla(取較新的) | |
| merged.execute('UPDATE patients SET patient_name=?, donor_hla=? WHERE chart_no=?', | |
| (p['patient_name'], p['donor_hla'] or '', p['chart_no'])) | |
| else: | |
| merged.execute( | |
| 'INSERT INTO patients (patient_name, chart_no, created_at, donor_hla) VALUES (?,?,?,?)', | |
| (p['patient_name'], p['chart_no'], p['created_at'], p['donor_hla'] or '')) | |
| # 建立 chart_no → merged patient_id 對照 | |
| merged_patients = {r['chart_no']: r['id'] for r in merged.execute('SELECT id, chart_no FROM patients').fetchall()} | |
| # 建立 local patient_id → chart_no 對照 | |
| local_pt_map = {r['id']: r['chart_no'] for r in local.execute('SELECT id, chart_no FROM patients').fetchall()} | |
| # --- reports: 以 (patient chart_no + report_date + pra_class) 為 key --- | |
| local_reports = local.execute('SELECT * FROM reports').fetchall() | |
| for r in local_reports: | |
| chart_no = local_pt_map.get(r['patient_id']) | |
| if not chart_no: | |
| continue | |
| merged_pid = merged_patients.get(chart_no) | |
| if not merged_pid: | |
| continue | |
| existing = merged.execute( | |
| 'SELECT id, COALESCE(is_deleted,0) as is_deleted, updated_at FROM reports WHERE patient_id=? AND report_date=? AND pra_class=?', | |
| (merged_pid, r['report_date'], r['pra_class']) | |
| ).fetchone() | |
| if existing: | |
| # 雲端有此 report → 更新內容(但不把雲端 active 的改為 deleted) | |
| cloud_deleted = existing['is_deleted'] | |
| local_deleted = r['is_deleted'] if r['is_deleted'] is not None else 0 | |
| # 如果 local 標記刪除但雲端沒有 → 保留雲端的 active 狀態 | |
| new_deleted = 0 if (cloud_deleted == 0 and local_deleted == 1) else local_deleted | |
| merged.execute('''UPDATE reports SET pra_percent=?, overall=?, specificity=?, comment=?, | |
| status=?, submitted_by=?, upload_file=?, updated_at=?, | |
| is_deleted=?, deleted_at=? WHERE id=?''', | |
| (r['pra_percent'], r['overall'], r['specificity'], r['comment'], | |
| r['status'] or 'draft', r['submitted_by'] or '', | |
| r['upload_file'] or '', r['updated_at'], | |
| new_deleted, r['deleted_at'] if new_deleted else None, | |
| existing['id'])) | |
| merged_rid = existing['id'] | |
| else: | |
| # 雲端沒有 → 新增 | |
| cur = merged.execute( | |
| '''INSERT INTO reports (patient_id, report_date, pra_class, pra_percent, overall, | |
| specificity, comment, status, submitted_by, upload_file, created_at, updated_at, | |
| is_deleted, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', | |
| (merged_pid, r['report_date'], r['pra_class'], r['pra_percent'], r['overall'], | |
| r['specificity'], r['comment'], r['status'] or 'draft', | |
| r['submitted_by'] or '', r['upload_file'] or '', | |
| r['created_at'], r['updated_at'], | |
| r['is_deleted'] if r['is_deleted'] is not None else 0, r['deleted_at'])) | |
| merged_rid = cur.lastrowid | |
| # antibody_strength: 用 local 的資料覆蓋(屬於同一 report) | |
| local_rid = r['id'] | |
| local_abs = local.execute('SELECT * FROM antibody_strength WHERE report_id=?', (local_rid,)).fetchall() | |
| if local_abs: | |
| merged.execute('DELETE FROM antibody_strength WHERE report_id=?', (merged_rid,)) | |
| for a in local_abs: | |
| merged.execute( | |
| '''INSERT INTO antibody_strength (report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads, bead_ids) | |
| VALUES (?,?,?,?,?,?,?)''', | |
| (merged_rid, a['antigen'], a['allele'], a['max_mfi'], a['mean_mfi'], | |
| a['no_of_beads'], a['bead_ids'])) | |
| # --- users: 以 username 為 key,只新增不刪 --- | |
| local_users = local.execute('SELECT * FROM users').fetchall() | |
| for u in local_users: | |
| existing = merged.execute('SELECT id FROM users WHERE username=?', (u['username'],)).fetchone() | |
| if existing: | |
| # 更新 display_name, role, password(取 local 版本) | |
| merged.execute('UPDATE users SET display_name=?, password_hash=?, password_plain=?, role=?, edit_time=? WHERE id=?', | |
| (u['display_name'] or '', u['password_hash'], u['password_plain'] or '', | |
| u['role'] or 'user', u['edit_time'], existing['id'])) | |
| else: | |
| merged.execute( | |
| 'INSERT INTO users (username, display_name, password_hash, password_plain, role, created_at, edit_time) VALUES (?,?,?,?,?,?,?)', | |
| (u['username'], u['display_name'] or '', u['password_hash'], | |
| u['password_plain'] or '', u['role'] or 'user', u['created_at'], u['edit_time'])) | |
| merged.commit() | |
| local.close() | |
| merged.close() | |
| def push(): | |
| """Local → 雲端 (Append-only merge)""" | |
| print('=== Local → 雲端 Append-only Push ===\n') | |
| local_info = get_db_info(LOCAL_DB) | |
| print(f'Local: {local_info["patients"]} 病患, {local_info["reports"]} 報告, {local_info["users"]} 使用者') | |
| print('下載雲端 DB...') | |
| cloud_path = download_cloud_db() | |
| if not cloud_path: | |
| print('無法取得雲端 DB,改為直接上傳 Local DB') | |
| upload_db(LOCAL_DB) | |
| print('已直接上傳') | |
| return | |
| cloud_info = get_db_info(cloud_path) | |
| print(f'Cloud: {cloud_info["patients"]} 病患, {cloud_info["reports"]} 報告, {cloud_info["users"]} 使用者') | |
| print() | |
| # Merge | |
| merged_path = Path(tempfile.gettempdir()) / 'pra_data_merged.db' | |
| merge_local_into_cloud(str(LOCAL_DB), cloud_path, str(merged_path)) | |
| merged_info = get_db_info(merged_path) | |
| print(f'Merged: {merged_info["patients"]} 病患, {merged_info["reports"]} 報告, {merged_info["users"]} 使用者') | |
| # Upload merged | |
| upload_db(merged_path) | |
| print('\n已推送 merged DB 到雲端') | |
| # 也更新 Local 為 merged 版本 | |
| _backup_local('push') | |
| shutil.copy2(str(merged_path), str(LOCAL_DB)) | |
| print('Local DB 已同步為 merged 版本') | |
| def sync(): | |
| """雲端 → Local(pull,雲端為主)""" | |
| print('=== 雲端 → Local 單向同步 ===\n') | |
| local_info = get_db_info(LOCAL_DB) | |
| print(f'Local: {local_info["patients"]} 病患, {local_info["reports"]} 報告, {local_info["users"]} 使用者, latest: {local_info["latest"] or "(none)"}') | |
| print('下載雲端 DB...') | |
| cloud_path = download_cloud_db() | |
| if not cloud_path: | |
| print('無法取得雲端 DB') | |
| return | |
| cloud_info = get_db_info(cloud_path) | |
| print(f'Cloud: {cloud_info["patients"]} 病患, {cloud_info["reports"]} 報告, {cloud_info["users"]} 使用者, latest: {cloud_info["latest"] or "(none)"}') | |
| print() | |
| if (local_info['latest'] == cloud_info['latest'] | |
| and local_info['reports'] == cloud_info['reports'] | |
| and local_info['patients'] == cloud_info['patients'] | |
| and local_info['users'] == cloud_info['users']): | |
| print('兩邊一致,不需要同步') | |
| return | |
| _backup_local('sync') | |
| # 直接用雲端 DB 覆蓋 Local | |
| shutil.copy2(cloud_path, str(LOCAL_DB)) | |
| new_info = get_db_info(LOCAL_DB) | |
| print(f'\n同步完成: {new_info["patients"]} 病患, {new_info["reports"]} 報告, {new_info["users"]} 使用者') | |
| if __name__ == '__main__': | |
| import sys | |
| if len(sys.argv) > 1 and sys.argv[1] == 'push': | |
| push() | |
| else: | |
| sync() | |