mirror of
https://github.com/open-webui/oikb.git
synced 2026-10-12 23:05:12 +00:00
feat(sharepoint): add path option to sync a single library folder
Scan starts at the given folder and uploaded paths are relative to it.
This commit is contained in:
parent
5fd27193da
commit
419a66de79
4 changed files with 124 additions and 3 deletions
|
|
@ -414,6 +414,19 @@ sources:
|
|||
interval: 1h
|
||||
```
|
||||
|
||||
To sync only one folder of a library and drop that folder from the uploaded paths,
|
||||
set `path` (or pass `--source-path`). Files are uploaded relative to that folder:
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
- name: exceptions
|
||||
source: sharepoint:contoso.sharepoint.com/sites/Team/Docs
|
||||
path: Exceptions # Exceptions/report.xlsx -> report.xlsx
|
||||
kb-id: abc123
|
||||
filter:
|
||||
include: ["*.xlsx"] # filters match paths relative to `path`
|
||||
```
|
||||
|
||||
### Nextcloud
|
||||
|
||||
Sync a Nextcloud folder via WebDAV:
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ def _resolve_connector(
|
|||
if source.startswith("sharepoint:"):
|
||||
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source
|
||||
parsed = parse_sharepoint_source(source)
|
||||
return SharePointConnector(site=parsed["site"], site_path=parsed["site_path"], library=parsed.get("library", "Documents"), **auth)
|
||||
return SharePointConnector(site=parsed["site"], site_path=parsed["site_path"], library=parsed.get("library", "Documents"), root_path=path, **auth)
|
||||
|
||||
if source.startswith("nextcloud:"):
|
||||
from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class SharePointConnector(BaseConnector):
|
|||
site: str,
|
||||
site_path: str = "",
|
||||
library: str = "Documents",
|
||||
root_path: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
|
|
@ -64,6 +65,7 @@ class SharePointConnector(BaseConnector):
|
|||
self.site = site
|
||||
self.site_path = site_path.strip("/")
|
||||
self.library = library
|
||||
self.root_path = (root_path or "").strip("/")
|
||||
|
||||
tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "")
|
||||
cid = client_id or os.environ.get("SHAREPOINT_CLIENT_ID", "")
|
||||
|
|
@ -141,7 +143,7 @@ class SharePointConnector(BaseConnector):
|
|||
|
||||
def build_manifest(self) -> list[ManifestEntry]:
|
||||
entries: list[ManifestEntry] = []
|
||||
self._walk_folder("/", "", entries)
|
||||
self._walk_folder(self.root_path or "/", "", entries)
|
||||
entries.sort(key=lambda e: e.display_path)
|
||||
return entries
|
||||
|
||||
|
|
@ -165,7 +167,7 @@ class SharePointConnector(BaseConnector):
|
|||
))
|
||||
|
||||
def read_file(self, path: str, filename: str) -> bytes:
|
||||
file_path = f"{path}/{filename}" if path else filename
|
||||
file_path = "/".join(p for p in (self.root_path, path, filename) if p)
|
||||
resp = self._http.get(f"/drives/{self._drive_id}/root:/{quote(file_path)}:/content", follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
|
|
|||
106
tests/test_sharepoint.py
Normal file
106
tests/test_sharepoint.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from oikb.cli import _resolve_connector
|
||||
from oikb.connectors.sharepoint import SharePointConnector
|
||||
|
||||
GRAPH = "https://graph.microsoft.com/v1.0"
|
||||
SOURCE = "sharepoint:contoso.sharepoint.com/sites/Team/Docs"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _sharepoint_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SHAREPOINT_TENANT_ID", "tenant")
|
||||
monkeypatch.setenv("SHAREPOINT_CLIENT_ID", "client")
|
||||
monkeypatch.setenv("SHAREPOINT_CLIENT_SECRET", "secret")
|
||||
|
||||
|
||||
def _mock_graph(router: respx.MockRouter) -> None:
|
||||
router.post("https://login.microsoftonline.com/tenant/oauth2/v2.0/token").respond(
|
||||
json={"access_token": "token"}
|
||||
)
|
||||
router.get(f"{GRAPH}/sites/contoso.sharepoint.com:/sites/Team").respond(json={"id": "site-id"})
|
||||
router.get(f"{GRAPH}/sites/site-id/drives").respond(json={"value": [{"id": "drive-id", "name": "Docs"}]})
|
||||
router.get(f"{GRAPH}/drives/drive-id/root/children").respond(
|
||||
json={
|
||||
"value": [
|
||||
{"name": "Exceptions", "folder": {}},
|
||||
{"name": "top.pdf", "file": {}, "eTag": '"top-etag"', "size": 1},
|
||||
]
|
||||
}
|
||||
)
|
||||
router.get(f"{GRAPH}/drives/drive-id/root:/Exceptions:/children").respond(
|
||||
json={
|
||||
"value": [
|
||||
{"name": "VM_POWERED_OFF.xlsx", "file": {}, "eTag": '"vm-etag"', "size": 2},
|
||||
{"name": "nested", "folder": {}},
|
||||
]
|
||||
}
|
||||
)
|
||||
router.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/nested:/children").respond(
|
||||
json={"value": [{"name": "deep.xlsx", "file": {}, "eTag": '"deep-etag"', "size": 3}]}
|
||||
)
|
||||
|
||||
|
||||
@respx.mock(assert_all_called=False)
|
||||
def test_default_root_is_library_root(respx_mock: respx.MockRouter) -> None:
|
||||
_mock_graph(respx_mock)
|
||||
|
||||
manifest = _resolve_connector(SOURCE).build_manifest()
|
||||
|
||||
assert [e.display_path for e in manifest] == [
|
||||
"Exceptions/VM_POWERED_OFF.xlsx",
|
||||
"Exceptions/nested/deep.xlsx",
|
||||
"top.pdf",
|
||||
]
|
||||
|
||||
|
||||
@respx.mock(assert_all_called=False)
|
||||
def test_root_path_scopes_manifest_and_strips_the_folder(respx_mock: respx.MockRouter) -> None:
|
||||
_mock_graph(respx_mock)
|
||||
|
||||
manifest = _resolve_connector(SOURCE, path="Exceptions").build_manifest()
|
||||
|
||||
assert [e.display_path for e in manifest] == [
|
||||
"VM_POWERED_OFF.xlsx",
|
||||
"nested/deep.xlsx",
|
||||
]
|
||||
|
||||
|
||||
@respx.mock(assert_all_called=False)
|
||||
def test_root_path_tolerates_surrounding_slashes(respx_mock: respx.MockRouter) -> None:
|
||||
_mock_graph(respx_mock)
|
||||
|
||||
manifest = _resolve_connector(SOURCE, path="/Exceptions/").build_manifest()
|
||||
|
||||
assert [e.display_path for e in manifest] == ["VM_POWERED_OFF.xlsx", "nested/deep.xlsx"]
|
||||
|
||||
|
||||
@respx.mock(assert_all_called=False)
|
||||
def test_read_file_resolves_paths_against_root_path(respx_mock: respx.MockRouter) -> None:
|
||||
_mock_graph(respx_mock)
|
||||
root_file = respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/VM_POWERED_OFF.xlsx:/content").respond(
|
||||
content=b"top"
|
||||
)
|
||||
nested_file = respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/nested/deep.xlsx:/content").respond(
|
||||
content=b"deep"
|
||||
)
|
||||
connector = SharePointConnector(site="contoso.sharepoint.com", site_path="sites/Team", library="Docs", root_path="Exceptions")
|
||||
|
||||
assert connector.read_file("", "VM_POWERED_OFF.xlsx") == b"top"
|
||||
assert connector.read_file("nested", "deep.xlsx") == b"deep"
|
||||
assert root_file.called and nested_file.called
|
||||
|
||||
|
||||
@respx.mock(assert_all_called=False)
|
||||
def test_missing_root_path_raises(respx_mock: respx.MockRouter) -> None:
|
||||
_mock_graph(respx_mock)
|
||||
respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Nope:/children").respond(
|
||||
404, json={"error": {"code": "itemNotFound"}}
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_resolve_connector(SOURCE, path="Nope").build_manifest()
|
||||
Loading…
Add table
Add a link
Reference in a new issue