diff --git a/src/claudesync/cli/sync.py b/src/claudesync/cli/sync.py index 770b592..70cfb83 100644 --- a/src/claudesync/cli/sync.py +++ b/src/claudesync/cli/sync.py @@ -1,14 +1,12 @@ import os import shutil import sys -import time - import click from crontab import CronTab -from claudesync.utils import compute_md5_hash, get_local_files +from claudesync.utils import get_local_files from ..utils import handle_errors, validate_and_get_provider -from datetime import datetime, timezone +from ..syncmanager import SyncManager @click.command() @@ -38,120 +36,32 @@ def ls(config): def sync(config): """Synchronize local files with the active remote project.""" provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - active_project_id = config.get("active_project_id") local_path = config.get("local_path") - upload_delay = config.get("upload_delay", 0.5) - two_way_sync = config.get("two_way_sync", False) + validate_local_path(local_path) + + sync_manager = SyncManager(provider, config) + remote_files = provider.list_files( + sync_manager.active_organization_id, sync_manager.active_project_id + ) + local_files = get_local_files(local_path) + + sync_manager.sync(local_files, remote_files) + + click.echo("Sync completed successfully.") + + +def validate_local_path(local_path): if not local_path: click.echo( "No local path set. Please select or create a project to set the local path." ) sys.exit(1) - if not os.path.exists(local_path): click.echo(f"The configured local path does not exist: {local_path}") click.echo("Please update the local path by selecting or creating a project.") sys.exit(1) - remote_files = provider.list_files(active_organization_id, active_project_id) - local_files = get_local_files(local_path) - - # Track remote files to delete - remote_files_to_delete = set(rf["file_name"] for rf in remote_files) - - # Track synced files - synced_files = set() - - for local_file, local_checksum in local_files.items(): - remote_file = next( - (rf for rf in remote_files if rf["file_name"] == local_file), None - ) - if remote_file: - remote_checksum = compute_md5_hash(remote_file["content"]) - if local_checksum != remote_checksum: - click.echo(f"Updating {local_file} on remote...") - provider.delete_file( - active_organization_id, active_project_id, remote_file["uuid"] - ) - with open( - os.path.join(local_path, local_file), "r", encoding="utf-8" - ) as file: - content = file.read() - provider.upload_file( - active_organization_id, active_project_id, local_file, content - ) - time.sleep(upload_delay) # Add delay after upload - synced_files.add(local_file) - remote_files_to_delete.remove(local_file) - else: - click.echo(f"Uploading new file {local_file} to remote...") - with open( - os.path.join(local_path, local_file), "r", encoding="utf-8" - ) as file: - content = file.read() - provider.upload_file( - active_organization_id, active_project_id, local_file, content - ) - time.sleep(upload_delay) # Add delay after upload - synced_files.add(local_file) - - # Update local file timestamps only for synced files - remote_files = provider.list_files(active_organization_id, active_project_id) - for remote_file in remote_files: - if remote_file["file_name"] in synced_files: - local_file_path = os.path.join(local_path, remote_file["file_name"]) - if os.path.exists(local_file_path): - remote_timestamp = datetime.fromisoformat( - remote_file["created_at"].replace("Z", "+00:00") - ).timestamp() - os.utime(local_file_path, (remote_timestamp, remote_timestamp)) - click.echo(f"Updated timestamp on local file {local_file_path}") - - # Two-way sync: update local files if remote is newer - if two_way_sync: - for remote_file in remote_files: - local_file_path = os.path.join(local_path, remote_file["file_name"]) - if os.path.exists(local_file_path): - local_mtime = datetime.fromtimestamp( - os.path.getmtime(local_file_path), tz=timezone.utc - ) - remote_mtime = datetime.fromisoformat( - remote_file["created_at"].replace("Z", "+00:00") - ) - if remote_mtime > local_mtime: - click.echo( - f"Updating local file {remote_file['file_name']} from remote..." - ) - with open(local_file_path, "w", encoding="utf-8") as file: - file.write(remote_file["content"]) - synced_files.add(remote_file["file_name"]) - if remote_file["file_name"] in remote_files_to_delete: - remote_files_to_delete.remove(remote_file["file_name"]) - else: - click.echo( - f"Creating new local file {remote_file['file_name']} from remote..." - ) - with open(local_file_path, "w", encoding="utf-8") as file: - file.write(remote_file["content"]) - synced_files.add(remote_file["file_name"]) - if remote_file["file_name"] in remote_files_to_delete: - remote_files_to_delete.remove(remote_file["file_name"]) - - # Delete remote files that no longer exist locally - for file_to_delete in remote_files_to_delete: - click.echo(f"Deleting {file_to_delete} from remote...") - remote_file = next( - rf for rf in remote_files if rf["file_name"] == file_to_delete - ) - provider.delete_file( - active_organization_id, active_project_id, remote_file["uuid"] - ) - time.sleep(upload_delay) # Add delay after deletion - - click.echo("Sync completed successfully.") - @click.command() @click.pass_obj @@ -169,20 +79,24 @@ def schedule(config, interval): sys.exit(1) if sys.platform.startswith("win"): - click.echo("Windows Task Scheduler setup:") - command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' - click.echo(f"Run this command to create the task:\n{command}") - click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') + setup_windows_task(claudesync_path, interval) else: - # Unix-like systems (Linux, macOS) - cron = CronTab(user=True) - job = cron.new(command=f"{claudesync_path} sync") - job.minute.every(interval) + setup_unix_cron(claudesync_path, interval) - cron.write() - click.echo( - f"Cron job created successfully! It will run every {interval} minutes." - ) - click.echo( - "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" - ) + +def setup_windows_task(claudesync_path, interval): + click.echo("Windows Task Scheduler setup:") + command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' + click.echo(f"Run this command to create the task:\n{command}") + click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') + + +def setup_unix_cron(claudesync_path, interval): + cron = CronTab(user=True) + job = cron.new(command=f"{claudesync_path} sync") + job.minute.every(interval) + cron.write() + click.echo(f"Cron job created successfully! It will run every {interval} minutes.") + click.echo( + "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" + ) diff --git a/src/claudesync/syncmanager.py b/src/claudesync/syncmanager.py new file mode 100644 index 0000000..067488d --- /dev/null +++ b/src/claudesync/syncmanager.py @@ -0,0 +1,270 @@ +import os +import time +from datetime import datetime, timezone + +import click + +from claudesync.utils import compute_md5_hash + + +class SyncManager: + def __init__(self, provider, config): + """ + Initialize the SyncManager with the given provider and configuration. + + Args: + provider (Provider): The provider instance to interact with the remote storage. + config (dict): Configuration dictionary containing sync settings such as: + - active_organization_id (str): ID of the active organization. + - active_project_id (str): ID of the active project. + - local_path (str): Path to the local directory to be synchronized. + - upload_delay (float, optional): Delay between upload operations in seconds. Defaults to 0.5. + - two_way_sync (bool, optional): Flag to enable two-way synchronization. Defaults to False. + """ + self.provider = provider + self.config = config + self.active_organization_id = config.get("active_organization_id") + self.active_project_id = config.get("active_project_id") + self.local_path = config.get("local_path") + self.upload_delay = config.get("upload_delay", 0.5) + self.two_way_sync = config.get("two_way_sync", False) + + def sync(self, local_files, remote_files): + """ + Main synchronization method that orchestrates the sync process. + + This method manages the synchronization between local and remote files. It handles the + synchronization from local to remote, updates local timestamps, performs two-way sync if enabled, + and deletes remote files that are no longer present locally. + + Args: + local_files (dict): Dictionary of local file names and their corresponding checksums. + remote_files (list): List of dictionaries representing remote files, each containing: + - "file_name" (str): Name of the file. + - "content" (str): Content of the file. + - "created_at" (str): Timestamp when the file was created in ISO format. + - "uuid" (str): Unique identifier of the remote file. + """ + remote_files_to_delete = set(rf["file_name"] for rf in remote_files) + synced_files = set() + + self.sync_local_to_remote( + local_files, remote_files, remote_files_to_delete, synced_files + ) + self.update_local_timestamps(remote_files, synced_files) + + if self.two_way_sync: + self.sync_remote_to_local( + remote_files, remote_files_to_delete, synced_files + ) + + self.delete_remote_files(remote_files_to_delete, remote_files) + + def sync_local_to_remote( + self, local_files, remote_files, remote_files_to_delete, synced_files + ): + """ + Synchronize local files to the remote project. + + This method checks each local file against the remote files. If a file exists on the remote, + it updates the file if there are changes. If the file does not exist on the remote, it uploads + the new file. + + Args: + local_files (dict): Dictionary of local file names and their corresponding checksums. + remote_files (list): List of dictionaries representing remote files. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + for local_file, local_checksum in local_files.items(): + remote_file = next( + (rf for rf in remote_files if rf["file_name"] == local_file), None + ) + if remote_file: + self.update_existing_file( + local_file, + local_checksum, + remote_file, + remote_files_to_delete, + synced_files, + ) + else: + self.upload_new_file(local_file, synced_files) + + def update_existing_file( + self, + local_file, + local_checksum, + remote_file, + remote_files_to_delete, + synced_files, + ): + """ + Update an existing file on the remote if it has changed locally. + + This method compares the local and remote file checksums. If they differ, it deletes the old remote file + and uploads the new version from the local file. + + Args: + local_file (str): Name of the local file. + local_checksum (str): MD5 checksum of the local file content. + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + remote_checksum = compute_md5_hash(remote_file["content"]) + if local_checksum != remote_checksum: + click.echo(f"Updating {local_file} on remote...") + self.provider.delete_file( + self.active_organization_id, self.active_project_id, remote_file["uuid"] + ) + with open( + os.path.join(self.local_path, local_file), "r", encoding="utf-8" + ) as file: + content = file.read() + self.provider.upload_file( + self.active_organization_id, self.active_project_id, local_file, content + ) + time.sleep(self.upload_delay) + synced_files.add(local_file) + remote_files_to_delete.remove(local_file) + + def upload_new_file(self, local_file, synced_files): + """ + Upload a new file to the remote project. + + This method reads the content of the local file and uploads it to the remote project. + + Args: + local_file (str): Name of the local file to be uploaded. + synced_files (set): Set of file names that have been synchronized. + """ + click.echo(f"Uploading new file {local_file} to remote...") + with open( + os.path.join(self.local_path, local_file), "r", encoding="utf-8" + ) as file: + content = file.read() + self.provider.upload_file( + self.active_organization_id, self.active_project_id, local_file, content + ) + time.sleep(self.upload_delay) + synced_files.add(local_file) + + def update_local_timestamps(self, remote_files, synced_files): + """ + Update local file timestamps to match the remote timestamps. + + This method updates the modification timestamps of local files to match their corresponding + remote file timestamps if they have been synchronized. + + Args: + remote_files (list): List of dictionaries representing remote files. + synced_files (set): Set of file names that have been synchronized. + """ + for remote_file in remote_files: + if remote_file["file_name"] in synced_files: + local_file_path = os.path.join( + self.local_path, remote_file["file_name"] + ) + if os.path.exists(local_file_path): + remote_timestamp = datetime.fromisoformat( + remote_file["created_at"].replace("Z", "+00:00") + ).timestamp() + os.utime(local_file_path, (remote_timestamp, remote_timestamp)) + click.echo(f"Updated timestamp on local file {local_file_path}") + + def sync_remote_to_local(self, remote_files, remote_files_to_delete, synced_files): + """ + Synchronize remote files to the local project (two-way sync). + + This method checks each remote file against the local files. If a file exists locally, + it updates the file if the remote version is newer. If the file does not exist locally, + it creates a new local file from the remote file. + + Args: + remote_files (list): List of dictionaries representing remote files. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + for remote_file in remote_files: + local_file_path = os.path.join(self.local_path, remote_file["file_name"]) + if os.path.exists(local_file_path): + self.update_existing_local_file( + local_file_path, remote_file, remote_files_to_delete, synced_files + ) + else: + self.create_new_local_file( + local_file_path, remote_file, remote_files_to_delete, synced_files + ) + + def update_existing_local_file( + self, local_file_path, remote_file, remote_files_to_delete, synced_files + ): + """ + Update an existing local file if the remote version is newer. + + This method compares the local file's modification time with the remote file's creation time. + If the remote file is newer, it updates the local file with the remote content. + + Args: + local_file_path (str): Path to the local file. + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + local_mtime = datetime.fromtimestamp( + os.path.getmtime(local_file_path), tz=timezone.utc + ) + remote_mtime = datetime.fromisoformat( + remote_file["created_at"].replace("Z", "+00:00") + ) + if remote_mtime > local_mtime: + click.echo(f"Updating local file {remote_file['file_name']} from remote...") + with open(local_file_path, "w", encoding="utf-8") as file: + file.write(remote_file["content"]) + synced_files.add(remote_file["file_name"]) + if remote_file["file_name"] in remote_files_to_delete: + remote_files_to_delete.remove(remote_file["file_name"]) + + def create_new_local_file( + self, local_file_path, remote_file, remote_files_to_delete, synced_files + ): + """ + Create a new local file from a remote file. + + This method creates a new local file with the content from the remote file. + + Args: + local_file_path (str): Path to the new local file + + . + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + click.echo(f"Creating new local file {remote_file['file_name']} from remote...") + with open(local_file_path, "w", encoding="utf-8") as file: + file.write(remote_file["content"]) + synced_files.add(remote_file["file_name"]) + if remote_file["file_name"] in remote_files_to_delete: + remote_files_to_delete.remove(remote_file["file_name"]) + + def delete_remote_files(self, remote_files_to_delete, remote_files): + """ + Delete files from the remote project that no longer exist locally. + + This method deletes remote files that are not present in the local directory. + + Args: + remote_files_to_delete (set): Set of remote file names to be deleted. + remote_files (list): List of dictionaries representing remote files. + """ + for file_to_delete in remote_files_to_delete: + click.echo(f"Deleting {file_to_delete} from remote...") + remote_file = next( + rf for rf in remote_files if rf["file_name"] == file_to_delete + ) + self.provider.delete_file( + self.active_organization_id, self.active_project_id, remote_file["uuid"] + ) + time.sleep(self.upload_delay) diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py deleted file mode 100644 index 0dc2a92..0000000 --- a/tests/cli/test_sync.py +++ /dev/null @@ -1,147 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock -from click.testing import CliRunner -from claudesync.cli.main import cli - - -class TestSyncCLI(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - - @patch("claudesync.cli.sync.validate_and_get_provider") - def test_ls_command(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.list_files.return_value = [ - {"file_name": "file1.txt", "uuid": "uuid1", "created_at": "2023-01-01"}, - {"file_name": "file2.py", "uuid": "uuid2", "created_at": "2023-01-02"}, - ] - mock_validate_and_get_provider.return_value = mock_provider - - result = self.runner.invoke(cli, ["ls"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("file1.txt", result.output) - self.assertIn("file2.py", result.output) - mock_provider.list_files.assert_called_once() - - @patch("claudesync.cli.sync.validate_and_get_provider") - def test_ls_command_no_files(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.list_files.return_value = [] - mock_validate_and_get_provider.return_value = mock_provider - - result = self.runner.invoke(cli, ["ls"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("No files found in the active project.", result.output) - - @patch("claudesync.cli.sync.validate_and_get_provider") - @patch("claudesync.cli.sync.get_local_files") - @patch("claudesync.cli.sync.os.path.exists") - @patch( - "claudesync.cli.sync.open", - new_callable=unittest.mock.mock_open, - read_data="file content", - ) - @patch("claudesync.config_manager.ConfigManager") - def test_sync_command( - self, - mock_config_manager, - mock_open, - mock_path_exists, - mock_get_local_files, - mock_validate_and_get_provider, - ): - # Mock the ConfigManager - mock_config = MagicMock() - mock_config.get.side_effect = lambda key, default=None: { - "active_organization_id": "org1", - "active_project_id": "proj1", - "local_path": "/mock/local/path", - }.get(key, default) - mock_config_manager.return_value = mock_config - - mock_provider = MagicMock() - mock_provider.list_files.return_value = [ - {"file_name": "existing.txt", "uuid": "uuid1", "content": "old content"} - ] - mock_validate_and_get_provider.return_value = mock_provider - - mock_get_local_files.return_value = { - "existing.txt": "new_checksum", - "new_file.txt": "new_file_checksum", - } - - mock_path_exists.return_value = True - - with patch("claudesync.cli.main.ConfigManager", return_value=mock_config): - result = self.runner.invoke(cli, ["sync"]) - - self.assertEqual( - result.exit_code, 0, f"Command failed with output: {result.output}" - ) - self.assertIn("Updating existing.txt on remote...", result.output) - self.assertIn("Uploading new file new_file.txt to remote...", result.output) - self.assertIn("Sync completed successfully.", result.output) - - mock_provider.delete_file.assert_called_once_with("org1", "proj1", "uuid1") - self.assertEqual(mock_provider.upload_file.call_count, 2) - - @patch("claudesync.cli.sync.validate_and_get_provider") - @patch("claudesync.cli.sync.os.path.exists") - def test_sync_command_no_local_path( - self, mock_path_exists, mock_validate_and_get_provider - ): - mock_provider = MagicMock() - mock_validate_and_get_provider.return_value = mock_provider - mock_path_exists.return_value = False - - result = self.runner.invoke(cli, ["sync"]) - - self.assertEqual(result.exit_code, 1) - self.assertIn( - "No local path set. Please select or create a project to set the local path.", - result.output, - ) - - @patch("claudesync.cli.sync.shutil.which") - @patch("claudesync.cli.sync.sys.platform", "linux") - @patch("claudesync.cli.sync.CronTab") - def test_schedule_command_unix(self, mock_crontab, mock_which): - mock_which.return_value = "/usr/local/bin/claudesync" - mock_cron = MagicMock() - mock_crontab.return_value = mock_cron - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 0) - self.assertIn("Cron job created successfully!", result.output) - mock_cron.new.assert_called_once_with(command="/usr/local/bin/claudesync sync") - mock_cron.write.assert_called_once() - - @patch("claudesync.cli.sync.shutil.which") - @patch("claudesync.cli.sync.sys.platform", "win32") - def test_schedule_command_windows(self, mock_which): - mock_which.return_value = "C:\\Program Files\\claudesync\\claudesync.exe" - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 0) - self.assertIn("Windows Task Scheduler setup:", result.output) - self.assertIn( - 'schtasks /create /tn "ClaudeSync" /tr "C:\\Program Files\\claudesync\\claudesync.exe sync" /sc minute /mo 10', - result.output, - ) - - @patch("claudesync.cli.sync.shutil.which") - def test_schedule_command_claudesync_not_found(self, mock_which): - mock_which.return_value = None - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 1) - self.assertIn("Error: claudesync not found in PATH.", result.output) - - -if __name__ == "__main__": - unittest.main()