from __future__ import annotations import hashlib import json import os import shutil import sys import time from pathlib import Path from .client import delete_remote, download, list_remote, upload SYNC_DIR_NAME = '.nanoshare-sync' STATE_FILE_NAME = 'state.json' def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open('rb') as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() def state_path(root: Path) -> Path: return root / SYNC_DIR_NAME / STATE_FILE_NAME def load_state(root: Path) -> dict: path = state_path(root) if not path.is_file(): return {'version': 1, 'files': {}} try: data = json.loads(path.read_text()) except (OSError, ValueError): return {'version': 1, 'files': {}} if not isinstance(data, dict): return {'version': 1, 'files': {}} data.setdefault('version', 1) data.setdefault('files', {}) return data def save_state(root: Path, state: dict) -> None: path = state_path(root) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(f'{path.name}.{os.getpid()}.tmp') tmp.write_text(json.dumps(state, indent=2, sort_keys=True)) os.replace(tmp, path) def iter_local_files(root: Path): sync_dir = root / SYNC_DIR_NAME for path in sorted(root.rglob('*')): if not path.is_file(): continue if path == state_path(root) or sync_dir in path.parents: continue yield path def relative(root: Path, path: Path) -> str: return path.relative_to(root).as_posix() def safe_remote_name(name: str, file_id: str) -> str: candidate = Path(name or file_id).name return candidate or file_id def unique_path(path: Path) -> Path: if not path.exists(): return path stamp = time.strftime('%Y%m%d-%H%M%S') suffix = ''.join(path.suffixes) stem = path.name[:-len(suffix)] if suffix else path.name candidate = path.with_name(f'{stem}.conflict-{stamp}{suffix}') counter = 2 while candidate.exists(): candidate = path.with_name(f'{stem}.conflict-{stamp}-{counter}{suffix}') counter += 1 return candidate def remote_by_id(remote_files: list[dict]) -> dict[str, dict]: return {item['file_id']: item for item in remote_files if isinstance(item, dict) and item.get('file_id')} def remote_by_name(remote_files: list[dict]) -> dict[str, dict]: result = {} for item in remote_files: if isinstance(item, dict) and item.get('file_name') and item.get('file_id'): result.setdefault(item['file_name'], item) return result def sync_once(args, client) -> int: root = Path(args.folder).expanduser().resolve() root.mkdir(parents=True, exist_ok=True) state = load_state(root) known = state.setdefault('files', {}) try: remote_files = list_remote(client, args.node) remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) local_paths = {relative(root, path): path for path in iter_local_files(root)} for rel, path in local_paths.items(): current_hash = sha256(path) entry = known.get(rel) remote_id = entry.get('file_id') if isinstance(entry, dict) else None old_hash = entry.get('sha256') if isinstance(entry, dict) else None if old_hash == current_hash and remote_id in remote_ids: continue if remote_id and remote_id not in remote_ids and old_hash != current_hash: conflict_path = unique_path(path) shutil.copy2(path, conflict_path) print(f'conflict: kept changed local copy at {conflict_path}') print(f'upload: {rel}') result = upload(client, args.node, path, rel, args.note or '', args.expires or '') new_file_id = result.get('file_id') or result.get('id') if not new_file_id: matching = remote_names.get(rel) refreshed = list_remote(client, args.node) new_file_id = (remote_by_name(refreshed).get(rel) or matching or {}).get('file_id') remote_files = refreshed remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) if not new_file_id: print(f'warning: upload succeeded but file_id is unknown for {rel}', file=sys.stderr) continue if remote_id and remote_id in remote_ids and remote_id != new_file_id: try: delete_remote(client, args.node, remote_id) print(f'delete old remote: {rel}') remote_files = [item for item in remote_files if item.get('file_id') != remote_id] remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) except Exception as exc: print(f'warning: could not delete old remote {remote_id}: {exc}', file=sys.stderr) known[rel] = { 'file_id': new_file_id, 'sha256': current_hash, 'local_mtime': path.stat().st_mtime, 'file_name': rel, } known_ids = {entry.get('file_id') for entry in known.values() if isinstance(entry, dict)} for remote in remote_files: file_id = remote.get('file_id') if not file_id or file_id in known_ids: continue name = safe_remote_name(remote.get('file_name') or '', file_id) path = unique_path(root / name) print(f'download: {file_id} -> {relative(root, path)}') download(client, args.node, file_id, path) known[relative(root, path)] = { 'file_id': file_id, 'sha256': sha256(path), 'local_mtime': path.stat().st_mtime, 'file_name': relative(root, path), } if args.delete: for rel, entry in list(known.items()): if rel in local_paths: continue file_id = entry.get('file_id') if isinstance(entry, dict) else None if file_id and file_id in remote_ids: print(f'delete remote missing locally: {rel}') delete_remote(client, args.node, file_id) known.pop(rel, None) save_state(root, state) except Exception as exc: print(f'error: {exc}', file=sys.stderr) return 1 return 0