gitlink-cli/shortcuts/wiki/wiki.go

684 lines
19 KiB
Go

package wiki
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const gatewayBaseURL = "https://gateway.gitlink.org.cn/api"
var (
projectIDCache sync.Map
gatewayClient *client.Client
gatewayOnce sync.Once
)
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
func getGatewayClient() *client.Client {
gatewayOnce.Do(func() {
gatewayClient = &client.Client{
HTTP: auth.NewHTTPClient(),
BaseURL: gatewayBaseURL,
SkipJSONSuffix: true,
}
})
return gatewayClient
}
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient()
gc.Debug = ctx.Client.Debug
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient()
gc.Debug = ctx.Client.Debug
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil
}
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
if v != 200 && v != 201 {
msg, _ := resp["msg"].(string)
return nil, fmt.Errorf("[%.0f] %s", v, msg)
}
}
}
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil
}
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (string, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from getWiki")
}
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", nil
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), nil
}
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
func cleanWikiList(env *output.Envelope) {
items, ok := env.Data.([]interface{})
if !ok {
return
}
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
delete(m, "wiki_clone_link")
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data, ok := env.Data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
if b64, ok := data["content_base64"].(string); ok && b64 != "" {
decoded, err := base64.StdEncoding.DecodeString(b64)
if err == nil {
data["content_decoded"] = string(decoded)
}
}
return ctx.Output(env)
}
// --- lint types and implementation ---
type LintIssue struct {
Page string `json:"page"`
Level string `json:"level"`
Check string `json:"check"`
Message string `json:"message"`
}
type LintSummary struct {
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
}
var (
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`)
)
func isCheckEnabled(checkFilter, name string) bool {
if checkFilter == "" {
return true
}
for _, c := range strings.Split(checkFilter, ",") {
if strings.TrimSpace(c) == name {
return true
}
}
return false
}
func checkEmpty(page, content string) []LintIssue {
if strings.TrimSpace(content) == "" {
return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}}
}
return nil
}
func checkHeading(page, content string) []LintIssue {
if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") {
return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}}
}
return nil
}
func checkShort(page, content string) []LintIssue {
if content != "" && len(content) < 50 {
return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}}
}
return nil
}
func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue {
var issues []LintIssue
for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
}
target := m[2]
// skip external links and anchors
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") {
continue
}
decoded, _ := url.PathUnescape(target)
if decoded == "" {
decoded = target
}
if !knownTitles[decoded] && !knownTitles[target] {
issues = append(issues, LintIssue{
Page: page,
Level: "error",
Check: "links",
Message: fmt.Sprintf("dead link: [%s](%s) -> page %q not found", m[1], target, decoded),
})
}
}
return issues
}
func checkImages(page, content string, httpClient *http.Client) []LintIssue {
var issues []LintIssue
for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
}
imgURL := m[2]
req, err := http.NewRequest("HEAD", imgURL, nil)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)})
continue
}
resp, err := httpClient.Do(req)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)})
continue
}
resp.Body.Close()
if resp.StatusCode >= 400 {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)})
}
}
return issues
}
func runLint(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
checkFilter := ctx.Arg("check")
fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo)
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// Fetch page list
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
return fmt.Errorf("failed to list wiki pages: %w", err)
}
// Parse pages: title for display/link-check, sub_url for fetching
type pageInfo struct {
title string
subURL string
}
knownTitles := make(map[string]bool)
var pages []pageInfo
if items, ok := env.Data.([]interface{}); ok {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
title, _ := m["title"].(string)
subURL, _ := m["sub_url"].(string)
if title != "" {
knownTitles[title] = true
if subURL == "" {
subURL = title
}
pages = append(pages, pageInfo{title: title, subURL: subURL})
}
}
}
if len(pages) == 0 {
fmt.Fprintln(os.Stderr, "No wiki pages found.")
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
Results: []LintIssue{},
})
}
// HTTP client for image checks with 5s timeout
httpClient := &http.Client{Timeout: 5 * time.Second}
var allIssues []LintIssue
for _, p := range pages {
// Skip system pages (e.g. _Sidebar, _Footer, _Header)
if strings.HasPrefix(p.title, "_") {
continue
}
content, err := fetchPageContent(ctx, projectID, p.subURL)
if err != nil {
allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)})
continue
}
if isCheckEnabled(checkFilter, "empty") {
allIssues = append(allIssues, checkEmpty(p.title, content)...)
}
if isCheckEnabled(checkFilter, "headings") {
allIssues = append(allIssues, checkHeading(p.title, content)...)
}
if isCheckEnabled(checkFilter, "short") {
allIssues = append(allIssues, checkShort(p.title, content)...)
}
if isCheckEnabled(checkFilter, "links") {
allIssues = append(allIssues, checkDeadLinks(p.title, content, knownTitles)...)
}
if isCheckEnabled(checkFilter, "images") {
allIssues = append(allIssues, checkImages(p.title, content, httpClient)...)
}
}
// Count errors/warnings
var errCount, warnCount int
for _, issue := range allIssues {
if issue.Level == "error" {
errCount++
} else {
warnCount++
}
}
// Print issues to stderr
for _, issue := range allIssues {
if issue.Level == "error" {
fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message)
} else {
fmt.Fprintf(os.Stderr, " ⚠ %s - %s\n", issue.Page, issue.Message)
}
}
fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount)
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
TotalPages: len(pages),
TotalIssues: len(allIssues),
Errors: errCount,
Warnings: warnCount,
Results: allIssues,
})
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List all wiki pages",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
return fmt.Errorf("获取 Wiki 页面列表失败: %w", err)
}
cleanWikiList(env)
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a wiki page",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", title)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return fmt.Errorf("查看 Wiki 页面失败: %w", err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "create",
Description: "Create a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (plain text, will be base64-encoded)"},
{Name: "file", Short: "f", Usage: "Read content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": title,
"title": title,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
if msg := ctx.Arg("message"); msg != "" {
body["message"] = msg
}
env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body)
if err != nil {
return fmt.Errorf("创建 Wiki 页面失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Update wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Current page title to find (defaults to --title)"},
{Name: "title", Short: "t", Usage: "New page title", Required: true},
{Name: "cover", Short: "c", Usage: "Replace entire page content with this text"},
{Name: "add", Short: "a", Usage: "Append text to existing page content"},
{Name: "file", Short: "f", Usage: "Read content from file (used with --cover or --add)"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
pageName := ctx.Arg("page")
if pageName == "" {
pageName = title
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
coverText := ctx.Arg("cover")
addText := ctx.Arg("add")
filePath := ctx.Arg("file")
message := ctx.Arg("message")
var finalContent string
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover or --file alone: overwrite
content, err := resolveUpdateContent(ctx, coverText, filePath)
if err != nil {
return err
}
finalContent = content
} else if addText != "" {
// --add: append to existing content
newPart, err := resolveUpdateContent(ctx, addText, filePath)
if err != nil {
return err
}
existing, err := fetchPageContent(ctx, projectID, pageName)
if err != nil {
return fmt.Errorf("failed to fetch existing page content for append: %w", err)
}
finalContent = existing + newPart
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": url.QueryEscape(pageName),
"title": title,
"message": message,
}
if finalContent != "" {
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(finalContent))
}
env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body)
if err != nil {
return fmt.Errorf("更新 Wiki 页面失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Delete wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": title,
"message": "",
}
_, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body)
if delErr != nil {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", title)
_, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if viewErr != nil {
return ctx.OutputData(map[string]string{
"message": "Wiki page deleted successfully",
})
}
return fmt.Errorf("删除 Wiki 页面失败: %w", delErr)
}
return ctx.OutputData(map[string]string{
"message": "Wiki page deleted successfully",
})
},
},
{
Name: "lint",
Description: "Check wiki pages for quality issues",
Flags: []common.Flag{
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
},
Run: runLint,
},
}
}