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 // //; root assets live directly under . 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 }