diff --git a/cli/nanoshare_client/cli.py b/cli/nanoshare_client/cli.py index d1987b6..d3f21ca 100644 --- a/cli/nanoshare_client/cli.py +++ b/cli/nanoshare_client/cli.py @@ -163,7 +163,7 @@ def main(argv: list[str] | None = None) -> int: sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.') _add_common(sync_cmd) 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('--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.') @@ -172,7 +172,7 @@ def main(argv: list[str] | None = None) -> int: watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.') _add_common(watch_cmd) 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('--delete', action='store_true') watch_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.') diff --git a/cli/nanoshare_client/client.py b/cli/nanoshare_client/client.py index 539523f..81fe887 100644 --- a/cli/nanoshare_client/client.py +++ b/cli/nanoshare_client/client.py @@ -25,13 +25,14 @@ def upload(client, node: str, path: Path, remote_name: str, note: str, 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: +def update_remote(client, node: str, file_id: str, file_name: str, file_path: str, note: str | None, expires: str) -> None: params = { 'file_id': file_id, 'file_name': file_name, 'file_path': file_path, - 'note': note, } + if note is not None: + params['note'] = note if expires: params['expires'] = expires client.call(node, 'files.update', params) diff --git a/cli/nanoshare_client/sync.py b/cli/nanoshare_client/sync.py index 20db559..ee186c6 100644 --- a/cli/nanoshare_client/sync.py +++ b/cli/nanoshare_client/sync.py @@ -112,6 +112,15 @@ def parse_size(value: object) -> int | None: 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 @@ -152,10 +161,14 @@ def sync_once(args, client) -> int: 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, ignore_patterns)} - for rel, path in local_paths.items(): - current_hash = sha256(path) + 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 @@ -165,12 +178,31 @@ def sync_once(args, client) -> int: 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 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}') known[rel] = { 'file_id': adopted_id, @@ -241,7 +273,7 @@ def sync_once(args, client) -> int: if args.delete: for rel, entry in list(known.items()): - if rel in local_paths: + 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: diff --git a/cli/pyproject.toml b/cli/pyproject.toml index a5b6df2..7e5a391 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanoshare-cli" -version = "0.2.0" +version = "0.3.0" description = "NanoShare desktop CLI and folder sync client" readme = "README.md" requires-python = ">=3.11" diff --git a/my_modules/db/ConvexDB.py b/my_modules/db/ConvexDB.py index 533c6d4..1e6416a 100644 --- a/my_modules/db/ConvexDB.py +++ b/my_modules/db/ConvexDB.py @@ -52,14 +52,17 @@ class ConvexDB(ConvexDbBase): ) 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 = { 'file_id': file_id, 'file_name': file_name, 'file_path': file_path, - 'note': note, 'user_id': user_id } + if note is not None: + args['note'] = note + elif not preserve_missing: + args['note'] = '' if expires_at: args['expires_at'] = expires_at.isoformat() diff --git a/pyproject.toml b/pyproject.toml index 24227a4..7dbca88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanoshare" -version = "1.23.0" +version = "1.24.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" diff --git a/routes/api/link.py b/routes/api/link.py index 1a1b054..30696c9 100644 --- a/routes/api/link.py +++ b/routes/api/link.py @@ -104,9 +104,10 @@ async def files_update(params, ctx): file_id=file_id, file_name=file_name, 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', ''))), user_id=_user_id(ctx), + preserve_missing=True, ) return {'updated': True} diff --git a/tests/test_nanoshare_cli.py b/tests/test_nanoshare_cli.py index fd11c33..b4546d9 100644 --- a/tests/test_nanoshare_cli.py +++ b/tests/test_nanoshare_cli.py @@ -37,6 +37,8 @@ class FakeClient: if item['file_id'] == params['file_id']: item['file_name'] = params['file_name'] item['file_path'] = params.get('file_path', '') + if 'note' in params: + item['note'] = params['note'] return {'updated': True} return {'updated': False} 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()) 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): fake = FakeClient() fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}] diff --git a/uv.lock b/uv.lock index 0de9c29..a163482 100644 --- a/uv.lock +++ b/uv.lock @@ -720,7 +720,7 @@ wheels = [ [[package]] name = "nanoshare" -version = "1.23.0" +version = "1.24.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" },