soulsync 1.0.0 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.py CHANGED
@@ -1,140 +1,203 @@
1
1
  import os
2
2
  import time
3
- from src.profiles import ProfilesClient, ConflictError
3
+ import shutil
4
+ import threading
5
+ from profiles import ProfilesClient, ConflictError
4
6
 
5
7
 
6
8
  class ProfileSync:
7
9
  """多文件同步逻辑"""
8
-
10
+
9
11
  def __init__(self, client: ProfilesClient, version_manager, workspace: str):
10
12
  self.client = client
11
13
  self.version_manager = version_manager
12
- self.workspace = os.path.abspath(workspace)
13
- self.is_syncing = False
14
-
15
- def pull_all(self) -> int:
16
- """从云端拉取所有文件
17
-
18
- Returns:
19
- 拉取的文件数量
20
- """
21
- print("[Sync] Pulling all profiles from cloud...")
22
- self.is_syncing = True
23
-
14
+ self.workspace = workspace
15
+ self._syncing_files = set()
16
+ self._sync_lock = threading.Lock()
17
+
18
+ def _get_absolute_path(self, relative_path: str) -> str:
19
+ """获取文件的绝对路径"""
20
+ return os.path.normpath(os.path.join(self.workspace, relative_path))
21
+
22
+ def _is_syncing(self, file_path: str) -> bool:
23
+ """检查文件是否正在同步"""
24
+ return file_path in self._syncing_files
25
+
26
+ def _mark_syncing(self, file_path: str):
27
+ """标记文件正在同步"""
28
+ self._syncing_files.add(file_path)
29
+
30
+ def _unmark_syncing(self, file_path: str):
31
+ """延迟取消同步标记"""
32
+ def remove():
33
+ time.sleep(2)
34
+ self._syncing_files.discard(file_path)
35
+ threading.Thread(target=remove, daemon=True).start()
36
+
37
+ def _read_local_file(self, file_path: str) -> str | None:
38
+ """读取本地文件内容"""
39
+ absolute_path = self._get_absolute_path(file_path)
40
+ if os.path.exists(absolute_path):
41
+ try:
42
+ with open(absolute_path, 'r', encoding='utf-8') as f:
43
+ return f.read()
44
+ except Exception as e:
45
+ print(f"[Sync] Error reading local file {file_path}: {e}")
46
+ return None
47
+ return None
48
+
49
+ def _write_local_file(self, file_path: str, content: str):
50
+ """写入本地文件"""
51
+ absolute_path = self._get_absolute_path(file_path)
24
52
  try:
25
- result = self.client.sync_profiles(since='0')
26
- files = result.get('files', [])
27
-
28
- if not files:
29
- print("[Sync] No files in cloud")
30
- return 0
53
+ directory = os.path.dirname(absolute_path)
54
+ if directory and not os.path.exists(directory):
55
+ os.makedirs(directory, exist_ok=True)
31
56
 
32
- count = 0
33
- for file_info in files:
34
- file_path = file_info.get('file_path')
35
- content = file_info.get('content')
36
- version = file_info.get('version')
37
-
38
- if file_path and content is not None:
39
- self._write_local_file(file_path, content)
40
- self.version_manager.set_version(file_path, version)
41
- count += 1
42
- print(f"[Sync] Downloaded: {file_path} (v{version})")
43
-
44
- print(f"[Sync] Pulled {count} files")
45
- return count
46
- finally:
47
- self.is_syncing = False
48
-
49
- def push_file(self, file_path: str):
50
- """推送单个文件到云端
57
+ with open(absolute_path, 'w', encoding='utf-8') as f:
58
+ f.write(content)
59
+ except Exception as e:
60
+ print(f"[Sync] Error writing local file {file_path}: {e}")
61
+
62
+ def _create_conflict_backup(self, file_path: str, local_content: str, server_content: str):
63
+ """创建冲突备份文件"""
64
+ absolute_path = self._get_absolute_path(file_path)
65
+ conflict_path = absolute_path + '.conflict'
66
+ try:
67
+ with open(conflict_path, 'w', encoding='utf-8') as f:
68
+ f.write(f"# Conflict: {file_path}\n")
69
+ f.write(f"# Created at: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
70
+ f.write("========== LOCAL VERSION ==========\n")
71
+ f.write(local_content or "(empty)")
72
+ f.write("\n\n========== SERVER VERSION ==========\n")
73
+ f.write(server_content or "(empty)")
74
+ print(f"[Sync] Conflict backup created: {conflict_path}")
75
+ except Exception as e:
76
+ print(f"[Sync] Error creating conflict backup: {e}")
77
+
78
+ def pull_all(self):
79
+ """Pull all profiles from cloud"""
80
+ print("[Sync] Pulling all profiles from cloud...")
51
81
 
52
- Args:
53
- file_path: 文件相对路径
54
- """
55
- if self.is_syncing:
56
- print(f"[Sync] Skipping push, currently syncing: {file_path}")
82
+ try:
83
+ result = self.client.get_profiles()
84
+ cloud_files = result.get('files', [])
85
+ except Exception as e:
86
+ print(f"[Sync] Error fetching cloud profiles: {e}")
57
87
  return
