diff --git a/src/oikb/cli.py b/src/oikb/cli.py index 4e668c3..d1ef75f 100644 --- a/src/oikb/cli.py +++ b/src/oikb/cli.py @@ -116,7 +116,11 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None if source.startswith("sharepoint:"): from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source parsed = parse_sharepoint_source(source) - return SharePointConnector(site=parsed["site"], library=parsed.get("library", "Documents")) + return SharePointConnector( + site=parsed["site"], + site_path=parsed["site_path"], + library=parsed["library"], + ) if source.startswith("nextcloud:"): from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source diff --git a/src/oikb/connectors/sharepoint.py b/src/oikb/connectors/sharepoint.py index 83da39b..8256eed 100644 --- a/src/oikb/connectors/sharepoint.py +++ b/src/oikb/connectors/sharepoint.py @@ -30,6 +30,7 @@ class SharePointConnector(BaseConnector): def __init__( self, site: str, + site_path: str = "", library: str = "Documents", tenant_id: str | None = None, client_id: str | None = None, @@ -38,6 +39,7 @@ def __init__( certificate_password: str | None = None, ): self.site = site + self.site_path = site_path.strip("/") self.library = library tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "") @@ -90,7 +92,8 @@ def __init__( ) # Resolve site ID. - site_resp = self._http.get(f"/sites/{self.site}") + site_identifier = f"{self.site}:/{self.site_path}" if self.site_path else self.site + site_resp = self._http.get(f"/sites/{site_identifier}") site_resp.raise_for_status() self._site_id = site_resp.json()["id"] @@ -240,13 +243,50 @@ def _get_token_via_certificate( # ── Source parser ─────────────────────────────────────────────── +_SITE_PATH_PREFIXES = ("sites", "teams") +_SEPARATOR = "::" -def parse_sharepoint_source(source: str) -> dict[str, str | None]: - """Parse sharepoint:site/library or sharepoint:site.""" +def parse_sharepoint_source(source: str) -> dict[str, str]: + """Parse a SharePoint source string. Supports: + sharepoint:/ + sharepoint:/sites// + sharepoint:/sites//:: + """ source = source.removeprefix("sharepoint:") - parts = source.split("/", 1) - site = parts[0] - library = parts[1] if len(parts) > 1 else "Documents" - if not site: - raise ValueError("Invalid SharePoint source. Expected: sharepoint:[/library]") - return {"site": site, "library": library} + host, _, rest = source.partition("/") + if not host: + raise ValueError( + "Invalid SharePoint source. Expected one of:\n" + " sharepoint:/\n" + " sharepoint:/sites//\n" + " sharepoint:/sites//::" + ) + + if _SEPARATOR in rest: + site_path_str, _, library = rest.partition(_SEPARATOR) + site_path = site_path_str.strip("/") + if not library: + raise ValueError(f"Invalid SharePoint source: '{_SEPARATOR}' must be followed by a library name.") + return {"site": host, "site_path": site_path, "library": library} + + segments = [s for s in rest.split("/") if s] + + if segments and segments[0] in _SITE_PATH_PREFIXES: + if len(segments) < 3: + raise ValueError( + f"Invalid SharePoint source. '{segments[0]}/...' requires a site name and " + f"library, e.g. sharepoint:{host}/{segments[0]}/TeamSite/Documents" + ) + if len(segments) > 3: + raise ValueError( + "Ambiguous SharePoint source with a subsite path — separate the site path " + f"from the library explicitly with '{_SEPARATOR}', e.g.\n" + f" sharepoint:{host}/{'/'.join(segments[:-1])}{_SEPARATOR}{segments[-1]}" + ) + site_path = "/".join(segments[:2]) # e.g. "sites/TeamSite" + library = segments[2] + else: + site_path = "" + library = "/".join(segments) if segments else "Documents" + + return {"site": host, "site_path": site_path, "library": library}