Fix/qa defects df002 df011 (#9)
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
This commit was merged in pull request #9.
This commit is contained in:
2026-09-09 16:19:31 +00:00
co-authored by thanhnv vudt15
parent e5fa21ecfd
commit 13e2c22067
37 changed files with 1418 additions and 30 deletions
+114
View File
@@ -0,0 +1,114 @@
"""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