58
88
 
59
- print(f"[Sync] Pushing file: {file_path}")
60
-
61
- content = self._read_local_file(file_path)
62
- if content is None:
63
- print(f"[Sync] File not found or read error: {file_path}")
89
+ if not cloud_files:
90
+ print("[Sync] No files on cloud")
64
91
  return
65
92
 
66
- local_version = self.version_manager.get_version(file_path)
93
+ pulled_count = 0
94
+ pushed_count = 0
95
+ skipped_count = 0
67
96
 
68
- try:
69
- result = self.client.upload_profile(file_path, content, local_version)
97
+ for cloud_file in cloud_files:
98
+ file_path = cloud_file.get('file_path')
99
+ cloud_version = cloud_file.get('version', 0)
100
+ cloud_content = cloud_file.get('content', '')
70
101
 
71
- new_version = result.get('version')
72
- self.version_manager.set_version(file_path, new_version)
102
+ if not file_path:
103
+ continue
73
104
 
74
- print(f"[Sync] Uploaded: {file_path} (v{new_version})")
105
+ local_content = self._read_local_file(file_path)
106
+ local_version = self.version_manager.get_version(file_path)
75
107
 
76
- except ConflictError as e:
77
- print(f"[Sync] Version conflict for {file_path}, pulling latest...")
78
-
79
- self._write_local_file(file_path, e.latest_content)
80
- self.version_manager.set_version(file_path, e.latest_version)
81
-
82
- print(f"[Sync] Resolved conflict: {file_path} (now v{e.latest_version})")
83
-
84
- except Exception as e:
85
- print(f"[Sync] Upload error: {e}")
86
-
87
- def on_remote_change(self, file_path: str, version: int):
88
- """远程文件变化回调
108
+ if cloud_version > local_version:
109
+ self._mark_syncing(file_path)
110
+ try:
111
+ self._write_local_file(file_path, cloud_content)
112
+ self.version_manager.set_version(file_path, cloud_version)
113
+ pulled_count += 1
114
+ print(f"[Sync] Pulled: {file_path} (v{cloud_version})")
115
+ finally:
116
+ self._unmark_syncing(file_path)
117
+ elif local_version > cloud_version and local_content is not None:
118
+ try:
119
+ result = self.client.upload_profile(file_path, local_content, local_version)
120
+ self.version_manager.set_version(file_path, result.get('version', local_version))
121
+ pushed_count += 1
122
+ print(f"[Sync] Pushed: {file_path} (v{local_version})")
123
+ except ConflictError as e:
124
+ self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
125
+ pushed_count += 1
126
+ except Exception as e:
127
+ print(f"[Sync] Error pushing {file_path}: {e}")
128
+ else:
129
+ skipped_count += 1
89
130
 
90
- Args:
91
- file_path: 文件相对路径
92
- version: 新版本号
93
- """
94
- print(f"[Sync] Remote change detected: {file_path} (v{version})")
131
+ print(f"[Sync] Sync complete: {pulled_count} pulled, {pushed_count} pushed, {skipped_count} skipped")
132
+
133
+ def push_file(self, file_path: str):
134
+ """Push a file to cloud"""
135
+ if self._is_syncing(file_path):
136
+ return
95
137
 
96
- local_version = self.version_manager.get_version(file_path)
138
+ self._mark_syncing(file_path)
97
139
 
