| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832 |
- package wikijs
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "mime"
- "mime/multipart"
- "net/http"
- "net/textproto"
- "net/url"
- "path/filepath"
- "regexp"
- "slices"
- "strings"
- "sync"
- "time"
- "unicode/utf8"
- )
- // maxResponseBytes bounds the response body read from WikiJS.
- const maxResponseBytes = 32 << 20
- // filenameRuns matches the character runs WikiJS collapses to a single
- // underscore when sanitizing uploaded filenames.
- var filenameRuns = regexp.MustCompile(`[\s,;#]+`)
- // assetFolderSegmentPattern matches a single asset folder slug segment as
- // produced by WikiJS (lowercase slugs; validation is deliberately tolerant
- // of uppercase input because folder matching is case-insensitive).
- var assetFolderSegmentPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
- // DefaultMaxUploadBytes is the upload limit WikiJS enforces out of the box
- // (5 MB, server/app/data.yml). It is the fallback when the site config is
- // not readable with the configured API key.
- const DefaultMaxUploadBytes = 5 << 20
- // siteConfigQuery fetches the upload limit from the WikiJS site config.
- // The field requires the manage:system permission; callers must treat a
- // failure as "limit unknown" and fall back to DefaultMaxUploadBytes.
- const siteConfigQuery = `query SiteConfig {
- site {
- config {
- uploadMaxFileSize
- }
- }
- }`
- // Client talks to the WikiJS GraphQL API.
- type Client struct {
- baseURL string
- endpoint string
- apiKey string
- userAgent string
- http *http.Client
- logger *slog.Logger
- uploadLimitOnce sync.Once
- uploadLimitBytes int
- }
- // Option customizes a [Client].
- type Option func(*Client)
- // WithHTTPClient sets the HTTP client used for GraphQL requests.
- func WithHTTPClient(hc *http.Client) Option {
- return func(c *Client) {
- if hc != nil {
- c.http = hc
- }
- }
- }
- // WithLogger enables debug logging of GraphQL operations.
- func WithLogger(logger *slog.Logger) Option {
- return func(c *Client) {
- if logger != nil {
- c.logger = logger
- }
- }
- }
- // WithUserAgent overrides the User-Agent sent to WikiJS.
- func WithUserAgent(userAgent string) Option {
- return func(c *Client) {
- if userAgent != "" {
- c.userAgent = userAgent
- }
- }
- }
- // New creates a client for a WikiJS base URL. The URL may end with /graphql;
- // it is normalized so that the GraphQL endpoint is always addressed once.
- func New(rawBaseURL, apiKey string, opts ...Option) (*Client, error) {
- base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
- u, err := url.Parse(base)
- if err != nil {
- return nil, fmt.Errorf("parse WikiJS URL: %w", err)
- }
- if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
- return nil, fmt.Errorf("WikiJS URL must be an absolute http(s) URL, got %q", rawBaseURL)
- }
- if u.RawQuery != "" || u.Fragment != "" {
- return nil, fmt.Errorf("WikiJS URL must not contain a query or fragment, got %q", rawBaseURL)
- }
- endpoint := base
- if strings.HasSuffix(base, "/graphql") {
- base = strings.TrimSuffix(base, "/graphql")
- } else {
- endpoint += "/graphql"
- }
- c := &Client{
- baseURL: base,
- endpoint: endpoint,
- apiKey: apiKey,
- userAgent: "wikijs-mcp",
- http: http.DefaultClient,
- logger: slog.New(slog.DiscardHandler),
- }
- for _, opt := range opts {
- opt(c)
- }
- return c, nil
- }
- // SearchPages runs a WikiJS full-text search. path and locale are optional.
- func (c *Client) SearchPages(ctx context.Context, query, path, locale string) (SearchResults, error) {
- vars := map[string]any{"query": query}
- putString(vars, "path", path)
- putString(vars, "locale", locale)
- var data struct {
- Pages *struct {
- Search *struct {
- Results []*SearchHit `json:"results"`
- Suggestions []string `json:"suggestions"`
- TotalHits int `json:"totalHits"`
- } `json:"search"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "SearchPages", searchPagesQuery, vars, &data); err != nil {
- return SearchResults{}, err
- }
- out := SearchResults{Results: []SearchHit{}, Suggestions: []string{}}
- if data.Pages == nil || data.Pages.Search == nil {
- return out, nil
- }
- for _, hit := range data.Pages.Search.Results {
- if hit == nil {
- continue
- }
- hit.URL = c.pageURL(hit.Path, hit.Locale)
- out.Results = append(out.Results, *hit)
- }
- out.Suggestions = append(out.Suggestions, data.Pages.Search.Suggestions...)
- out.TotalHits = data.Pages.Search.TotalHits
- return out, nil
- }
- // PageByID returns a page by its numeric ID. It returns a nil page without an
- // error when WikiJS does not know the ID.
- func (c *Client) PageByID(ctx context.Context, id int) (*Page, error) {
- var data struct {
- Pages *struct {
- Single *Page `json:"single"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "GetPageById", pageByIDQuery, map[string]any{"id": id}, &data); err != nil {
- return nil, err
- }
- if data.Pages == nil || data.Pages.Single == nil {
- return nil, nil
- }
- return c.decorate(data.Pages.Single), nil
- }
- // PageByPath returns a page by its path and locale. It returns a nil page
- // without an error when the page does not exist.
- func (c *Client) PageByPath(ctx context.Context, path, locale string) (*Page, error) {
- vars := map[string]any{"path": path, "locale": locale}
- var data struct {
- Pages *struct {
- Single *Page `json:"singleByPath"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "GetPageByPath", pageByPathQuery, vars, &data); err != nil {
- return nil, err
- }
- if data.Pages == nil || data.Pages.Single == nil {
- return nil, nil
- }
- return c.decorate(data.Pages.Single), nil
- }
- // ListPages returns pages, optionally filtered by limit, locale, and tags.
- func (c *Client) ListPages(ctx context.Context, opts ListOptions) ([]PageListItem, error) {
- vars := map[string]any{}
- if opts.Limit != nil {
- vars["limit"] = *opts.Limit
- }
- putString(vars, "locale", opts.Locale)
- if len(opts.Tags) > 0 {
- vars["tags"] = opts.Tags
- }
- var data struct {
- Pages *struct {
- List []*PageListItem `json:"list"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "GetAllPages", listPagesQuery, vars, &data); err != nil {
- return nil, err
- }
- out := []PageListItem{}
- if data.Pages == nil {
- return out, nil
- }
- for _, page := range data.Pages.List {
- if page == nil {
- continue
- }
- page.URL = c.pageURL(page.Path, page.Locale)
- if page.Tags == nil {
- page.Tags = []string{}
- }
- out = append(out, *page)
- }
- return out, nil
- }
- // CreatePage creates a page.
- func (c *Client) CreatePage(ctx context.Context, in CreatePageInput) (MutationResult, error) {
- vars := map[string]any{
- "title": in.Title,
- "content": in.Content,
- "description": in.Description,
- "editor": in.Editor,
- "isPublished": in.IsPublished,
- "isPrivate": in.IsPrivate,
- "locale": in.Locale,
- "tags": nonNilStrings(in.Tags),
- "path": in.Path,
- }
- var data struct {
- Pages *struct {
- Create *struct {
- ResponseResult *responseResult `json:"responseResult"`
- } `json:"create"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "CreatePage", createPageMutation, vars, &data); err != nil {
- return MutationResult{}, err
- }
- if data.Pages == nil || data.Pages.Create == nil {
- return MutationResult{}, errors.New("wikijs CreatePage: missing create result")
- }
- return mutationResult(data.Pages.Create.ResponseResult)
- }
- // UpdatePage updates a page.
- func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationResult, error) {
- vars := map[string]any{
- "id": in.ID,
- "title": in.Title,
- "content": in.Content,
- "description": in.Description,
- "editor": in.Editor,
- "isPublished": in.IsPublished,
- "isPrivate": in.IsPrivate,
- "locale": in.Locale,
- "tags": nonNilStrings(in.Tags),
- "path": in.Path,
- }
- var data struct {
- Pages *struct {
- Update *struct {
- ResponseResult *responseResult `json:"responseResult"`
- } `json:"update"`
- } `json:"pages"`
- }
- if err := c.do(ctx, "UpdatePage", updatePageMutation, vars, &data); err != nil {
- return MutationResult{}, err
- }
- if data.Pages == nil || data.Pages.Update == nil {
- return MutationResult{}, errors.New("wikijs UpdatePage: missing update result")
- }
- return mutationResult(data.Pages.Update.ResponseResult)
- }
- // ListAssetFolders returns the child folders of an asset folder. An empty
- // parentPath addresses the root folder. Folder paths are slash-separated slug
- // chains, for example "images/vacation".
- func (c *Client) ListAssetFolders(ctx context.Context, parentPath string) ([]AssetFolder, error) {
- parentID, chain, err := c.resolveAssetFolder(ctx, parentPath)
- if err != nil {
- return nil, err
- }
- folders, err := c.assetFolders(ctx, parentID)
- if err != nil {
- return nil, err
- }
- for i := range folders {
- folders[i].Path = strings.Join(append(slices.Clone(chain), folders[i].Slug), "/")
- }
- return folders, nil
- }
- // ListAssets returns the assets of an asset folder. An empty folderPath
- // addresses the root folder.
- func (c *Client) ListAssets(ctx context.Context, folderPath string) ([]Asset, error) {
- folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
- if err != nil {
- return nil, err
- }
- var data struct {
- Assets *struct {
- List []Asset `json:"list"`
- } `json:"assets"`
- }
- vars := map[string]any{"folderId": folderID, "kind": "ALL"}
- if err := c.do(ctx, "ListAssets", listAssetsQuery, vars, &data); err != nil {
- return nil, err
- }
- out := []Asset{}
- if data.Assets == nil {
- return out, nil
- }
- for i := range data.Assets.List {
- asset := &data.Assets.List[i]
- if asset.Folder == nil {
- asset.Folder = &AssetFolder{ID: folderID, Path: strings.Join(chain, "/")}
- } else {
- asset.Folder.Path = strings.Join(chain, "/")
- }
- asset.URL = c.assetURL(chain, asset.FileName)
- out = append(out, *asset)
- }
- return out, nil
- }
- // CreateAssetFolder creates a new asset folder inside parentPath (empty means
- // the root folder). slug must already be a valid WikiJS slug.
- func (c *Client) CreateAssetFolder(ctx context.Context, parentPath, slug, name string) (MutationResult, error) {
- parentID, _, err := c.resolveAssetFolder(ctx, parentPath)
- if err != nil {
- return MutationResult{}, err
- }
- var data struct {
- Assets *struct {
- CreateFolder *struct {
- ResponseResult *responseResult `json:"responseResult"`
- } `json:"createFolder"`
- } `json:"assets"`
- }
- vars := map[string]any{"parentFolderId": parentID, "slug": slug, "name": name}
- if err := c.do(ctx, "CreateAssetFolder", createAssetFolderMutation, vars, &data); err != nil {
- return MutationResult{}, err
- }
- if data.Assets == nil || data.Assets.CreateFolder == nil {
- return MutationResult{}, errors.New("wikijs CreateAssetFolder: missing createFolder result")
- }
- return mutationResult(data.Assets.CreateFolder.ResponseResult)
- }
- // uploadLimit returns the WikiJS upload limit in bytes, reading it once
- // from the site config (requires manage:system) and falling back to
- // DefaultMaxUploadBytes when the config is not readable. The value is
- // cached for the lifetime of the client; a failed lookup is not retried.
- func (c *Client) uploadLimit(ctx context.Context) int {
- c.uploadLimitOnce.Do(func() {
- var data struct {
- Site *struct {
- Config *struct {
- UploadMaxFileSize *int `json:"uploadMaxFileSize"`
- } `json:"config"`
- } `json:"site"`
- }
- if err := c.do(ctx, "SiteConfig", siteConfigQuery, nil, &data); err != nil {
- c.logger.Debug("site config not readable; using default upload limit",
- "limit", DefaultMaxUploadBytes, "error", err)
- c.uploadLimitBytes = DefaultMaxUploadBytes
- return
- }
- limit := 0
- if data.Site != nil && data.Site.Config != nil && data.Site.Config.UploadMaxFileSize != nil {
- limit = *data.Site.Config.UploadMaxFileSize
- }
- if limit <= 0 {
- limit = DefaultMaxUploadBytes
- }
- c.uploadLimitBytes = limit
- })
- return c.uploadLimitBytes
- }
- // UploadAsset uploads a single file to an asset folder (empty folderPath
- // means the root folder) using the /u upload endpoint. The filename appears
- // in the returned URL and in markdown references. mimeType is optional and
- // guessed from the file extension when empty.
- //
- // Uploads larger than the WikiJS upload limit are rejected before they are
- // sent. The limit is read once from the site config (requires manage:system)
- // and falls back to DefaultMaxUploadBytes; it can be overridden per tool
- // call only by raising the limit in WikiJS itself.
- func (c *Client) UploadAsset(ctx context.Context, folderPath, filename string, data []byte, mimeType string) (MutationResult, UploadedAsset, error) {
- clean := sanitizeAssetFilename(filename)
- if clean == "" || clean == "." {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: invalid filename %q", filename)
- }
- if len(data) > c.uploadLimit(ctx) {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf(
- "wikijs UploadAsset: file is %d bytes, exceeding the WikiJS upload limit of %d bytes; reduce the file size or raise the limit in WikiJS (Administration > System Info)",
- len(data), c.uploadLimitBytes)
- }
- folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
- if err != nil {
- return MutationResult{}, UploadedAsset{}, err
- }
- if mimeType == "" {
- mimeType = "application/octet-stream"
- if guessed := mime.TypeByExtension(filepath.Ext(clean)); guessed != "" {
- mimeType = guessed
- }
- }
- metadata, err := json.Marshal(map[string]any{"folderId": folderID})
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: encode metadata: %w", err)
- }
- var body bytes.Buffer
- form := multipart.NewWriter(&body)
- metaPart, err := form.CreatePart(textproto.MIMEHeader{
- "Content-Disposition": {`form-data; name="mediaUpload"`},
- })
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
- }
- if _, err := metaPart.Write(metadata); err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
- }
- filePart, err := form.CreatePart(textproto.MIMEHeader{
- "Content-Disposition": {fmt.Sprintf(`form-data; name="mediaUpload"; filename=%q`, clean)},
- "Content-Type": {mimeType},
- })
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
- }
- if _, err := filePart.Write(data); err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
- }
- if err := form.Close(); err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/u", &body)
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build request: %w", err)
- }
- req.Header.Set("Content-Type", form.FormDataContentType())
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
- req.Header.Set("User-Agent", c.userAgent)
- resp, err := c.http.Do(req)
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: %w", err)
- }
- defer resp.Body.Close()
- respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
- if err != nil {
- return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: read response: %w", err)
- }
- if len(respBody) > maxResponseBytes {
- return MutationResult{}, UploadedAsset{}, &APIError{
- Operation: "UploadAsset",
- Status: resp.StatusCode,
- Err: fmt.Errorf("response exceeds %d bytes", maxResponseBytes),
- }
- }
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || strings.TrimSpace(string(respBody)) != "ok" {
- var rr struct {
- Succeeded bool `json:"succeeded"`
- Message string `json:"message"`
- }
- if err := json.Unmarshal(respBody, &rr); err == nil && rr.Message != "" {
- return MutationResult{Succeeded: false, Message: rr.Message}, UploadedAsset{}, nil
- }
- return MutationResult{}, UploadedAsset{}, &APIError{
- Operation: "UploadAsset",
- Status: resp.StatusCode,
- Body: truncate(strings.TrimSpace(string(respBody)), 512),
- }
- }
- return MutationResult{Succeeded: true}, UploadedAsset{
- Filename: clean,
- URL: c.assetURL(chain, clean),
- }, nil
- }
- type responseResult struct {
- Succeeded bool `json:"succeeded"`
- ErrorCode int `json:"errorCode"`
- Slug string `json:"slug"`
- Message string `json:"message"`
- }
- func mutationResult(rr *responseResult) (MutationResult, error) {
- if rr == nil {
- return MutationResult{}, errors.New("wikijs mutation: missing responseResult")
- }
- return MutationResult{
- Succeeded: rr.Succeeded,
- Message: rr.Message,
- ErrorCode: rr.ErrorCode,
- Slug: rr.Slug,
- }, nil
- }
- type graphqlRequest struct {
- Query string `json:"query"`
- Variables map[string]any `json:"variables,omitempty"`
- }
- type graphqlError struct {
- Message string `json:"message"`
- }
- type graphqlResponse struct {
- Data json.RawMessage `json:"data"`
- Errors []graphqlError `json:"errors"`
- }
- // APIError describes a failed WikiJS request.
- type APIError struct {
- Operation string
- Status int
- Messages []string
- Body string
- Err error
- }
- func (e *APIError) Error() string {
- var b strings.Builder
- b.WriteString("wikijs")
- if e.Operation != "" {
- b.WriteByte(' ')
- b.WriteString(e.Operation)
- }
- b.WriteString(": ")
- switch {
- case len(e.Messages) > 0:
- b.WriteString(strings.Join(e.Messages, "; "))
- case e.Body != "":
- b.WriteString(e.Body)
- case e.Err != nil:
- b.WriteString(e.Err.Error())
- default:
- b.WriteString("request failed")
- }
- if e.Status != 0 {
- fmt.Fprintf(&b, " (HTTP %d)", e.Status)
- }
- return b.String()
- }
- func (e *APIError) Unwrap() error { return e.Err }
- func (c *Client) do(ctx context.Context, operation, query string, vars map[string]any, out any) error {
- payload, err := json.Marshal(graphqlRequest{Query: query, Variables: vars})
- if err != nil {
- return fmt.Errorf("wikijs %s: encode request: %w", operation, err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
- if err != nil {
- return fmt.Errorf("wikijs %s: build request: %w", operation, err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
- req.Header.Set("User-Agent", c.userAgent)
- start := time.Now()
- resp, err := c.http.Do(req)
- if err != nil {
- return fmt.Errorf("wikijs %s: %w", operation, err)
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
- if err != nil {
- return fmt.Errorf("wikijs %s: read response: %w", operation, err)
- }
- if len(body) > maxResponseBytes {
- return fmt.Errorf("wikijs %s: response exceeds %d bytes", operation, maxResponseBytes)
- }
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
- return &APIError{
- Operation: operation,
- Status: resp.StatusCode,
- Body: truncate(strings.TrimSpace(string(body)), 512),
- }
- }
- var envelope graphqlResponse
- if err := json.Unmarshal(body, &envelope); err != nil {
- return &APIError{Operation: operation, Err: fmt.Errorf("decode response: %w", err)}
- }
- if len(envelope.Errors) > 0 {
- messages := make([]string, 0, len(envelope.Errors))
- for _, e := range envelope.Errors {
- messages = append(messages, e.Message)
- }
- return &APIError{Operation: operation, Messages: messages}
- }
- if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
- return &APIError{Operation: operation, Messages: []string{"response contains no data"}}
- }
- if err := json.Unmarshal(envelope.Data, out); err != nil {
- return fmt.Errorf("wikijs %s: decode data: %w", operation, err)
- }
- c.logger.Debug("wikijs request",
- "operation", operation,
- "duration", time.Since(start),
- "status", resp.StatusCode,
- )
- return nil
- }
- func (c *Client) decorate(page *Page) *Page {
- page.URL = c.pageURL(page.Path, page.Locale)
- if page.Tags == nil {
- page.Tags = []Tag{}
- }
- return page
- }
- // pageURL builds the absolute URL of a page. Each path segment is escaped
- // separately so that separators stay intact and unicode filenames produce
- // valid links. The locale segment is only included for non-English pages.
- func (c *Client) pageURL(path, locale string) string {
- p := strings.TrimLeft(path, "/")
- segments := []string{}
- if locale != "" && locale != "en" {
- segments = append(segments, locale)
- }
- segments = append(segments, strings.Split(p, "/")...)
- return c.baseURL + "/" + escapePathSegments(segments)
- }
- // escapePathSegments escapes every segment with url.PathEscape and joins
- // them with "/", keeping the path structure intact.
- func escapePathSegments(segments []string) string {
- escaped := make([]string, len(segments))
- for i, seg := range segments {
- escaped[i] = url.PathEscape(seg)
- }
- return strings.Join(escaped, "/")
- }
- func putString(vars map[string]any, key, value string) {
- if value != "" {
- vars[key] = value
- }
- }
- func nonNilStrings(values []string) []string {
- if values == nil {
- return []string{}
- }
- return values
- }
- func truncate(s string, max int) string {
- if len(s) <= max {
- return s
- }
- return truncateRunes(s, max-3) + "..."
- }
- // assetFolders returns the direct child folders of the folder with the given
- // ID (0 is the root folder).
- func (c *Client) assetFolders(ctx context.Context, parentFolderID int) ([]AssetFolder, error) {
- var data struct {
- Assets *struct {
- Folders []AssetFolder `json:"folders"`
- } `json:"assets"`
- }
- if err := c.do(ctx, "ListAssetFolders", listAssetFoldersQuery, map[string]any{"parentFolderId": parentFolderID}, &data); err != nil {
- return nil, err
- }
- out := []AssetFolder{}
- if data.Assets == nil {
- return out, nil
- }
- return append(out, data.Assets.Folders...), nil
- }
- // resolveAssetFolder walks the asset folder tree from the root and returns the
- // ID and slug chain of the folder at folderPath. An empty path yields the root
- // folder (ID 0) with an empty chain.
- func (c *Client) resolveAssetFolder(ctx context.Context, folderPath string) (int, []string, error) {
- segments, err := assetFolderPathSegments(folderPath)
- if err != nil {
- return 0, nil, err
- }
- id := 0
- chain := []string{}
- for _, slug := range segments {
- children, err := c.assetFolders(ctx, id)
- if err != nil {
- return 0, nil, err
- }
- found := false
- for _, folder := range children {
- if strings.EqualFold(folder.Slug, slug) {
- id = folder.ID
- chain = append(chain, folder.Slug)
- found = true
- break
- }
- }
- if !found {
- return 0, nil, fmt.Errorf("asset folder %q not found", strings.Join(chain, "/")+"/"+slug)
- }
- }
- return id, chain, nil
- }
- // AssetFolderPathSegments splits a slash-separated asset folder path into
- // slug segments. An empty (or whitespace-only) path yields no segments and
- // addresses the root folder. It rejects empty segments and segments that are
- // not valid WikiJS slugs; resolvers treat matching case-insensitively.
- func AssetFolderPathSegments(folderPath string) ([]string, error) {
- segments, err := assetFolderPathSegments(folderPath)
- if err != nil {
- return nil, err
- }
- for _, segment := range segments {
- if !assetFolderSegmentPattern.MatchString(segment) {
- return nil, fmt.Errorf("invalid asset folder segment %q: use slugs like images or images-screens", segment)
- }
- }
- return segments, nil
- }
- func assetFolderPathSegments(folderPath string) ([]string, error) {
- if strings.TrimSpace(folderPath) == "" {
- return nil, nil
- }
- var segments []string
- for _, segment := range strings.Split(strings.Trim(folderPath, "/"), "/") {
- segment = strings.TrimSpace(segment)
- if segment == "" {
- return nil, fmt.Errorf("asset folder path %q must not contain an empty segment", folderPath)
- }
- segments = append(segments, segment)
- }
- return segments, nil
- }
- // assetURL builds the direct download URL of an asset. WikiJS serves assets at
- // <base>/<folder chain>/<filename>; root assets live directly under <base>.
- func (c *Client) assetURL(chain []string, filename string) string {
- parts := append(slices.Clone(chain), filename)
- return c.baseURL + "/" + strings.Join(parts, "/")
- }
- // sanitizeAssetFilename mirrors WikiJS's upload normalization, which uses
- // the sanitize-filename package: lowercase, runs of whitespace/commas/
- // semicolons/hashes collapsed to a single underscore, illegal and control
- // characters dropped, Windows reserved names removed, trailing spaces and
- // dots removed, and a maximum length of 255 bytes (truncated without
- // splitting UTF-8 sequences).
- func sanitizeAssetFilename(name string) string {
- const maxFilenameBytes = 255
- name = filenameRuns.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "_")
- var b strings.Builder
- for _, r := range name {
- if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) || strings.ContainsRune(`/?%*<>|":\`, r) {
- continue
- }
- b.WriteRune(r)
- }
- name = strings.Trim(b.String(), ". ")
- // Windows reserved device names are removed entirely (case-insensitive,
- // with or without extension), as in sanitize-filename.
- base := name
- if i := strings.IndexByte(base, '.'); i >= 0 {
- base = base[:i]
- }
- switch strings.ToLower(base) {
- case "con", "prn", "aux", "nul",
- "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
- "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9":
- return ""
- }
- return truncateRunes(name, maxFilenameBytes)
- }
- // truncateRunes cuts s to at most max bytes without splitting a multi-byte
- // UTF-8 sequence, mirroring sanitize-filename's truncate-utf8-bytes helper.
- func truncateRunes(s string, max int) string {
- for len(s) > max {
- _, size := utf8.DecodeLastRuneInString(s)
- s = s[:len(s)-size]
- }
- return s
- }
|