42064de633
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
- Add a separate installable NanoShare CLI package under cli/. - Implement browser login with local callback and refresh-token config. - Add upload, list, download, sync, and watch CLI commands. - Add private owner-only file download endpoint for CLI downloads. - Add CLI auth endpoints for browser login and token refresh. - Return file_id from ServiceLink uploads for reliable sync state. - Exclude the CLI package from NanoShare container builds. - Include tests for private downloads and sync state updates.
171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
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
|