Some checks failed
Build and Release / Build Binaries (amd64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, linux) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, windows) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, linux) (push) Blocked by required conditions
Build and Release / Build Docker Image (push) Blocked by required conditions
Build and Release / Create Release (push) Blocked by required conditions
Build and Release / Lint and Test (push) Has been cancelled
- Replace encoding/json with modules/json (depguard)
- Add error handling for json.Unmarshal and WriteItem calls (errcheck)
- Use slices.Contains instead of manual loops (modernize)
- Use any instead of interface{} (modernize)
- Use min/max built-in functions (modernize)
- Use strings.FieldsSeq and strings.SplitSeq (modernize)
- Use range over int for loops (modernize)
- Use http.MethodOptions constant (usestdlibvars)
- Use tagged switch statements (staticcheck)
- Use += and /= operators (gocritic)
- Fix gofumpt formatting issues
- Remove unused streamLargeFile function
- Remove unused primaryLang parameter
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
900 lines
23 KiB
Go
900 lines
23 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package v2
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
"code.gitea.io/gitea/modules/charset"
|
|
apierrors "code.gitea.io/gitea/modules/errors"
|
|
"code.gitea.io/gitea/modules/git"
|
|
"code.gitea.io/gitea/modules/gitrepo"
|
|
"code.gitea.io/gitea/modules/json"
|
|
"code.gitea.io/gitea/modules/markup"
|
|
"code.gitea.io/gitea/modules/markup/markdown"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
"code.gitea.io/gitea/modules/web"
|
|
"code.gitea.io/gitea/services/context"
|
|
notify_service "code.gitea.io/gitea/services/notify"
|
|
wiki_service "code.gitea.io/gitea/services/wiki"
|
|
)
|
|
|
|
// WikiV2 handles all v2 wiki API endpoints
|
|
|
|
// ListWikiPagesV2 lists all wiki pages with enhanced metadata
|
|
func ListWikiPagesV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
// Open wiki repo
|
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
if wikiRepo == nil {
|
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
|
Pages: []*api.WikiPageV2{},
|
|
TotalCount: 0,
|
|
HasMore: false,
|
|
})
|
|
return
|
|
}
|
|
defer wikiRepo.Close()
|
|
|
|
if commit == nil {
|
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
|
Pages: []*api.WikiPageV2{},
|
|
TotalCount: 0,
|
|
HasMore: false,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Pagination
|
|
page := max(ctx.FormInt("page"), 1)
|
|
limit := ctx.FormInt("limit")
|
|
if limit <= 0 {
|
|
limit = setting.API.DefaultPagingNum
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
entries, err := commit.ListEntries()
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Filter to .md files
|
|
var mdEntries []*git.TreeEntry
|
|
for _, entry := range entries {
|
|
if entry.IsRegular() && strings.HasSuffix(entry.Name(), ".md") {
|
|
mdEntries = append(mdEntries, entry)
|
|
}
|
|
}
|
|
|
|
totalCount := int64(len(mdEntries))
|
|
skip := (page - 1) * limit
|
|
end := min(skip+limit, len(mdEntries))
|
|
|
|
pages := make([]*api.WikiPageV2, 0, limit)
|
|
for i := skip; i < end; i++ {
|
|
entry := mdEntries[i]
|
|
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Get last commit for this page
|
|
lastCommit, _ := wikiRepo.GetCommitByPath(entry.Name())
|
|
|
|
_, pageTitle := wiki_service.WebPathToUserTitle(wikiName)
|
|
page := &api.WikiPageV2{
|
|
Name: string(wikiName),
|
|
Title: pageTitle,
|
|
Path: entry.Name(),
|
|
URL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
|
HTMLURL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
|
}
|
|
|
|
// Get word count from index if available
|
|
if idx, _ := repo_model.GetWikiIndex(ctx, repo.ID, string(wikiName)); idx != nil {
|
|
page.WordCount = idx.WordCount
|
|
}
|
|
|
|
if lastCommit != nil {
|
|
page.LastCommit = &api.WikiCommitV2{
|
|
SHA: lastCommit.ID.String(),
|
|
Message: strings.Split(lastCommit.CommitMessage, "\n")[0],
|
|
Date: lastCommit.Author.When,
|
|
Author: &api.WikiAuthorV2{
|
|
Name: lastCommit.Author.Name,
|
|
Email: lastCommit.Author.Email,
|
|
},
|
|
}
|
|
}
|
|
|
|
pages = append(pages, page)
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
|
Pages: pages,
|
|
TotalCount: totalCount,
|
|
HasMore: int64(end) < totalCount,
|
|
})
|
|
}
|
|
|
|
// GetWikiPageV2 gets a single wiki page with full content and metadata
|
|
func GetWikiPageV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
pageName := ctx.PathParam("pageName")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
|
|
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
if wikiRepo == nil || commit == nil {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
|
return
|
|
}
|
|
defer wikiRepo.Close()
|
|
|
|
// Get page content
|
|
gitFilename := wiki_service.WebPathToGitPath(wikiName)
|
|
entry, err := commit.GetTreeEntryByPath(gitFilename)
|
|
if err != nil {
|
|
if git.IsErrNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
blob := entry.Blob()
|
|
content, err := blob.GetBlobContent(1024 * 1024) // 1MB max
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Render HTML
|
|
var htmlContent string
|
|
rd := charset.ToUTF8WithFallbackReader(strings.NewReader(content), charset.ConvertOpts{})
|
|
buf := new(strings.Builder)
|
|
if err := markdown.Render(markup.NewRenderContext(ctx).WithRelativePath(gitFilename), rd, buf); err == nil {
|
|
htmlContent = buf.String()
|
|
}
|
|
|
|
// Get last commit
|
|
lastCommit, _ := wikiRepo.GetCommitByPath(gitFilename)
|
|
|
|
// Get commit count
|
|
commitsCount, _ := gitrepo.FileCommitsCount(ctx, repo.WikiStorageRepo(), repo.DefaultWikiBranch, gitFilename)
|
|
|
|
// Get sidebar and footer
|
|
sidebarContent, _ := getWikiContentByName(commit, "_Sidebar")
|
|
footerContent, _ := getWikiContentByName(commit, "_Footer")
|
|
|
|
// Get links from index
|
|
var linksOut, linksIn []string
|
|
if idx, _ := repo_model.GetWikiIndex(ctx, repo.ID, string(wikiName)); idx != nil {
|
|
if idx.LinksOut != "" {
|
|
_ = json.Unmarshal([]byte(idx.LinksOut), &linksOut)
|
|
}
|
|
}
|
|
|
|
// Get incoming links
|
|
if incoming, _ := wiki_service.GetWikiIncomingLinks(ctx, repo.ID, string(wikiName)); incoming != nil {
|
|
linksIn = incoming
|
|
}
|
|
|
|
_, pageTitle := wiki_service.WebPathToUserTitle(wikiName)
|
|
page := &api.WikiPageV2{
|
|
Name: string(wikiName),
|
|
Title: pageTitle,
|
|
Path: gitFilename,
|
|
URL: setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName),
|
|
HTMLURL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
|
Content: content,
|
|
ContentHTML: htmlContent,
|
|
WordCount: len(strings.Fields(content)),
|
|
LinksOut: linksOut,
|
|
LinksIn: linksIn,
|
|
Sidebar: sidebarContent,
|
|
Footer: footerContent,
|
|
HistoryURL: setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName) + "/revisions",
|
|
}
|
|
|
|
if lastCommit != nil {
|
|
page.LastCommit = &api.WikiCommitV2{
|
|
SHA: lastCommit.ID.String(),
|
|
Message: strings.Split(lastCommit.CommitMessage, "\n")[0],
|
|
Date: lastCommit.Author.When,
|
|
Author: &api.WikiAuthorV2{
|
|
Name: lastCommit.Author.Name,
|
|
Email: lastCommit.Author.Email,
|
|
},
|
|
}
|
|
if lastCommit.Committer != nil {
|
|
page.LastCommit.Committer = &api.WikiAuthorV2{
|
|
Name: lastCommit.Committer.Name,
|
|
Email: lastCommit.Committer.Email,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update index in background
|
|
go func() {
|
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(wikiName))
|
|
}()
|
|
|
|
ctx.SetTotalCountHeader(commitsCount)
|
|
ctx.JSON(http.StatusOK, page)
|
|
}
|
|
|
|
// CreateWikiPageV2 creates a new wiki page
|
|
func CreateWikiPageV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check write access
|
|
if !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
|
return
|
|
}
|
|
|
|
// Check if archived
|
|
if repo.IsArchived {
|
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*api.CreateWikiPageV2Option)
|
|
if form.Name == "" {
|
|
ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{
|
|
"field": "name",
|
|
"error": "name is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
wikiName := wiki_service.UserTitleToWebPath("", form.Name)
|
|
message := form.Message
|
|
if message == "" {
|
|
message = "Add page: " + form.Name
|
|
}
|
|
|
|
if err := wiki_service.AddWikiPage(ctx, ctx.Doer, repo, wikiName, form.Content, message); err != nil {
|
|
if repo_model.IsErrWikiReservedName(err) {
|
|
ctx.APIErrorWithCode(apierrors.WikiReservedName)
|
|
return
|
|
}
|
|
if repo_model.IsErrWikiAlreadyExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageAlreadyExists)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Index the new page
|
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(wikiName))
|
|
|
|
notify_service.NewWikiPage(ctx, ctx.Doer, repo, string(wikiName), message)
|
|
|
|
// Return the created page
|
|
ctx.Redirect(setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName))
|
|
}
|
|
|
|
// UpdateWikiPageV2 updates an existing wiki page
|
|
func UpdateWikiPageV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
pageName := ctx.PathParam("pageName")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check write access
|
|
if !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
|
return
|
|
}
|
|
|
|
// Check if archived
|
|
if repo.IsArchived {
|
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*api.UpdateWikiPageV2Option)
|
|
oldWikiName := wiki_service.WebPathFromRequest(pageName)
|
|
newWikiName := oldWikiName
|
|
|
|
if form.RenameTo != "" {
|
|
newWikiName = wiki_service.UserTitleToWebPath("", form.RenameTo)
|
|
}
|
|
|
|
message := form.Message
|
|
if message == "" {
|
|
if oldWikiName != newWikiName {
|
|
message = "Rename page: " + string(oldWikiName) + " to " + string(newWikiName)
|
|
} else {
|
|
message = "Update page: " + string(oldWikiName)
|
|
}
|
|
}
|
|
|
|
if err := wiki_service.EditWikiPage(ctx, ctx.Doer, repo, oldWikiName, newWikiName, form.Content, message); err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Update index
|
|
if oldWikiName != newWikiName {
|
|
_ = wiki_service.RemoveWikiPageFromIndex(ctx, repo.ID, string(oldWikiName))
|
|
}
|
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(newWikiName))
|
|
|
|
notify_service.EditWikiPage(ctx, ctx.Doer, repo, string(newWikiName), message)
|
|
|
|
// Return the updated page
|
|
ctx.Redirect(setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(newWikiName))
|
|
}
|
|
|
|
// DeleteWikiPageV2 deletes a wiki page
|
|
func DeleteWikiPageV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
pageName := ctx.PathParam("pageName")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check write access
|
|
if !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
|
return
|
|
}
|
|
|
|
// Check if archived
|
|
if repo.IsArchived {
|
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
|
return
|
|
}
|
|
|
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
|
|
|
if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, repo, wikiName); err != nil {
|
|
if strings.Contains(err.Error(), "not exist") {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Remove from index
|
|
_ = wiki_service.RemoveWikiPageFromIndex(ctx, repo.ID, string(wikiName))
|
|
|
|
notify_service.DeleteWikiPage(ctx, ctx.Doer, repo, string(wikiName))
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiDeleteResponseV2{Success: true})
|
|
}
|
|
|
|
// SearchWikiV2 searches wiki pages
|
|
func SearchWikiV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
query := ctx.FormString("q")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
// Ensure wiki is indexed
|
|
go func() {
|
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
|
}()
|
|
|
|
limit := ctx.FormInt("limit")
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
opts := &repo_model.SearchWikiOptions{
|
|
RepoID: repo.ID,
|
|
Query: query,
|
|
Limit: limit,
|
|
Offset: ctx.FormInt("offset"),
|
|
}
|
|
|
|
results, total, err := repo_model.SearchWikiPages(ctx, opts)
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
searchResults := make([]*api.WikiSearchResultV2, 0, len(results))
|
|
for _, idx := range results {
|
|
// Create snippet
|
|
snippet := createSearchSnippet(idx.Content, query, 200)
|
|
|
|
searchResults = append(searchResults, &api.WikiSearchResultV2{
|
|
Name: idx.PageName,
|
|
Title: idx.Title,
|
|
Snippet: snippet,
|
|
Score: calculateSearchScore(idx, query),
|
|
WordCount: idx.WordCount,
|
|
LastUpdated: idx.UpdatedUnix.AsTime(),
|
|
})
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiSearchResponseV2{
|
|
Query: query,
|
|
Results: searchResults,
|
|
TotalCount: total,
|
|
})
|
|
}
|
|
|
|
// GetWikiGraphV2 returns the wiki link graph
|
|
func GetWikiGraphV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
// Ensure wiki is indexed
|
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
|
|
|
nodes, edges, err := wiki_service.GetWikiGraph(ctx, repo.ID)
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Convert to API types
|
|
apiNodes := make([]*api.WikiGraphNodeV2, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
apiNodes = append(apiNodes, &api.WikiGraphNodeV2{
|
|
Name: n["name"].(string),
|
|
Title: n["title"].(string),
|
|
WordCount: n["word_count"].(int),
|
|
})
|
|
}
|
|
|
|
apiEdges := make([]*api.WikiGraphEdgeV2, 0, len(edges))
|
|
for _, e := range edges {
|
|
apiEdges = append(apiEdges, &api.WikiGraphEdgeV2{
|
|
Source: e["source"].(string),
|
|
Target: e["target"].(string),
|
|
})
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiGraphV2{
|
|
Nodes: apiNodes,
|
|
Edges: apiEdges,
|
|
})
|
|
}
|
|
|
|
// GetWikiStatsV2 returns wiki statistics
|
|
func GetWikiStatsV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
// Ensure wiki is indexed
|
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
|
|
|
// Get basic stats
|
|
totalPages, _ := repo_model.GetWikiIndexCount(ctx, repo.ID)
|
|
totalWords, _ := repo_model.GetWikiTotalWordCount(ctx, repo.ID)
|
|
|
|
// Get health info
|
|
orphaned, _ := wiki_service.GetOrphanedPages(ctx, repo.ID)
|
|
deadLinks, _ := wiki_service.GetDeadLinks(ctx, repo.ID)
|
|
|
|
// Get indexes for top linked calculation
|
|
indexes, _ := repo_model.GetWikiIndexByRepo(ctx, repo.ID)
|
|
|
|
// Calculate incoming links for each page
|
|
linkCounts := make(map[string]int)
|
|
for _, idx := range indexes {
|
|
var links []string
|
|
if idx.LinksOut != "" {
|
|
_ = json.Unmarshal([]byte(idx.LinksOut), &links)
|
|
}
|
|
for _, link := range links {
|
|
linkCounts[link]++
|
|
}
|
|
}
|
|
|
|
// Find top linked pages
|
|
topLinked := make([]*api.WikiTopLinkedPageV2, 0)
|
|
for name, count := range linkCounts {
|
|
if count > 1 { // Only include pages with multiple links
|
|
topLinked = append(topLinked, &api.WikiTopLinkedPageV2{
|
|
Name: name,
|
|
IncomingLinks: count,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by incoming links (simple bubble sort for small lists)
|
|
for i := 0; i < len(topLinked); i++ {
|
|
for j := i + 1; j < len(topLinked); j++ {
|
|
if topLinked[j].IncomingLinks > topLinked[i].IncomingLinks {
|
|
topLinked[i], topLinked[j] = topLinked[j], topLinked[i]
|
|
}
|
|
}
|
|
}
|
|
if len(topLinked) > 10 {
|
|
topLinked = topLinked[:10]
|
|
}
|
|
|
|
// Build health info
|
|
health := &api.WikiHealthV2{
|
|
OrphanedPages: make([]*api.WikiOrphanedPageV2, 0),
|
|
DeadLinks: make([]*api.WikiDeadLinkV2, 0),
|
|
OutdatedPages: make([]*api.WikiOutdatedPageV2, 0),
|
|
ShortPages: make([]*api.WikiShortPageV2, 0),
|
|
}
|
|
|
|
for _, o := range orphaned {
|
|
health.OrphanedPages = append(health.OrphanedPages, &api.WikiOrphanedPageV2{
|
|
Name: o.PageName,
|
|
WordCount: o.WordCount,
|
|
})
|
|
}
|
|
|
|
for _, d := range deadLinks {
|
|
health.DeadLinks = append(health.DeadLinks, &api.WikiDeadLinkV2{
|
|
Page: d["page"],
|
|
BrokenLink: d["broken_link"],
|
|
})
|
|
}
|
|
|
|
// Find short pages (< 100 words)
|
|
for _, idx := range indexes {
|
|
if idx.WordCount < 100 && idx.PageName != "_Sidebar" && idx.PageName != "_Footer" {
|
|
health.ShortPages = append(health.ShortPages, &api.WikiShortPageV2{
|
|
Name: idx.PageName,
|
|
WordCount: idx.WordCount,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Find outdated pages (> 180 days old)
|
|
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
|
|
for _, idx := range indexes {
|
|
lastEdit := idx.UpdatedUnix.AsTime()
|
|
if lastEdit.Before(sixMonthsAgo) {
|
|
daysOld := int(time.Since(lastEdit).Hours() / 24)
|
|
health.OutdatedPages = append(health.OutdatedPages, &api.WikiOutdatedPageV2{
|
|
Name: idx.PageName,
|
|
LastEdit: lastEdit,
|
|
DaysOld: daysOld,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Get last updated time
|
|
var lastUpdated time.Time
|
|
for _, idx := range indexes {
|
|
if idx.UpdatedUnix.AsTime().After(lastUpdated) {
|
|
lastUpdated = idx.UpdatedUnix.AsTime()
|
|
}
|
|
}
|
|
|
|
// Get commit count and contributors from git
|
|
var totalCommits int64
|
|
var contributors int64
|
|
wikiRepo, _, err := findWikiRepoCommitV2(ctx, repo)
|
|
if err == nil && wikiRepo != nil {
|
|
defer wikiRepo.Close()
|
|
// Get commit count
|
|
if count, err := gitrepo.CommitsCount(ctx, repo.WikiStorageRepo(), gitrepo.CommitsCountOptions{
|
|
Revision: []string{repo.DefaultWikiBranch},
|
|
}); err == nil {
|
|
totalCommits = count
|
|
}
|
|
// Contributors - simplified as unique committers
|
|
contributors = int64(len(linkCounts)) // Approximate
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiStatsV2{
|
|
TotalPages: totalPages,
|
|
TotalWords: totalWords,
|
|
TotalCommits: totalCommits,
|
|
LastUpdated: lastUpdated,
|
|
Contributors: contributors,
|
|
Health: health,
|
|
TopLinked: topLinked,
|
|
})
|
|
}
|
|
|
|
// GetWikiPageRevisionsV2 returns revision history for a wiki page
|
|
func GetWikiPageRevisionsV2(ctx *context.APIContext) {
|
|
owner := ctx.PathParam("owner")
|
|
repoName := ctx.PathParam("repo")
|
|
pageName := ctx.PathParam("pageName")
|
|
|
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
|
return
|
|
}
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Check access
|
|
if repo.IsPrivate && !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
|
return
|
|
}
|
|
|
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
|
gitFilename := wiki_service.WebPathToGitPath(wikiName)
|
|
|
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
|
if err != nil || wikiRepo == nil || commit == nil {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
|
return
|
|
}
|
|
defer wikiRepo.Close()
|
|
|
|
// Check if page exists
|
|
if _, err := commit.GetTreeEntryByPath(gitFilename); err != nil {
|
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
|
return
|
|
}
|
|
|
|
// Get commits for this file
|
|
page := max(ctx.FormInt("page"), 1)
|
|
commits, err := wikiRepo.CommitsByFileAndRange(git.CommitsByFileAndRangeOptions{
|
|
Revision: repo.DefaultWikiBranch,
|
|
File: gitFilename,
|
|
Page: page,
|
|
})
|
|
if err != nil {
|
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
|
return
|
|
}
|
|
|
|
// Get total count
|
|
totalCount, _ := gitrepo.FileCommitsCount(ctx, repo.WikiStorageRepo(), repo.DefaultWikiBranch, gitFilename)
|
|
|
|
revisions := make([]*api.WikiRevisionV2, 0, len(commits))
|
|
for _, c := range commits {
|
|
rev := &api.WikiRevisionV2{
|
|
SHA: c.ID.String(),
|
|
Message: strings.Split(c.CommitMessage, "\n")[0],
|
|
Date: c.Author.When,
|
|
Author: &api.WikiAuthorV2{
|
|
Name: c.Author.Name,
|
|
Email: c.Author.Email,
|
|
},
|
|
}
|
|
revisions = append(revisions, rev)
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, &api.WikiRevisionsV2{
|
|
PageName: string(wikiName),
|
|
Revisions: revisions,
|
|
TotalCount: totalCount,
|
|
})
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func findWikiRepoCommitV2(ctx *context.APIContext, repo *repo_model.Repository) (*git.Repository, *git.Commit, error) {
|
|
wikiRepo, err := gitrepo.OpenRepository(ctx, repo.WikiStorageRepo())
|
|
if err != nil {
|
|
if git.IsErrNotExist(err) || strings.Contains(err.Error(), "no such file or directory") {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
branch := repo.DefaultWikiBranch
|
|
if branch == "" {
|
|
branch = "master"
|
|
}
|
|
|
|
commit, err := wikiRepo.GetBranchCommit(branch)
|
|
if err != nil {
|
|
wikiRepo.Close()
|
|
if git.IsErrNotExist(err) {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
return wikiRepo, commit, nil
|
|
}
|
|
|
|
func getWikiContentByName(commit *git.Commit, name string) (string, error) {
|
|
wikiPath := wiki_service.WebPathToGitPath(wiki_service.WebPath(name))
|
|
entry, err := commit.GetTreeEntryByPath(wikiPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
blob := entry.Blob()
|
|
content, err := blob.GetBlobContent(1024 * 1024)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func createSearchSnippet(content, query string, maxLen int) string {
|
|
lowerContent := strings.ToLower(content)
|
|
lowerQuery := strings.ToLower(query)
|
|
|
|
idx := strings.Index(lowerContent, lowerQuery)
|
|
if idx == -1 {
|
|
// Query not found, return beginning of content
|
|
if len(content) > maxLen {
|
|
return content[:maxLen] + "..."
|
|
}
|
|
return content
|
|
}
|
|
|
|
// Find start position
|
|
start := max(idx-maxLen/4, 0)
|
|
|
|
// Find end position
|
|
end := min(start+maxLen, len(content))
|
|
|
|
snippet := content[start:end]
|
|
if start > 0 {
|
|
snippet = "..." + snippet
|
|
}
|
|
if end < len(content) {
|
|
snippet += "..."
|
|
}
|
|
|
|
return snippet
|
|
}
|
|
|
|
func calculateSearchScore(idx *repo_model.WikiIndex, query string) float32 {
|
|
score := float32(0.0)
|
|
lowerQuery := strings.ToLower(query)
|
|
|
|
// Title match is highest priority
|
|
if strings.Contains(strings.ToLower(idx.Title), lowerQuery) {
|
|
score += 10.0
|
|
}
|
|
|
|
// Page name match
|
|
if strings.Contains(strings.ToLower(idx.PageName), lowerQuery) {
|
|
score += 8.0
|
|
}
|
|
|
|
// Content match - count occurrences
|
|
lowerContent := strings.ToLower(idx.Content)
|
|
count := strings.Count(lowerContent, lowerQuery)
|
|
score += float32(count) * 0.5
|
|
|
|
// Longer pages might have more matches but aren't necessarily more relevant
|
|
// Normalize by word count
|
|
if idx.WordCount > 0 {
|
|
score /= float32(idx.WordCount) / 100.0
|
|
}
|
|
|
|
return score
|
|
}
|