98
- if version > local_version:
140
+ try:
141
+ local_content = self._read_local_file(file_path)
142
+ if local_content is None:
143
+ print(f"[Sync] File not found locally: {file_path}")
144
+ return
145
+
146
+ local_version = self.version_manager.get_version(file_path)
147
+
99
148
  try:
100
- result = self.client.get_profiles(path=file_path)
101
- files = result.get('files', [])
102
-
103
- if files:
104
- file_info = files[0]
105
- content = file_info.get('content')
106
-
107
- self._write_local_file(file_path, content)
108
- self.version_manager.set_version(file_path, version)
109
-
110
- print(f"[Sync] Pulled remote change: {file_path} (v{version})")
149
+ result = self.client.upload_profile(file_path, local_content, local_version)
150
+ new_version = result.get('version', local_version + 1)
151
+ self.version_manager.set_version(file_path, new_version)
152
+ print(f"[Sync] Pushed: {file_path} (v{new_version})")
153
+ except ConflictError as e:
154
+ self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
111
155
  except Exception as e:
112
- print(f"[Sync] Pull remote change error: {e}")
113
-
114
- def _read_local_file(self, file_path: str) -> str:
115
- """读取本地文件"""
116
- full_path = os.path.join(self.workspace, file_path)
156
+ print(f"[Sync] Error pushing {file_path}: {e}")
157
+ finally:
158
+ self._unmark_syncing(file_path)
159
+
160
+ def on_remote_change(self, file_path: str, version: int):
161
+ """Handle remote file change"""
162
+ local_version = self.version_manager.get_version(file_path)
163
+
164
+ if version <= local_version:
165
+ print(f"[Sync] Remote version not newer, skipping: {file_path} (local: v{local_version}, remote: v{version})")
166
+ return
117
167
 
118
- if not os.path.exists(full_path):
119
- return None
168
+ self._mark_syncing(file_path)
120
169
 
121
170
  try:
122
- with open(full_path, 'r', encoding='utf-8') as f:
123
- return f.read()
171
+ result = self.client.get_profiles(file_path)
172
+ files = result.get('files', [])
173
+ if not files:
174
+ print(f"[Sync] File not found on cloud: {file_path}")
175
+ return
176
+
177
+ cloud_file = files[0]
178
+ cloud_content = cloud_file.get('content', '')
179
+
180
+ local_content = self._read_local_file(file_path)
181
+
182
+ if local_content is not None and local_content != cloud_content:
183
+ self._create_conflict_backup(file_path, local_content, cloud_content)
184
+
185
+ self._write_local_file(file_path, cloud_content)
186
+ self.version_manager.set_version(file_path, version)
187
+ print(f"[Sync] Pulled remote change: {file_path} (v{version})")
124
188
  except Exception as e:
125
- print(f"[Sync] Read error {file_path}: {e}")
126
- return None
127
-
128
- def _write_local_file(self, file_path: str, content: str):
129
- """写入本地文件"""
130
- full_path = os.path.join(self.workspace, file_path)
189
+ print(f"[Sync] Error handling remote change for {file_path}: {e}")
190
+ finally:
191
+ self._unmark_syncing(file_path)
192
+
193
+ def _handle_conflict(self, file_path: str, local_content: str, server_content: str, server_version: int):
194
+ """Handle conflict when pushing file"""
195
+ print(f"[Sync] CONFLICT detected for {file_path}")
131
196
 
132
- directory = os.path.dirname(full_path)
133
- if directory and not os.path.exists(directory):
134
- os.makedirs(directory, exist_ok=True)
197
+ self._create_conflict_backup(file_path, local_content, server_content)
135
198
 
136
- try:
137
- with open(full_path, 'w', encoding='utf-8') as f:
138
- f.write(content)
139
- except Exception as e:
140
- print(f"[Sync] Write error {file_path}: {e}")
199
+ self._write_local_file(file_path, server_content)
200
+ self.version_manager.set_version(file_path, server_version)
201
+
202
+ print(f"[Sync] Conflict resolved: server version used for {file_path}")
203
+ print(f"[Sync] Please manually merge the conflict backup file")
@@ -1,5 +0,0 @@
1
- # Memory
2
-
3
- This is your SoulSync memory file.
4
-
5
- Start writing your memories here, and they will be automatically synced to the cloud!