gitlink-cli/doc/reading_notes/03_wiki.md

706 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package wiki`
**字面意思**:声明这个文件属于 `wiki`
**运行时作用**Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
**小白补充**
- 包就像"工具箱"`wiki` 包就是专门处理 Wiki 功能的工具箱
- 同一个包下的文件可以直接互相调用函数,不需要导入
- 包名一般和目录名一致(这里文件在 `shortcuts/wiki/` 目录下,所以包名是 `wiki`
---
## 第 3-22 行import 导入依赖
```go
import (
"encoding/base64" // Base64 编解码
"encoding/json" // JSON 序列化/反序列化
"errors" // 错误处理
"fmt" // 格式化输出(类似 Python 的 print
"net/http" // HTTP 客户端
"net/url" // URL 编码/解析
"os" // 操作系统交互(读文件等)
"regexp" // 正则表达式
"strconv" // 字符串转数字
"strings" // 字符串操作
"sync" // 并发同步(锁、线程安全)
"time" // 时间处理
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
)
```
**字面意思**:导入需要用到的外部库/包
**运行时作用**:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
**小白补充**
| 包名 | 一句话解释 | 在本文件中的用途 |
|------|-----------|-----------------|
| `encoding/base64` | 把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
| `encoding/json` | 处理 JSON 数据 | 解析 API 返回的 JSON |
| `errors` | Go 标准错误处理工具 | 判断错误类型 |
| `fmt` | 格式化打印 | 输出错误信息、拼接字符串 |
| `net/http` | HTTP 协议客户端 | 发送 HTTP 请求 |
| `net/url` | URL 处理 | 构建查询参数、URL 编码 |
| `os` | 操作系统接口 | 读取本地文件内容 |
| `regexp` | 正则表达式 | 匹配 Markdown 链接和图片 |
| `strconv` | 字符串转换 | 把字符串转成数字 |
| `strings` | 字符串操作 | 切割、查找、替换字符串 |
| `sync` | 并发同步 | 提供线程安全的缓存(`sync.Map` |
| `time` | 时间处理 | 设置 HTTP 请求超时 |
| `internal/auth` | 项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
| `internal/client` | 项目内部客户端模块 | 封装 API 调用逻辑 |
| `internal/errors` | 项目内部错误定义 | 自定义错误类型 |
| `internal/output` | 项目内部输出模块 | 格式化输出结果JSON/Table |
| `shortcuts/common` | 通用工具模块 | 提供 RuntimeContext 等基础结构 |
---
## 第 24 行:`var projectIDCache sync.Map`
**字面意思**:声明一个全局变量 `projectIDCache`,类型是 `sync.Map`
**运行时作用**:这是一个**线程安全的缓存**,用来存储 `owner/repo -> projectID` 的映射关系,避免重复调用 API 获取项目 ID。
**小白补充**
- `var` 是 Go 声明变量的关键字
- `sync.Map` 是 Go 标准库提供的**并发安全的 map**(普通 map 在多线程下读写会崩溃)
- `projectIDCache` 是全局变量(在函数外面声明),整个包内都可以访问
- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID但获取 projectID 需要调用一次 API缓存可以节省网络请求
---
## 第 26-28 行:`wikiPath` 函数
```go
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
```
**字面意思**:定义一个函数 `wikiPath`,接收一个字符串参数 `endpoint`返回一个字符串Wiki 功能调用的是 Gateway API (网关 API所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
**运行时作用**:拼接 Wiki API 的路径前缀。比如传入 `"wikiPages"`,返回 `"/wiki/open/wikiPages"`
**小白补充**
- `func` 是 Go 定义函数的关键字
- `wikiPath(endpoint string)`:函数名是 `wikiPath`,参数名是 `endpoint`,参数类型是 `string`
- `string`(返回类型):表示函数执行完返回一个字符串
- 这是一个**工具函数**,用来避免重复写相同的路径前缀
---
## 第 30-49 行:`getGatewayClient` 函数
```go
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
```
**字面意思**:定义一个函数 `getGatewayClient`,接收 `*common.RuntimeContext` 类型的指针参数 `ctx`,返回 `*client.Client` 类型的指针
**运行时作用**:创建一个专门访问 **Wiki Gateway API** 的客户端实例。
**小白补充**
### ① 为什么需要单独的 Gateway 客户端?
GitLink 的 Wiki API 和主 API 不在同一个域名:
- 主 API`https://www.gitlink.org.cn/api`(用于获取项目信息等)
- Wiki Gateway API`https://gateway.gitlink.org.cn/api`(专门处理 Wiki 操作)
### ② 代码逐句解析:
```go
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
if baseURL == "" { // 如果没配置,用默认地址
baseURL = "https://gateway.gitlink.org.cn/api"
}
```
```go
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
httpClient = auth.NewHTTPClient()
}
```
```go
return &client.Client{...} // 创建并返回 Client 结构体实例
```
### ③ 结构体初始化语法:
```go
&client.Client{
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
BaseURL: baseURL, // Gateway API 地址
SkipJSONSuffix: true, // 关键Gateway API 不需要 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式
}
```
- `&` 符号表示取地址返回指针Go 中结构体传参常用指针,避免拷贝)
- `client.Client` 是一个**结构体类型**,里面定义了客户端的各种配置
---
## 第 51-58 行:`callWikiAPI` 函数
```go
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:定义函数 `callWikiAPI`接收上下文、HTTP 方法、路径、请求体,返回 `(*output.Envelope, error)`
**运行时作用**:封装对 Wiki Gateway API 的调用流程。
**小白补充**
### ① 参数说明:
- `ctx *common.RuntimeContext`运行时上下文包含认证信息、owner/repo 等
- `method string`HTTP 方法GET/POST/PUT/DELETE
- `path string`API 路径
- `body interface{}`请求体可以是任何类型Go 中 `interface{}` 表示万能类型)
### ② 返回值说明:
- `(*output.Envelope, error)`Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
### ③ 执行流程:
1. `gc := getGatewayClient(ctx)` → 获取 Gateway 客户端
2. `gc.Do(...)` → 调用客户端的 Do 方法发送 HTTP 请求
3. `unwrapGatewayResponse(env)` → 解析并处理响应(后面会讲)
---
## 第 60-67 行:`callWikiAPIWithQuery` 函数
```go
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:和 `callWikiAPI` 类似,但专门用于带查询参数的请求
**运行时作用**:当需要发送带 `?key=value` 查询参数的 GET 请求时使用。
**小白补充**
- `url.Values` 是 Go 标准库类型,本质是 `map[string][]string`,用来存储 URL 查询参数
- 比如 `?owner=zzx&repo=test` 会被表示为 `{"owner": ["zzx"], "repo": ["test"]}`
---
## 第 69-99 行:`unwrapGatewayResponse` 函数(核心!)
```go
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 1. 尝试把响应数据转成 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 2. 检查响应中的 code 字段
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 都算成功(包括 200/201/204 等)
if v < 200 || v >= 300 {
// 错误情况:提取错误信息
msg, _ := resp["msg"].(string)
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
// 返回自定义错误
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
```
**字面意思**"拆开" Gateway API 的响应,提取真正的数据
**运行时作用**:处理 Gateway API 返回的特殊格式,统一成标准的 `Envelope` 结构。
**小白补充**
### ① Gateway API 的响应格式:
Gateway API 返回的 JSON 格式是这样的:
```json
{
"code": 200,
"msg": "success",
"data": { "真正的数据在这里" }
}
```
而我们需要的是直接拿到 `data` 里面的内容。
### ② 类型断言Go 的特色语法):
```go
resp, ok := env.Data.(map[string]interface{})
```
- 这是**类型断言**,把 `env.Data`(类型是 `interface{}`)转换成 `map[string]interface{}`
- `ok` 是一个布尔值,表示转换是否成功
- 如果转换失败(比如 `env.Data` 是个字符串而不是 map`ok` 就是 `false`
### ③ switch type 语法:
```go
switch v := code.(type) {
case float64:
// code 是浮点数类型时执行这里
}
```
- 这是 Go 的**类型 switch**,用来判断一个 `interface{}` 变量的具体类型
- JSON 解析数字时,默认会转成 `float64` 类型
### ④ 为什么要判断 2xx 状态码?
```go
if v < 200 || v >= 300 {
// 错误处理
}
```
- HTTP 状态码中200-299 表示成功
- 之前的代码只判断了 200/201导致 DELETE 返回 204No Content时被误判为失败
- 现在扩展到所有 2xx 都算成功
---
## 第 101-135 行:`resolveProjectID` 函数(核心!)
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
// 1. 生成缓存 key
key := ctx.Owner + "/" + ctx.Repo
// 2. 先查缓存
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 3. 缓存没命中,调用主 API 获取项目详情
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)
}
// 4. 从响应中提取 project_id
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")
}
// 5. 处理 project_id 的不同类型(可能是 float64 或 int
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)
}
// 6. 存入缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
**字面意思**:根据 owner/repo 解析出项目的数字 ID
**运行时作用**Wiki API 需要 `projectId`(数字),但用户只知道 `owner/repo`(字符串),这个函数就是做转换的。
**小白补充**
### ① 为什么需要 projectID
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 `projectId`,而不是字符串形式的 `owner/repo`。所以必须先调用主 API 获取项目详情,从中提取 `project_id`
### ② 缓存机制:
```go
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
```
- `projectIDCache.Load(key)` 从缓存中查找
- 如果找到(`ok == true`),直接返回缓存的值,不需要再调用 API
- 这是**性能优化**,避免重复请求
### ③ fmt.Sprintf 的用法:
```go
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
```
- 类似 Python 的 `"%s/%s/detail" % (owner, repo)`
- `%s` 是占位符,会被后面的参数替换
### ④ ctx.CallAPI 是什么?
```go
env, err := ctx.CallAPI("GET", path, nil)
```
- `ctx``*common.RuntimeContext` 类型
- `CallAPI``RuntimeContext` 结构体的**方法**(后面会详细讲)
- 它内部调用 `ctx.Client.Do()` 发送 HTTP 请求
---
## 第 137-140 行:`parseProjectIDInt` 函数
```go
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
```
**字面意思**:把字符串形式的 projectID 转成整数
**运行时作用**Wiki API 的某些接口要求 `projectId` 是整数类型,所以需要转换。
**小白补充**
- `strconv.Atoi` 是 string convert to int 的缩写
- `_` 是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
---
## 第 142-154 行:`resolveUpdateContent` 函数
```go
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")
}
```
**字面意思**:解析更新 Wiki 时的内容来源
**运行时作用**:支持两种方式提供内容:直接文本(`--cover`)或文件路径(`--file`)。
---
## 第 156-179 行:`fetchPageContent` 函数(带自动重试)
```go
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
// 第一次尝试
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 成功了,直接返回
}
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
// 两次都失败
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
```
**字面意思**:获取 Wiki 页面的明文内容,带自动重试机制
**运行时作用**:解决 GitLink 后端的一个命名问题。
**小白补充**
### ① GitLink 后端的命名 bug
GitLink 创建 Wiki 页面时,会自动给内部存储的 `sub_url` 追加 `".-"` 后缀。但 `wiki +list` 返回的 `title` 不带后缀。
比如:
- 用户创建页面 "Home"
- 后端实际存储的 key 是 "Home.-"
- 但 list API 返回的 title 是 "Home"
所以用 "Home" 去查询会 404必须用 "Home.-" 才能查到。
### ② 自动重试逻辑:
1. 先用原始 `pageName` 尝试查询
2. 如果失败,且 `pageName` 不带 `".-"` 后缀
3. 自动用 `pageName+".-"` 重试一次
---
## 第 181-205 行:`fetchPageContentOnce` 函数(单次查询)
```go
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
// 构建查询参数
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 API
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
// 解析响应
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
// 提取 base64 编码的内容
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 内容为空,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
```
**字面意思**:单次尝试获取 Wiki 页面内容
**运行时作用**:发送 GET 请求到 `/wiki/open/getWiki`,获取页面数据并解码。
**小白补充**
### ① URL 查询参数构建:
```go
q := url.Values{}
q.Set("owner", ctx.Owner)
```
- `url.Values` 是 map 类型,用来存储查询参数
- 最终会变成 `?owner=zzx&repo=test&projectId=12345&pageName=Home`
### ② Base64 编解码:
```go
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
return string(decoded), pageName, nil // 转成字符串返回
```
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
- 需要解码才能得到人类可读的文本
---
## 第 207-216 行:`fetchWikiPage` 函数
```go
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
```
**字面意思**:获取 Wiki 页面的完整响应(不解码)
**运行时作用**:和 `fetchPageContent` 类似,但返回完整的 `Envelope` 而不是解码后的文本。
---
## 第 218-230 行:`resolveContent` 函数
```go
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")
}
```
**字面意思**:解析创建 Wiki 时的内容来源
**运行时作用**:支持 `--content` 直接传内容,或 `--file` 从文件读取。
**小白补充**
- `ctx.Arg("content")` 是从命令行参数中获取 `--content` 的值
- 如果两个参数都没提供,返回错误
---
## 第 232-249 行:`cleanWikiList` 函数
```go
func cleanWikiList(env *output.Envelope) {
// 把 data 转成 slice
items, ok := env.Data.([]interface{})
if !ok {
return
}
// 遍历每个 wiki 页面
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
// 删除不需要的字段
delete(m, "wiki_clone_link")
// URL 解码 sub_url
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
```
**字面意思**:清理 Wiki 列表数据
**运行时作用**:对 `wiki +list` 返回的数据进行清洗,去掉无用字段,解码 URL。
---
## 第 251-299 行:`outputWithDecodedContent` 函数
```go
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 处理 JSON 字符串形式的 data
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
// 转成 map
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content重命名并解码
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原字段
}
}
// sidebar / footer 原地解码
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
```
**字面意思**:解码 Wiki 响应中的所有 Base64 字段,用明文替换
**运行时作用**:让返回的 Wiki 内容更易读,同时节省 tokenBase64 编码会增加约 33% 的体积)。
---
## 第 301-526 行Lint 相关