feat(cli): sync folders with remote paths
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
- Store synced folder locations in file_path instead of embedding them in file names. - Add ignore rules from .nanoshareignore and repeatable sync --ignore flags. - Adopt existing remote files only after SHA-256 verification. - Move adopted remotes with metadata updates instead of uploading duplicates. - Bump NanoShare to 1.23.0 and the standalone CLI to 0.2.0.
This commit is contained in:
@@ -11,6 +11,7 @@ from .auth import login as auth_login
|
||||
from .client import download, list_remote, upload
|
||||
from .completion import script as completion_script
|
||||
from .config import DEFAULT_CONFIG
|
||||
from .remote_path import join_remote_path
|
||||
from .sync import sync_once
|
||||
from .table import format_table
|
||||
|
||||
@@ -48,24 +49,29 @@ def _cmd_list(args) -> int:
|
||||
print(json.dumps(files, indent=2, ensure_ascii=False))
|
||||
elif args.format == 'tsv':
|
||||
for item in files:
|
||||
print(f"{item.get('file_id', '')}\t{item.get('file_name', '')}\t{item.get('file_size', '')}")
|
||||
print(f"{item.get('file_id', '')}\t{item.get('file_path', '')}\t{item.get('file_name', '')}\t{item.get('file_size', '')}")
|
||||
else:
|
||||
rows = [
|
||||
[item.get('file_id'), item.get('file_name'), item.get('file_size', '')]
|
||||
[item.get('file_id'), item.get('file_path', ''), item.get('file_name'), item.get('file_size', '')]
|
||||
for item in files
|
||||
]
|
||||
print(format_table(['ID', 'Name', 'Size'], rows))
|
||||
print(format_table(['ID', 'Path', 'Name', 'Size'], rows))
|
||||
return 0
|
||||
|
||||
def _resolve_remote_file(files: list[dict], selector: str) -> dict:
|
||||
id_matches = [item for item in files if item.get('file_id') == selector]
|
||||
if id_matches:
|
||||
return id_matches[0]
|
||||
full_path_matches = [item for item in files if join_remote_path(str(item.get('file_name') or ''), str(item.get('file_path') or ''), str(item.get('file_id') or '')) == selector]
|
||||
if full_path_matches:
|
||||
if len(full_path_matches) > 1:
|
||||
raise RuntimeError(f'multiple remote files at {selector!r}; use the file ID')
|
||||
return full_path_matches[0]
|
||||
name_matches = [item for item in files if item.get('file_name') == selector]
|
||||
if not name_matches:
|
||||
raise FileNotFoundError(f'no remote file matches: {selector}')
|
||||
if len(name_matches) > 1:
|
||||
raise RuntimeError(f'multiple remote files named {selector!r}; use the file ID')
|
||||
raise RuntimeError(f'multiple remote files named {selector!r}; use the file ID or full path')
|
||||
return name_matches[0]
|
||||
|
||||
def _cmd_download(args) -> int:
|
||||
@@ -74,6 +80,7 @@ def _cmd_download(args) -> int:
|
||||
remote = _resolve_remote_file(list_remote(client, args.node), args.file)
|
||||
file_id = str(remote['file_id'])
|
||||
file_name = str(remote.get('file_name') or file_id)
|
||||
remote_name = join_remote_path(file_name, str(remote.get('file_path') or ''), file_id)
|
||||
dest = Path(args.output).expanduser() if args.output else Path(file_name)
|
||||
download(client, args.node, file_id, dest)
|
||||
except Exception as exc:
|
||||
@@ -81,7 +88,7 @@ def _cmd_download(args) -> int:
|
||||
return 1
|
||||
finally:
|
||||
client.close()
|
||||
print(f'downloaded {file_name} ({file_id}) -> {dest}')
|
||||
print(f'downloaded {remote_name} ({file_id}) -> {dest}')
|
||||
return 0
|
||||
|
||||
def _cmd_sync(args) -> int:
|
||||
@@ -159,6 +166,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sync_cmd.add_argument('--note', default='synced from nanoshare cli')
|
||||
sync_cmd.add_argument('--expires', default='')
|
||||
sync_cmd.add_argument('--delete', action='store_true', help='Delete remote files that were deleted locally.')
|
||||
sync_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||
sync_cmd.set_defaults(func=_cmd_sync)
|
||||
|
||||
watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.')
|
||||
@@ -167,6 +175,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
watch_cmd.add_argument('--note', default='synced from nanoshare cli')
|
||||
watch_cmd.add_argument('--expires', default='')
|
||||
watch_cmd.add_argument('--delete', action='store_true')
|
||||
watch_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||
watch_cmd.add_argument('--interval', type=float, default=10.0)
|
||||
watch_cmd.set_defaults(func=_cmd_watch)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import httpx
|
||||
def content_type(path: Path) -> str:
|
||||
return mimetypes.guess_type(path.name)[0] or 'application/octet-stream'
|
||||
|
||||
def upload(client, node: str, path: Path, remote_name: str, note: str, expires: str) -> dict:
|
||||
def upload(client, node: str, path: Path, remote_name: str, note: str, expires: str, remote_path: str = '') -> dict:
|
||||
content_b64 = base64.b64encode(path.read_bytes()).decode('ascii')
|
||||
params = {
|
||||
'file_name': remote_name,
|
||||
@@ -18,11 +18,24 @@ def upload(client, node: str, path: Path, remote_name: str, note: str, expires:
|
||||
'content_type': content_type(path),
|
||||
'note': note,
|
||||
}
|
||||
if remote_path:
|
||||
params['file_path'] = remote_path
|
||||
if expires:
|
||||
params['expires'] = expires
|
||||
result = client.call(node, 'files.upload', params)
|
||||
return result if isinstance(result, dict) else {'result': result}
|
||||
|
||||
def update_remote(client, node: str, file_id: str, file_name: str, file_path: str, note: str, expires: str) -> None:
|
||||
params = {
|
||||
'file_id': file_id,
|
||||
'file_name': file_name,
|
||||
'file_path': file_path,
|
||||
'note': note,
|
||||
}
|
||||
if expires:
|
||||
params['expires'] = expires
|
||||
client.call(node, 'files.update', params)
|
||||
|
||||
def delete_remote(client, node: str, file_id: str) -> None:
|
||||
client.call(node, 'files.delete', {'file_id': file_id})
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
_ZSH = r'''# nanoshare zsh completion -- add to ~/.zshrc: eval "$(nanoshare completion zsh)"
|
||||
_nanoshare_files() {
|
||||
local id name size
|
||||
while IFS=$'\t' read -r id name size; do
|
||||
[[ -n $id ]] && printf '%s:%s\n' "$id" "$name"
|
||||
local id path name size label
|
||||
while IFS=$'\t' read -r id path name size; do
|
||||
label="${path:+$path/}$name"
|
||||
[[ -n $id ]] && printf '%s:%s\n' "$id" "$label"
|
||||
done < <(nanoshare list --format tsv 2>/dev/null)
|
||||
}
|
||||
|
||||
@@ -13,7 +14,7 @@ _nanoshare() {
|
||||
commands=(login upload list download sync watch completion)
|
||||
common_opts=(--config --url --token --refresh-token --token-url --source --timeout --node)
|
||||
login_opts=(--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser)
|
||||
sync_opts=($common_opts --note --expires --delete)
|
||||
sync_opts=($common_opts --note --expires --delete --ignore)
|
||||
watch_opts=($sync_opts --interval)
|
||||
download_opts=($common_opts --output -o)
|
||||
|
||||
@@ -65,7 +66,7 @@ _nanoshare() {
|
||||
local commands="login upload list download sync watch completion"
|
||||
local common_opts="--config --url --token --refresh-token --token-url --source --timeout --node"
|
||||
local login_opts="--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser"
|
||||
local sync_opts="$common_opts --note --expires --delete"
|
||||
local sync_opts="$common_opts --note --expires --delete --ignore"
|
||||
local watch_opts="$sync_opts --interval"
|
||||
local download_opts="$common_opts --output -o"
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
IGNORE_FILE_NAME = '.nanoshareignore'
|
||||
|
||||
def load_ignore_patterns(root: Path, extra_patterns: list[str] | None = None) -> list[str]:
|
||||
patterns = [IGNORE_FILE_NAME]
|
||||
ignore_file = root / IGNORE_FILE_NAME
|
||||
if ignore_file.is_file():
|
||||
for line in ignore_file.read_text().splitlines():
|
||||
pattern = line.strip()
|
||||
if pattern and not pattern.startswith('#'):
|
||||
patterns.append(pattern)
|
||||
patterns.extend(pattern for pattern in extra_patterns or [] if pattern)
|
||||
return patterns
|
||||
|
||||
def is_ignored(rel_path: str, patterns: list[str]) -> bool:
|
||||
path = rel_path.strip('/')
|
||||
parts = path.split('/')
|
||||
for pattern in patterns:
|
||||
normalized = pattern.strip().replace('\\', '/').strip('/')
|
||||
if not normalized:
|
||||
continue
|
||||
if pattern.endswith('/'):
|
||||
if path == normalized or path.startswith(f'{normalized}/'):
|
||||
return True
|
||||
continue
|
||||
if '/' in normalized:
|
||||
if fnmatch(path, normalized):
|
||||
return True
|
||||
elif any(fnmatch(part, normalized) for part in parts):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
def split_remote_path(value: str, file_id: str = '') -> tuple[str, str]:
|
||||
raw = (value or file_id).replace('\\', '/')
|
||||
parts = [
|
||||
part for part in PurePosixPath(raw).parts
|
||||
if part not in ('', '.', '..', '/')
|
||||
]
|
||||
if not parts:
|
||||
return file_id, ''
|
||||
return parts[-1], '/'.join(parts[:-1])
|
||||
|
||||
def join_remote_path(file_name: str, file_path: str | None = None, file_id: str = '') -> str:
|
||||
name, embedded_path = split_remote_path(file_name, file_id)
|
||||
clean_path = split_remote_path(f'{file_path or ""}/placeholder')[1] if file_path else embedded_path
|
||||
return f'{clean_path}/{name}' if clean_path else name
|
||||
@@ -5,10 +5,13 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .client import delete_remote, download, list_remote, upload
|
||||
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'
|
||||
@@ -44,21 +47,23 @@ def save_state(root: Path, state: dict) -> None:
|
||||
tmp.write_text(json.dumps(state, indent=2, sort_keys=True))
|
||||
os.replace(tmp, path)
|
||||
|
||||
def iter_local_files(root: 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:
|
||||
candidate = Path(name or file_id).name
|
||||
return candidate or file_id
|
||||
return join_remote_path(name, file_id=file_id)
|
||||
|
||||
def unique_path(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
@@ -80,19 +85,74 @@ 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)
|
||||
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_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)
|
||||
local_paths = {relative(root, path): path for path in iter_local_files(root)}
|
||||
local_paths = {relative(root, path): path for path in iter_local_files(root, ignore_patterns)}
|
||||
|
||||
for rel, path in local_paths.items():
|
||||
current_hash = sha256(path)
|
||||
@@ -103,13 +163,34 @@ def sync_once(args, client) -> int:
|
||||
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:
|
||||
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 or '', 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, rel, args.note or '', args.expires or '')
|
||||
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)
|
||||
@@ -134,7 +215,8 @@ def sync_once(args, client) -> int:
|
||||
'file_id': new_file_id,
|
||||
'sha256': current_hash,
|
||||
'local_mtime': path.stat().st_mtime,
|
||||
'file_name': rel,
|
||||
'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)}
|
||||
@@ -142,15 +224,19 @@ def sync_once(args, client) -> int:
|
||||
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)
|
||||
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': relative(root, path),
|
||||
'file_name': remote_file_name,
|
||||
'file_path': remote_file_path,
|
||||
}
|
||||
|
||||
if args.delete:
|
||||
|
||||
Reference in New Issue
Block a user