CI / test (push) Canceled after 0s
## Summary What changed and why? ## Change Type - [ ] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Reviewed-on: #9
115 lines
5.2 KiB
Python
115 lines
5.2 KiB
Python
"""Mirror a OneDrive/SharePoint folder to/from a local directory (DF-007).
|
|
|
|
This is deliberately NOT a general sync engine: every existing tool
|
|
(``run_command``, ``read_file``, ``write_file``...) operates on a real local
|
|
``Path`` (``Project.output_dir`` — see ``core/projects.py::Project.workspace_dir``),
|
|
and that contract does not change here. A cloud-backed project's
|
|
``output_dir`` still points at a real local folder; this module only knows how
|
|
to pull that folder's content down from Graph once, and push it back up once,
|
|
both on explicit user action (a button click) — there is no background
|
|
watcher, no continuous sync, no delete propagation, and no conflict
|
|
resolution beyond "whichever side ran last wins" for a given file. See the
|
|
DF-007 plan for why: OneDrive/SharePoint sync-client detection is unreliable,
|
|
so a local mirror + manual sync is the only predictable option that does not
|
|
touch the sandboxed command/file tools.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
from . import ms365_graph as graph
|
|
|
|
|
|
@dataclass
|
|
class SyncReport:
|
|
"""Kết quả một lượt tải xuống/đẩy lên — hiển thị cho người dùng sau khi chạy."""
|
|
transferred: int = 0
|
|
skipped_too_large: List[str] = field(default_factory=list)
|
|
errors: List[str] = field(default_factory=list)
|
|
|
|
|
|
def _list_children(token: str, cloud_source: Dict[str, str], remote_path: str) -> List[dict]:
|
|
provider = cloud_source.get("provider")
|
|
if provider == "sharepoint":
|
|
return graph.list_sharepoint_files(token, cloud_source["site_id"], remote_path)
|
|
return graph.list_onedrive_files(token, remote_path)
|
|
|
|
|
|
def _download_file(token: str, cloud_source: Dict[str, str], remote_path: str) -> bytes:
|
|
if cloud_source.get("provider") == "sharepoint":
|
|
return graph.download_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path)
|
|
return graph.download_onedrive_file_bytes(token, remote_path)
|
|
|
|
|
|
def _upload_file(token: str, cloud_source: Dict[str, str], remote_path: str, data: bytes) -> None:
|
|
if cloud_source.get("provider") == "sharepoint":
|
|
graph.upload_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path, data)
|
|
else:
|
|
graph.upload_onedrive_file_bytes(token, remote_path, data)
|
|
|
|
|
|
def download_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
|
"""Tải toàn bộ cây thư mục ``cloud_source['remote_path']`` xuống ``local_dir``,
|
|
giữ nguyên cấu trúc thư mục con. Ghi đè file local nếu đã tồn tại (một
|
|
chiều: cloud thắng). Không xoá file local nào không còn ở phía cloud."""
|
|
report = SyncReport()
|
|
root_remote = cloud_source.get("remote_path", "")
|
|
local_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _walk(remote_path: str, local_sub: Path) -> None:
|
|
try:
|
|
children = _list_children(token, cloud_source, remote_path)
|
|
except graph.Ms365GraphError as exc:
|
|
report.errors.append(f"{remote_path or '/'}: {exc}")
|
|
return
|
|
for item in children:
|
|
name = item.get("name", "")
|
|
if not name:
|
|
continue
|
|
child_remote = f"{remote_path}/{name}" if remote_path else name
|
|
child_local = local_sub / name
|
|
if "folder" in item:
|
|
child_local.mkdir(parents=True, exist_ok=True)
|
|
_walk(child_remote, child_local)
|
|
else:
|
|
try:
|
|
data = _download_file(token, cloud_source, child_remote)
|
|
child_local.write_bytes(data)
|
|
report.transferred += 1
|
|
except graph.Ms365GraphError as exc:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
|
|
_walk(root_remote, local_dir)
|
|
return report
|
|
|
|
|
|
def upload_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
|
"""Đẩy mọi file dưới ``local_dir`` lên đúng đường dẫn tương ứng phía cloud
|
|
(tạo mới hoặc ghi đè). Một chiều: local thắng cho từng file được duyệt qua.
|
|
Không xoá file cloud nào đã bị xoá ở local, không phát hiện xung đột."""
|
|
report = SyncReport()
|
|
root_remote = cloud_source.get("remote_path", "")
|
|
local_dir = Path(local_dir)
|
|
for dirpath, _dirnames, filenames in os.walk(local_dir):
|
|
rel_dir = Path(dirpath).relative_to(local_dir)
|
|
for fname in filenames:
|
|
local_file = Path(dirpath) / fname
|
|
rel_parts = [] if str(rel_dir) == "." else list(rel_dir.parts)
|
|
rel_parts.append(fname)
|
|
child_remote = "/".join(([root_remote] if root_remote else []) + rel_parts)
|
|
try:
|
|
data = local_file.read_bytes()
|
|
_upload_file(token, cloud_source, child_remote, data)
|
|
report.transferred += 1
|
|
except graph.Ms365GraphError as exc:
|
|
if "too large" in str(exc):
|
|
report.skipped_too_large.append(child_remote)
|
|
else:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
except OSError as exc:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
return report
|