feat(cli): add private NanoShare sync client
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
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.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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
|
||||
Reference in New Issue
Block a user