from __future__ import annotations import hashlib import json import os import shutil import sys import tempfile import time from pathlib import Path from .client import delete_remote, download, list_remote, update_remote, upload from .ignore import is_ignored, load_ignore_patterns from .remote_path import join_remote_path, split_remote_path 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, ignore_patterns: list[str] | None = None): sync_dir = root / SYNC_DIR_NAME patterns = ignore_patterns or [] for path in sorted(root.rglob('*')): if not path.is_file(): continue if path == state_path(root) or sync_dir in path.parents: continue if is_ignored(relative(root, path), patterns): 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: return join_remote_path(name, file_id=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(join_remote_path(item['file_name'], item.get('file_path') or ''), item) return result def parse_size(value: object) -> int | None: if isinstance(value, int): return value text = str(value or '').strip() if not text: return None parts = text.split() try: number = float(parts[0]) except (ValueError, IndexError): return None unit = parts[1].lower() if len(parts) > 1 else 'bytes' factors = { 'byte': 1, 'bytes': 1, 'b': 1, 'kb': 1024, 'mb': 1024 ** 2, 'gb': 1024 ** 3, 'tb': 1024 ** 4, } factor = factors.get(unit) return int(number * factor) if factor else None def find_moved_entry(root: Path, known: dict, rel: str, local_hash: str, remote_ids: dict[str, dict]) -> tuple[str, dict] | None: for old_rel, entry in known.items(): if old_rel == rel or not isinstance(entry, dict): continue file_id = entry.get('file_id') if entry.get('sha256') == local_hash and file_id in remote_ids and not (root / old_rel).is_file(): return old_rel, entry return None def find_adoptable_remote(client, node: str, path: Path, local_hash: str, remote_files: list[dict], known_ids: set[str]) -> dict | None: size = path.stat().st_size name = path.name candidates = [] for remote in remote_files: if not isinstance(remote, dict): continue file_id = remote.get('file_id') if not file_id or file_id in known_ids: continue if remote.get('file_name') != name: continue remote_size = parse_size(remote.get('size_bytes') or remote.get('file_size')) if remote_size is not None and remote_size != size: continue candidates.append(remote) hash_matches = [] with tempfile.TemporaryDirectory(prefix='nanoshare-adopt-') as temp_dir: for remote in candidates: temp_path = Path(temp_dir) / str(remote['file_id']) try: download(client, node, str(remote['file_id']), temp_path) except Exception as exc: print(f"warning: could not verify remote {remote['file_id']} for adoption: {exc}", file=sys.stderr) continue if sha256(temp_path) == local_hash: hash_matches.append(remote) return hash_matches[0] if len(hash_matches) == 1 else None 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: ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None)) remote_files = list_remote(client, args.node) remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) for path in iter_local_files(root, ignore_patterns): rel = relative(root, path) try: current_hash = sha256(path) except FileNotFoundError: print(f'skip vanished local file: {rel}') continue 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 remote_file_name, remote_file_path = split_remote_path(rel) if not entry: moved = find_moved_entry(root, known, rel, current_hash, remote_ids) if moved: old_rel, moved_entry = moved moved_id = moved_entry['file_id'] update_remote(client, args.node, moved_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '') print(f'move remote: {old_rel} -> {rel}') known.pop(old_rel, None) known[rel] = { 'file_id': moved_id, 'sha256': current_hash, 'local_mtime': path.stat().st_mtime, 'file_name': remote_file_name, 'file_path': remote_file_path, } remote_files = list_remote(client, args.node) remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) continue adopted = find_adoptable_remote(client, args.node, path, current_hash, remote_files, {value.get('file_id') for value in known.values() if isinstance(value, dict)}) if adopted: adopted_id = adopted['file_id'] current_remote_name = join_remote_path(adopted.get('file_name') or '', adopted.get('file_path') or '', adopted_id) if current_remote_name != rel: update_remote(client, args.node, adopted_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '') print(f'move remote: {current_remote_name} -> {rel}') known[rel] = { 'file_id': adopted_id, 'sha256': current_hash, 'local_mtime': path.stat().st_mtime, 'file_name': remote_file_name, 'file_path': remote_file_path, } remote_files = list_remote(client, args.node) remote_ids = remote_by_id(remote_files) remote_names = remote_by_name(remote_files) 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, remote_file_name, args.note or '', args.expires or '', remote_file_path) 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': remote_file_name, 'file_path': remote_file_path, } 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 = join_remote_path(remote.get('file_name') or '', remote.get('file_path') or '', file_id) if is_ignored(name, ignore_patterns): continue path = unique_path(root / name) print(f'download: {file_id} -> {relative(root, path)}') download(client, args.node, file_id, path) remote_file_name, remote_file_path = split_remote_path(name) known[relative(root, path)] = { 'file_id': file_id, 'sha256': sha256(path), 'local_mtime': path.stat().st_mtime, 'file_name': remote_file_name, 'file_path': remote_file_path, } if args.delete: for rel, entry in list(known.items()): if (root / rel).is_file(): 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