This commit is contained in:
Timothy Jaeryang Baek 2026-07-17 15:45:30 -04:00
parent 9b7be954c5
commit ee3f84bb7b
4 changed files with 117 additions and 11 deletions

View file

@ -4,12 +4,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **Google Drive**: sync now works for folders inside a Shared Drive (formerly Team Drive). The Drive API `files.list` calls were missing `supportsAllDrives` and `includeItemsFromAllDrives`, so Shared Drive contents were invisible and the sync reported "nothing to do". File downloads/exports now pass `supportsAllDrives` as well.
## [0.3.6] - 2026-05-28
### Added

View file

@ -20,6 +20,7 @@ A complete guide to syncing content into Open WebUI Knowledge Bases.
- [GitHub](#github)
- [GitLab / Bitbucket](#gitlab--bitbucket)
- [Confluence](#confluence)
- [BookStack](#bookstack)
- [Cloud Storage (S3 / GCS / Azure)](#cloud-storage-s3--gcs--azure)
- [SharePoint](#sharepoint)
- [Nextcloud](#nextcloud)
@ -250,6 +251,41 @@ oikb sync confluence:SPACE_KEY --kb-id your-kb-id
Requires `CONFLUENCE_URL`, `CONFLUENCE_USERNAME`, and `CONFLUENCE_API_TOKEN`.
### BookStack
```bash
# Sync all pages from all BookStack books as Markdown files.
oikb sync bookstack: --kb-id your-kb-id
# Sync one specific BookStack page.
oikb sync 'bookstack:pages?include_ids=12' --kb-id your-kb-id
# Sync one BookStack book as chapter files; pages outside chapters remain page files.
oikb sync 'bookstack:books?include_ids=12&output=chapters' --kb-id your-kb-id
# Sync one whole BookStack book as a PDF file.
oikb sync 'bookstack:books?include_ids=12&output=books&format=pdf' --kb-id your-kb-id
# Sync all pages from one shelf and preserve shelf/book/chapter paths.
oikb sync 'bookstack:shelves?include_ids=5&structure=hierarchical' --kb-id your-kb-id
```
Requires `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID`, and `BOOKSTACK_TOKEN_SECRET`.
Defaults are `bookstack:books`, `output=pages`, `format=md`, and `structure=flat`.
| Parameter | Values | Description |
|---|---|---|
| `output` | `pages`, `chapters`, `books` | Exports individual pages, chapters where pages are grouped in chapters, or whole books. |
| `format` | `txt`, `md`, `html`, `pdf` | Exported file format. |
| `structure` | `flat`, `hierarchical` | Keeps files flat or mirrors shelf/book/chapter paths where available. |
| `include_ids` | comma-separated IDs | Limits the selected pages, books, or shelves. |
| `exclude_ids` | comma-separated IDs | Excludes selected pages, books, or shelves. |
Use `bookstack:pages`, `bookstack:books`, or `bookstack:shelves` to select what IDs refer to. `bookstack:` defaults to `bookstack:books`.
When `bookstack:pages` is used, `output` is always treated as `pages`.
With `output=chapters`, pages outside chapters remain individual page exports.
### Cloud Storage (S3 / GCS / Azure)
```bash

View file

@ -53,7 +53,13 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
if source.startswith("gitlab:"):
from oikb.connectors.gitlab import GitLabConnector, parse_gitlab_source
parsed = parse_gitlab_source(source)
return GitLabConnector(owner=parsed["owner"], repo=parsed["repo"], branch=branch, path=path or parsed.get("path"))
return GitLabConnector(
owner=parsed["owner"],
repo=parsed["repo"],
branch=branch,
path=path or parsed.get("path"),
is_wiki=bool(parsed.get("wiki")),
)
if source.startswith("s3://"):
from oikb.connectors.s3 import S3Connector, parse_s3_source

View file

@ -6,6 +6,7 @@ Uses the GitLab Repository Tree API — no local clone needed.
from __future__ import annotations
import hashlib
import os
import urllib.parse
@ -13,6 +14,13 @@ import httpx
from oikb.connectors import BaseConnector, ManifestEntry
_WIKI_FORMAT_EXT = {
"markdown": ".md",
"rdoc": ".rdoc",
"asciidoc": ".adoc",
"org": ".org",
}
class GitLabConnector(BaseConnector):
"""Sync files from a GitLab repository.
@ -34,11 +42,14 @@ class GitLabConnector(BaseConnector):
path: str | None = None,
token: str | None = None,
base_url: str | None = None,
is_wiki: bool = False,
):
self.owner = owner
self.repo = repo
self.branch = branch
self.path = path.strip("/") if path else None
self.is_wiki = is_wiki
self._cache: dict[str, str] = {}
self._token = token or os.environ.get("GITLAB_TOKEN")
self._base_url = (
base_url or os.environ.get("GITLAB_URL", "https://gitlab.com")
@ -111,6 +122,9 @@ class GitLabConnector(BaseConnector):
Uses the recursive tree API. Blob IDs are content-addressable hashes.
"""
self._ensure_resolved()
if self.is_wiki:
return self._build_wiki_manifest()
ref = self.branch or self._get_default_branch()
entries: list[ManifestEntry] = []
@ -168,6 +182,9 @@ class GitLabConnector(BaseConnector):
def read_file(self, path: str, filename: str) -> bytes:
"""Download a file's raw content via the GitLab Repository Files API."""
self._ensure_resolved()
if self.is_wiki:
key = f"{path}/{filename}" if path else filename
return (self._cache.get(key) or "").encode("utf-8")
file_path = f"{path}/{filename}" if path else filename
if self.path:
@ -183,6 +200,49 @@ class GitLabConnector(BaseConnector):
resp.raise_for_status()
return resp.content
def _build_wiki_manifest(self) -> list[ManifestEntry]:
resp = self._http.get(
f"/projects/{self._project_id}/wikis",
params={"with_content": 1},
)
resp.raise_for_status()
entries: list[ManifestEntry] = []
for page in resp.json():
slug = page.get("slug")
if not slug:
continue
content = page.get("content")
if content is None:
content = self._fetch_wiki_page(slug)
title = page.get("title") or slug
text = f"# {title}\n\n{content or ''}"
ext = _WIKI_FORMAT_EXT.get(page.get("format", "markdown"), ".md")
dir_path, _, base = slug.rpartition("/")
filename = f"{base or slug}{ext}"
checksum = hashlib.sha256(text.encode()).hexdigest()[:16]
entries.append(
ManifestEntry(
filename=filename,
path=dir_path,
checksum=checksum,
size=len(text.encode()),
)
)
self._cache[f"{dir_path}/{filename}" if dir_path else filename] = text
entries.sort(key=lambda e: e.display_path)
return entries
def _fetch_wiki_page(self, slug: str) -> str:
encoded_slug = urllib.parse.quote(slug, safe="")
resp = self._http.get(f"/projects/{self._project_id}/wikis/{encoded_slug}")
resp.raise_for_status()
return resp.json().get("content") or ""
def _get_default_branch(self) -> str:
"""Fetch the project's default branch name."""
self._ensure_resolved()
@ -194,23 +254,33 @@ class GitLabConnector(BaseConnector):
self._http.close()
def parse_gitlab_source(source: str) -> dict[str, str | None]:
def parse_gitlab_source(source: str) -> dict[str, str | bool | None]:
"""Parse a gitlab:owner/repo[/path] source string.
Supports:
- Standard format: gitlab:owner/repo/path/to/docs
- Explicit project:path format: gitlab:group/subgroup/project:path/to/docs
- Wiki format: gitlab:group/subgroup/project?wiki=true
"""
source = source.removeprefix("gitlab:")
source, _, query = source.partition("?")
params = urllib.parse.parse_qs(query)
is_wiki = params.get("wiki", ["false"])[-1].lower() in ("", "1", "true", "yes")
if is_wiki:
parts = source.rsplit("/", 1)
if len(parts) == 2:
return {"owner": parts[0], "repo": parts[1], "path": None, "wiki": True}
return {"owner": source, "repo": None, "path": None, "wiki": True}
# Explicit separator project:path
if ":" in source:
project, path = source.split(":", 1)
parts = project.rsplit("/", 1)
if len(parts) == 2:
return {"owner": parts[0], "repo": parts[1], "path": path}
return {"owner": parts[0], "repo": parts[1], "path": path, "wiki": False}
# Safe fallback if there is no slash in the project name
return {"owner": project, "repo": None, "path": path}
return {"owner": project, "repo": None, "path": path, "wiki": False}
# Otherwise, return full path as owner and let connector resolve repo/path dynamically
return {"owner": source, "repo": None, "path": None}
return {"owner": source, "repo": None, "path": None, "wiki": False}