mirror of
https://github.com/open-webui/oikb.git
synced 2026-10-14 02:02:21 +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
|
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
|
### Nextcloud
|
||||||
|
|
||||||
Sync a Nextcloud folder via WebDAV:
|
Sync a Nextcloud folder via WebDAV:
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ def _resolve_connector(
|
||||||
if source.startswith("sharepoint:"):
|
if source.startswith("sharepoint:"):
|
||||||
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source
|
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source
|
||||||
parsed = parse_sharepoint_source(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:"):
|
if source.startswith("nextcloud:"):
|
||||||
from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source
|
from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class SharePointConnector(BaseConnector):
|
||||||
site: str,
|
site: str,
|
||||||
site_path: str = "",
|
site_path: str = "",
|
||||||
library: str = "Documents",
|
library: str = "Documents",
|
||||||
|
root_path: str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
client_id: str | None = None,
|
client_id: str | None = None,
|
||||||
client_secret: str | None = None,
|
client_secret: str | None = None,
|
||||||
|
|
@ -64,6 +65,7 @@ class SharePointConnector(BaseConnector):
|
||||||
self.site = site
|
self.site = site
|
||||||
self.site_path = site_path.strip("/")
|
self.site_path = site_path.strip("/")
|
||||||
self.library = library
|
self.library = library
|
||||||
|
self.root_path = (root_path or "").strip("/")
|
||||||
|
|
||||||
tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "")
|
tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "")
|
||||||
cid = client_id or os.environ.get("SHAREPOINT_CLIENT_ID", "")
|
cid = client_id or os.environ.get("SHAREPOINT_CLIENT_ID", "")
|
||||||
|
|
@ -141,7 +143,7 @@ class SharePointConnector(BaseConnector):
|
||||||
|
|
||||||
def build_manifest(self) -> list[ManifestEntry]:
|
def build_manifest(self) -> list[ManifestEntry]:
|
||||||
entries: 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)
|
entries.sort(key=lambda e: e.display_path)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -165,7 +167,7 @@ class SharePointConnector(BaseConnector):
|
||||||
))
|
))
|
||||||
|
|
||||||
def read_file(self, path: str, filename: str) -> bytes:
|
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 = self._http.get(f"/drives/{self._drive_id}/root:/{quote(file_path)}:/content", follow_redirects=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.content
|
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