fix(cli): preserve notes during sync moves
Build and Push Docker Container / build-and-push (push) Successful in 1m52s
Build and Push Docker Container / build-and-push (push) Successful in 1m52s
- Leave sync and watch notes empty by default instead of writing placeholder text. - Preserve existing remote notes when moving or adopting files without --note. - Detect tracked local moves by SHA-256 and update remote metadata safely. - Skip files that vanish during watch scans instead of aborting the sync cycle. - Bump NanoShare to 1.24.0 and the standalone CLI to 0.3.0.
This commit is contained in:
@@ -163,7 +163,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.')
|
sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.')
|
||||||
_add_common(sync_cmd)
|
_add_common(sync_cmd)
|
||||||
sync_cmd.add_argument('folder')
|
sync_cmd.add_argument('folder')
|
||||||
sync_cmd.add_argument('--note', default='synced from nanoshare cli')
|
sync_cmd.add_argument('--note', default='')
|
||||||
sync_cmd.add_argument('--expires', default='')
|
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('--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.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||||
@@ -172,7 +172,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.')
|
watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.')
|
||||||
_add_common(watch_cmd)
|
_add_common(watch_cmd)
|
||||||
watch_cmd.add_argument('folder')
|
watch_cmd.add_argument('folder')
|
||||||
watch_cmd.add_argument('--note', default='synced from nanoshare cli')
|
watch_cmd.add_argument('--note', default='')
|
||||||
watch_cmd.add_argument('--expires', default='')
|
watch_cmd.add_argument('--expires', default='')
|
||||||
watch_cmd.add_argument('--delete', action='store_true')
|
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('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ def upload(client, node: str, path: Path, remote_name: str, note: str, expires:
|
|||||||
result = client.call(node, 'files.upload', params)
|
result = client.call(node, 'files.upload', params)
|
||||||
return result if isinstance(result, dict) else {'result': result}
|
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:
|
def update_remote(client, node: str, file_id: str, file_name: str, file_path: str, note: str | None, expires: str) -> None:
|
||||||
params = {
|
params = {
|
||||||
'file_id': file_id,
|
'file_id': file_id,
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
'file_path': file_path,
|
'file_path': file_path,
|
||||||
'note': note,
|
|
||||||
}
|
}
|
||||||
|
if note is not None:
|
||||||
|
params['note'] = note
|
||||||
if expires:
|
if expires:
|
||||||
params['expires'] = expires
|
params['expires'] = expires
|
||||||
client.call(node, 'files.update', params)
|
client.call(node, 'files.update', params)
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ def parse_size(value: object) -> int | None:
|
|||||||
factor = factors.get(unit)
|
factor = factors.get(unit)
|
||||||
return int(number * factor) if factor else None
|
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:
|
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
|
size = path.stat().st_size
|
||||||
name = path.name
|
name = path.name
|
||||||
@@ -152,10 +161,14 @@ def sync_once(args, client) -> int:
|
|||||||
remote_files = list_remote(client, args.node)
|
remote_files = list_remote(client, args.node)
|
||||||
remote_ids = remote_by_id(remote_files)
|
remote_ids = remote_by_id(remote_files)
|
||||||
remote_names = remote_by_name(remote_files)
|
remote_names = remote_by_name(remote_files)
|
||||||
local_paths = {relative(root, path): path for path in iter_local_files(root, ignore_patterns)}
|
|
||||||
|
|
||||||
for rel, path in local_paths.items():
|
for path in iter_local_files(root, ignore_patterns):
|
||||||
|
rel = relative(root, path)
|
||||||
|
try:
|
||||||
current_hash = sha256(path)
|
current_hash = sha256(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f'skip vanished local file: {rel}')
|
||||||
|
continue
|
||||||
entry = known.get(rel)
|
entry = known.get(rel)
|
||||||
remote_id = entry.get('file_id') if isinstance(entry, dict) else None
|
remote_id = entry.get('file_id') if isinstance(entry, dict) else None
|
||||||
old_hash = entry.get('sha256') if isinstance(entry, dict) else None
|
old_hash = entry.get('sha256') if isinstance(entry, dict) else None
|
||||||
@@ -165,12 +178,31 @@ def sync_once(args, client) -> int:
|
|||||||
|
|
||||||
remote_file_name, remote_file_path = split_remote_path(rel)
|
remote_file_name, remote_file_path = split_remote_path(rel)
|
||||||
if not entry:
|
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)})
|
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:
|
if adopted:
|
||||||
adopted_id = adopted['file_id']
|
adopted_id = adopted['file_id']
|
||||||
current_remote_name = join_remote_path(adopted.get('file_name') or '', adopted.get('file_path') or '', adopted_id)
|
current_remote_name = join_remote_path(adopted.get('file_name') or '', adopted.get('file_path') or '', adopted_id)
|
||||||
if current_remote_name != rel:
|
if current_remote_name != rel:
|
||||||
update_remote(client, args.node, adopted_id, remote_file_name, remote_file_path, args.note or '', args.expires or '')
|
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}')
|
print(f'move remote: {current_remote_name} -> {rel}')
|
||||||
known[rel] = {
|
known[rel] = {
|
||||||
'file_id': adopted_id,
|
'file_id': adopted_id,
|
||||||
@@ -241,7 +273,7 @@ def sync_once(args, client) -> int:
|
|||||||
|
|
||||||
if args.delete:
|
if args.delete:
|
||||||
for rel, entry in list(known.items()):
|
for rel, entry in list(known.items()):
|
||||||
if rel in local_paths:
|
if (root / rel).is_file():
|
||||||
continue
|
continue
|
||||||
file_id = entry.get('file_id') if isinstance(entry, dict) else None
|
file_id = entry.get('file_id') if isinstance(entry, dict) else None
|
||||||
if file_id and file_id in remote_ids:
|
if file_id and file_id in remote_ids:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanoshare-cli"
|
name = "nanoshare-cli"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "NanoShare desktop CLI and folder sync client"
|
description = "NanoShare desktop CLI and folder sync client"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -52,14 +52,17 @@ class ConvexDB(ConvexDbBase):
|
|||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str, file_path:str=''):
|
async def update_file(self, file_id:str, file_name:str, note:str|None, expires_at:datetime|None, user_id:str, file_path:str='', preserve_missing:bool=False):
|
||||||
args = {
|
args = {
|
||||||
'file_id': file_id,
|
'file_id': file_id,
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
'file_path': file_path,
|
'file_path': file_path,
|
||||||
'note': note,
|
|
||||||
'user_id': user_id
|
'user_id': user_id
|
||||||
}
|
}
|
||||||
|
if note is not None:
|
||||||
|
args['note'] = note
|
||||||
|
elif not preserve_missing:
|
||||||
|
args['note'] = ''
|
||||||
if expires_at:
|
if expires_at:
|
||||||
args['expires_at'] = expires_at.isoformat()
|
args['expires_at'] = expires_at.isoformat()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanoshare"
|
name = "nanoshare"
|
||||||
version = "1.23.0"
|
version = "1.24.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+2
-1
@@ -104,9 +104,10 @@ async def files_update(params, ctx):
|
|||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
file_name=file_name,
|
file_name=file_name,
|
||||||
file_path=_safe_file_path(str(params.get('file_path') or '')),
|
file_path=_safe_file_path(str(params.get('file_path') or '')),
|
||||||
note=params.get('note', ''),
|
note=params.get('note') if 'note' in params else None,
|
||||||
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
||||||
user_id=_user_id(ctx),
|
user_id=_user_id(ctx),
|
||||||
|
preserve_missing=True,
|
||||||
)
|
)
|
||||||
return {'updated': True}
|
return {'updated': True}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class FakeClient:
|
|||||||
if item['file_id'] == params['file_id']:
|
if item['file_id'] == params['file_id']:
|
||||||
item['file_name'] = params['file_name']
|
item['file_name'] = params['file_name']
|
||||||
item['file_path'] = params.get('file_path', '')
|
item['file_path'] = params.get('file_path', '')
|
||||||
|
if 'note' in params:
|
||||||
|
item['note'] = params['note']
|
||||||
return {'updated': True}
|
return {'updated': True}
|
||||||
return {'updated': False}
|
return {'updated': False}
|
||||||
if method == 'files.delete':
|
if method == 'files.delete':
|
||||||
@@ -91,6 +93,47 @@ def test_sync_uploads_nested_local_files(tmp_path, monkeypatch):
|
|||||||
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||||
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_sync_updates_remote_path_when_tracked_file_moves(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'old.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
|
||||||
|
state_dir = tmp_path / '.nanoshare-sync'
|
||||||
|
state_dir.mkdir()
|
||||||
|
(state_dir / 'state.json').write_text(json.dumps({
|
||||||
|
'version': 1,
|
||||||
|
'files': {
|
||||||
|
'old.txt': {
|
||||||
|
'file_id': 'file_1',
|
||||||
|
'sha256': '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
||||||
|
'local_mtime': 1,
|
||||||
|
'file_name': 'old.txt',
|
||||||
|
'file_path': '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
nested = tmp_path / 'docs' / 'new.txt'
|
||||||
|
nested.parent.mkdir(parents=True)
|
||||||
|
nested.write_text('hello')
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
'--delete',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert fake.uploads == []
|
||||||
|
assert fake.deleted == []
|
||||||
|
assert fake.remote_files[0]['file_name'] == 'new.txt'
|
||||||
|
assert fake.remote_files[0]['file_path'] == 'docs'
|
||||||
|
assert 'note' not in fake.remote_files[0]
|
||||||
|
state = json.loads((state_dir / 'state.json').read_text())
|
||||||
|
assert 'old.txt' not in state['files']
|
||||||
|
assert state['files']['docs/new.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
def test_sync_adopts_and_moves_existing_remote_file(tmp_path, monkeypatch):
|
def test_sync_adopts_and_moves_existing_remote_file(tmp_path, monkeypatch):
|
||||||
fake = FakeClient()
|
fake = FakeClient()
|
||||||
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||||
|
|||||||
Reference in New Issue
Block a user