Compare commits

...

23 Commits

Author SHA1 Message Date
Jonathan Miller 3c56dc8814 feat(wiki): revision diff — view changes for any wiki commit (#667)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m9s
Deploy MokoGitea / deploy (push) Failing after 5m31s
Compare a wiki commit against its parent showing added/removed lines
with color-coded diff view. Accessible via diff icon button in wiki
page header and from revision history. Shows commit metadata
(author, message, timestamp) alongside the diff.
2026-06-21 18:23:09 -05:00
Jonathan Miller dce712fabd Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 6m21s
2026-06-21 18:17:44 -05:00
Jonathan Miller 78b0ce9650 fix: org metadata API respects visibility for unauthenticated requests (#690)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m28s
Add checkOrgVisibility() guard to issue-statuses, issue-priorities,
and issue-types endpoints. Public orgs remain accessible to everyone.
Private/limited orgs return 404 for unauthenticated callers.
2026-06-21 18:17:00 -05:00
jmiller 500a5be6d7 Merge pull request 'Release: Wiki redirects, code review fixes, bug fixes' (#689) from dev into main
Deploy MokoGitea / deploy (push) Failing after 7m34s
2026-06-21 23:14:07 +00:00
Jonathan Miller 95a747b1d5 docs: update changelog with post-#682 fixes and #672
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m27s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 2m43s
PR RC Release / Build RC Release (pull_request) Failing after 2m5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-21 18:13:28 -05:00
Jonathan Miller bb7e99ad40 fix: backlinks template pluralization
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
Deploy MokoGitea / deploy (push) Failing after 6m34s
2026-06-21 18:10:16 -05:00
Jonathan Miller 6c6b7c888e feat(wiki): page rename with automatic redirects (#672)
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m25s
Deploy MokoGitea / deploy (push) Successful in 5m57s
When a wiki page is renamed, automatically create a redirect file at
the old path with YAML frontmatter (redirect: new/path). The redirect
is detected in renderViewPage() before markdown rendering, issuing an
HTTP redirect with a flash message "Redirected from OldPage".
2026-06-21 18:00:56 -05:00
Jonathan Miller 2a1692d599 fix: resolve code review issues in wiki and status features
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 36s
Deploy MokoGitea / deploy (push) Successful in 7m31s
- collectWikiPages: store hyphen-normalized names for flexible lookup
- Backlinks: use wiki_service.GitPathToWebPath for subdirectory URLs
- issue_statuses.tmpl: fix garbled em-dash bytes (0x97 → hyphen)
- backlinks.tmpl: fix pluralization text
2026-06-21 17:57:46 -05:00
Jonathan Miller 6984ac108f Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Successful in 4m47s
2026-06-21 17:39:36 -05:00
Jonathan Miller 3fdbe94830 fix: use commit.MessageTitle() instead of commit.Message() in wiki recent changes
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 35s
The Commit type embeds CommitMessage which provides MessageTitle(),
MessageUTF8(), and MessageBody() — not Message().
2026-06-21 17:38:43 -05:00
jmiller e937dd8d8b Merge pull request 'Release: Wiki system, licensing, issue statuses, metadata API' (#682) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m39s
2026-06-21 22:26:33 +00:00
Jonathan Miller e7b70f54ed docs: add #670 to changelog
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 1m50s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Failing after 1m52s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m11s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 32s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
2026-06-21 17:24:17 -05:00
Jonathan Miller b161561571 Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 5m8s
2026-06-21 17:21:01 -05:00
Jonathan Miller b981cf72e3 feat(wiki): recent changes page — cross-page edit activity (#670)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m4s
Shows all recent wiki edits with page name, author, edit summary,
and timestamp. Paginated. Accessible via "Recent changes" in the
wiki pages dropdown menu.
2026-06-21 17:20:12 -05:00
jmiller 9964c7e16c chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:02:50 +00:00
Jonathan Miller ff27e77c37 docs: update changelog with session 2 changes
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
2026-06-21 16:54:20 -05:00
Jonathan Miller 04ce7dc896 feat(wiki): internal wikilinks with [[Page Name]] syntax (#666)
Deploy MokoGitea / deploy (push) Successful in 4m51s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Add Wikipedia-style [[Page Name]] and [[Page|Display Text]] syntax.
Existing pages render as normal links; non-existent pages render as
red "new page" links. Supports [[folder/Page]], [[Page#Section]],
and [[#Anchor]] patterns.
2026-06-21 15:42:50 -05:00
Jonathan Miller f87f904a21 fix: remove Version field from metadata settings template
Version is no longer stored in RepoMetadata struct — it comes from
the metadata API. The template reference caused a 500 error.
2026-06-21 15:42:46 -05:00
Jonathan Miller fc72d8e90a feat(wiki): add backlinks — "What links here" for wiki pages (#669)
Deploy MokoGitea / deploy (push) Successful in 3m27s
Scan all wiki pages for references to the current page and display
them on a dedicated backlinks page. Uses content search across
markdown files (no database needed). Adds cross-reference button
to wiki page header.
2026-06-21 11:45:20 -05:00
Jonathan Miller 71d52e432e feat: enforce required baseline issue statuses (#681)
Deploy MokoGitea / deploy (push) Successful in 5m8s
Add IsRequired field to IssueStatusDef. Open and Closed statuses are
seeded as required and cannot be deleted. Delete attempts return an
error flash in the web UI and ErrStatusRequired in the model layer.
API response now includes is_required field.
2026-06-21 11:29:49 -05:00
jmiller 172303b61f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:39 +00:00
Jonathan Miller bfb4b53da3 feat: add folder-based tree sidebar to org wiki (#680)
Deploy MokoGitea / deploy (push) Successful in 3m31s
Replace flat page list with hierarchical folder tree in org wiki sidebar.
_Sidebar.md takes precedence when present; otherwise auto-generates
collapsible folder menus up to 2 levels deep.
2026-06-21 11:00:18 -05:00
Jonathan Miller 9149fa100c feat: make metadata/manifest GET endpoint publicly accessible (#676)
Deploy MokoGitea / deploy (push) Successful in 3m30s
Remove reqRepoReader auth requirement from GET /repos/{owner}/{repo}/metadata
and /manifest endpoints. PUT (update) still requires token + admin.
2026-06-21 10:20:56 -05:00
18 changed files with 980 additions and 67 deletions
+43 -7
View File
@@ -10,9 +10,9 @@
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +=======================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
@@ -21,7 +21,7 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +=======================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
@@ -51,7 +51,7 @@ permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
@@ -149,7 +149,7 @@ jobs:
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release: release:
name: Build & Release Pipeline name: Build & Release Pipeline
runs-on: release runs-on: release
@@ -241,11 +241,47 @@ jobs:
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT" [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=stable" >> "$GITHUB_OUTPUT" PLATFORM="${{ steps.platform.outputs.platform }}"
echo "release_tag=stable" >> "$GITHUB_OUTPUT" if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT" echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}" echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog - name: Update release notes and promote changelog
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+16
View File
@@ -88,8 +88,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
@@ -166,6 +178,7 @@ jobs:
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -176,6 +189,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -212,6 +226,7 @@ jobs:
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -225,6 +240,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+19
View File
@@ -10,6 +10,25 @@
- 13 seeded product tiers from base to enterprise - 13 seeded product tiers from base to enterprise
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml - DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
- Profile repo fallback chain: .mokogitea > .profile > .github - Profile repo fallback chain: .mokogitea > .profile > .github
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
- Issue status API response includes is_required field
- Wiki recent changes page: cross-page edit activity with pagination (#670)
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
### Fixed
- Metadata settings template 500 error: removed reference to deleted Version field
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
- Wiki backlinks: proper URL encoding for subdirectory pages
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
- Issue statuses template: garbled em-dash character replaced
### Changed
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
## [06.19.00] --- 2026-06-20 ## [06.19.00] --- 2026-06-20
+32 -6
View File
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48" Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
Description string `xorm:"TEXT"` Description string `xorm:"TEXT"`
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"` ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
@@ -56,14 +57,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
} }
// seedDefaultIssueStatuses creates the standard status presets for an org. // seedDefaultIssueStatuses creates the standard status presets for an org.
// Open and Closed are required (is_required=true) and cannot be deleted.
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error { func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
defaults := []*IssueStatusDef{ defaults := []*IssueStatusDef{
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true}, {OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true}, {OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true}, {OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true}, {OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true}, {OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
} }
for _, d := range defaults { for _, d := range defaults {
if _, err := db.GetEngine(ctx).Insert(d); err != nil { if _, err := db.GetEngine(ctx).Insert(d); err != nil {
@@ -111,13 +113,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
return err return err
} }
// ErrStatusRequired is returned when trying to delete a required status.
type ErrStatusRequired struct {
ID int64
Name string
}
func (e ErrStatusRequired) Error() string {
return "status is required and cannot be deleted"
}
// IsErrStatusRequired checks if an error is ErrStatusRequired.
func IsErrStatusRequired(err error) bool {
_, ok := err.(ErrStatusRequired)
return ok
}
// DeleteIssueStatusDef deletes a status definition and clears references on issues. // DeleteIssueStatusDef deletes a status definition and clears references on issues.
// Returns ErrStatusRequired if the status is marked as required.
func DeleteIssueStatusDef(ctx context.Context, id int64) error { func DeleteIssueStatusDef(ctx context.Context, id int64) error {
def, err := GetIssueStatusDefByID(ctx, id)
if err != nil {
return err
}
if def.IsRequired {
return ErrStatusRequired{ID: def.ID, Name: def.Name}
}
// Clear status_id on all issues that reference this definition // Clear status_id on all issues that reference this definition
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil { if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
return err return err
} }
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef)) _, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
return err return err
} }
+1
View File
@@ -165,6 +165,7 @@ type IssueStatusDef struct {
Color string `json:"color"` Color string `json:"color"`
Description string `json:"description"` Description string `json:"description"`
ClosesIssue bool `json:"closes_issue"` ClosesIssue bool `json:"closes_issue"`
IsRequired bool `json:"is_required"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
+4 -6
View File
@@ -1480,12 +1480,10 @@ func Routes() *web.Router {
Delete(reqToken(), repo.DeleteTopic) Delete(reqToken(), repo.DeleteTopic)
}, reqAdmin()) }, reqAdmin())
}, reqAnyRepoReader()) }, reqAnyRepoReader())
m.Combo("/metadata", reqRepoReader(unit.TypeCode)). m.Get("/metadata", repo.GetRepoMetadata)
Get(repo.GetRepoMetadata). m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata) m.Get("/manifest", repo.GetRepoMetadata) // backward compat
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Get(repo.GetRepoMetadata).
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
// MokoGitea badge engine // MokoGitea badge engine
m.Get("/badge/{type}.svg", repo.GetRepoBadge) m.Get("/badge/{type}.svg", repo.GetRepoBadge)
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
+26
View File
@@ -11,6 +11,19 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
) )
// checkOrgVisibility returns true if the current user can view org metadata.
// Public orgs are visible to everyone. Private/limited orgs require authentication.
func checkOrgVisibility(ctx *context.APIContext) bool {
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
return true
}
if ctx.Doer == nil {
ctx.APIErrorNotFound()
return false
}
return true
}
// ListIssueStatuses returns active issue status definitions for an org. // ListIssueStatuses returns active issue status definitions for an org.
func ListIssueStatuses(ctx *context.APIContext) { func ListIssueStatuses(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses // swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
@@ -34,6 +47,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -47,6 +64,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
Color: d.Color, Color: d.Color,
Description: d.Description, Description: d.Description,
ClosesIssue: d.ClosesIssue, ClosesIssue: d.ClosesIssue,
IsRequired: d.IsRequired,
SortOrder: d.SortOrder, SortOrder: d.SortOrder,
}) })
} }
@@ -76,6 +94,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -118,6 +140,10 @@ func ListIssueTypes(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
+5
View File
@@ -103,6 +103,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
} }
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil { if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
if issues_model.IsErrStatusRequired(err) {
ctx.Flash.Error("Cannot delete required status: " + def.Name)
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
return
}
ctx.ServerError("DeleteIssueStatusDef", err) ctx.ServerError("DeleteIssueStatusDef", err)
return return
} }
+73 -25
View File
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
SubURL string SubURL string
} }
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
type OrgWikiTreeNode struct {
Name string
SubURL string
IsDir bool
Children []*OrgWikiTreeNode
}
// Wiki renders the org wiki tab. // Wiki renders the org wiki tab.
func Wiki(ctx *context.Context) { func Wiki(ctx *context.Context) {
org := ctx.Org.Organization org := ctx.Org.Organization
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
} }
ctx.Data["WikiRepoLink"] = wikiRepo.Link() ctx.Data["WikiRepoLink"] = wikiRepo.Link()
// Build page list from repo root. // Build folder tree for sidebar navigation.
entries, err := commit.ListEntries() wikiTree := buildOrgWikiTree(commit)
if err != nil { ctx.Data["WikiTree"] = wikiTree
ctx.ServerError("ListEntries", err)
return
}
pages := make([]OrgWikiPage, 0, len(entries))
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
name := entry.Name()
if !isMarkdownFile(name) {
continue
}
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
pages = append(pages, OrgWikiPage{
Name: displayName,
SubURL: displayName,
})
}
ctx.Data["Pages"] = pages
// Determine which page to render. // Determine which page to render.
pageName := ctx.PathParamRaw("*") pageName := ctx.PathParamRaw("*")
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplOrgWiki) ctx.HTML(http.StatusOK, tplOrgWiki)
} }
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
// Shows up to 2 levels deep (folders and their immediate children).
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
if commit == nil {
return nil
}
entries, err := commit.ListEntries()
if err != nil {
return nil
}
var topLevel []*OrgWikiTreeNode
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
node := &OrgWikiTreeNode{
Name: name,
SubURL: name,
IsDir: true,
}
// List children of this directory (1 level deep).
subTree := entry.Tree()
if subTree != nil {
children, _ := subTree.ListEntries()
for _, child := range children {
childName := child.Name()
if child.IsDir() {
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: childName,
SubURL: name + "/" + childName,
IsDir: true,
})
} else if isMarkdownFile(childName) {
displayName := strings.TrimSuffix(childName, path.Ext(childName))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: displayName,
SubURL: name + "/" + displayName,
IsDir: false,
})
}
}
}
topLevel = append(topLevel, node)
} else if isMarkdownFile(name) {
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
topLevel = append(topLevel, &OrgWikiTreeNode{
Name: displayName,
SubURL: displayName,
IsDir: false,
})
}
}
return topLevel
}
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit. // findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git). // The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
// Tries fallback repo names (.profile, .github) if the primary doesn't exist. // Tries fallback repo names (.profile, .github) if the primary doesn't exist.
+540 -6
View File
@@ -6,11 +6,14 @@ package repo
import ( import (
"bytes" "bytes"
"html"
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"regexp"
"strconv"
"strings" "strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper"
@@ -38,11 +41,14 @@ import (
) )
const ( const (
tplWikiStart templates.TplName = "repo/wiki/start" tplWikiStart templates.TplName = "repo/wiki/start"
tplWikiView templates.TplName = "repo/wiki/view" tplWikiView templates.TplName = "repo/wiki/view"
tplWikiRevision templates.TplName = "repo/wiki/revision" tplWikiRevision templates.TplName = "repo/wiki/revision"
tplWikiNew templates.TplName = "repo/wiki/new" tplWikiNew templates.TplName = "repo/wiki/new"
tplWikiPages templates.TplName = "repo/wiki/pages" tplWikiPages templates.TplName = "repo/wiki/pages"
tplWikiBacklinks templates.TplName = "repo/wiki/backlinks"
tplWikiRecentChanges templates.TplName = "repo/wiki/recent"
tplWikiDiff templates.TplName = "repo/wiki/diff"
) )
// MustEnableWiki check if wiki is enabled, if external then redirect // MustEnableWiki check if wiki is enabled, if external then redirect
@@ -301,6 +307,14 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
return nil, nil return nil, nil
} }
// Check for redirect frontmatter: ---\nredirect: target\n---
if target := extractWikiRedirect(data); target != "" {
redirectURL := ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(wiki_service.WebPath(target))
ctx.Flash.Info("Redirected from " + displayName)
ctx.Redirect(redirectURL)
return nil, nil
}
rctx := renderhelper.NewRenderContextRepoWiki(ctx, ctx.Repo.Repository) rctx := renderhelper.NewRenderContextRepoWiki(ctx, ctx.Repo.Repository)
renderFn := func(data []byte) (escaped *charset.EscapeStatus, output template.HTML, err error) { renderFn := func(data []byte) (escaped *charset.EscapeStatus, output template.HTML, err error) {
@@ -321,6 +335,9 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
return escaped, output, err return escaped, output, err
} }
// Preprocess wikilinks: [[Page Name]] → HTML links before markdown rendering.
data = preprocessWikilinks(data, commit, ctx.Repo.RepoLink+"/wiki/")
ctx.Data["EscapeStatus"], ctx.Data["WikiContentHTML"], err = renderFn(data) ctx.Data["EscapeStatus"], ctx.Data["WikiContentHTML"], err = renderFn(data)
if err != nil { if err != nil {
ctx.ServerError("Render", err) ctx.ServerError("Render", err)
@@ -357,10 +374,15 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
} }
} }
// get commit count - wiki revisions // get commit count and last commit for wiki revisions
commitsCount, _ := gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository.WikiStorageRepo(), ctx.Repo.Repository.DefaultWikiBranch, pageFilename) commitsCount, _ := gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository.WikiStorageRepo(), ctx.Repo.Repository.DefaultWikiBranch, pageFilename)
ctx.Data["CommitCount"] = commitsCount ctx.Data["CommitCount"] = commitsCount
// Pass last commit ID for diff link
if lastCommit, err := wikiGitRepo.GetCommitByPath(pageFilename); err == nil && lastCommit != nil {
ctx.Data["LastCommitID"] = lastCommit.ID.String()
}
return wikiGitRepo, entry return wikiGitRepo, entry
} }
@@ -504,6 +526,15 @@ func Wiki(ctx *context.Context) {
case "_revision": case "_revision":
WikiRevision(ctx) WikiRevision(ctx)
return return
case "_backlinks":
WikiBacklinks(ctx)
return
case "_recent":
WikiRecentChanges(ctx)
return
case "_diff":
WikiDiff(ctx)
return
case "_edit": case "_edit":
if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) {
ctx.NotFound(nil) ctx.NotFound(nil)
@@ -561,6 +592,376 @@ func Wiki(ctx *context.Context) {
} }
// WikiRevision renders file revision list of wiki page // WikiRevision renders file revision list of wiki page
// WikiBacklinks shows all wiki pages that link to the current page.
func WikiBacklinks(ctx *context.Context) {
ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived
if !repo_service.HasWiki(ctx, ctx.Repo.Repository) {
ctx.Data["Title"] = ctx.Tr("repo.wiki")
ctx.HTML(http.StatusOK, tplWikiStart)
return
}
_, commit, err := findWikiRepoCommit(ctx)
if err != nil {
if !git.IsErrNotExist(err) {
ctx.ServerError("findWikiRepoCommit", err)
}
return
}
pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*"))
if len(pageName) == 0 {
pageName = "Home"
}
_, displayName := wiki_service.WebPathToUserTitle(pageName)
ctx.Data["Title"] = "What links here: " + displayName
ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
ctx.Data["title"] = displayName
searchTerms := []string{
string(pageName), // raw path
wiki_service.WebPathToURLPath(wiki_service.WebPath(pageName)), // URL-encoded path
displayName, // display name
}
type BacklinkResult struct {
PageName string
PageURL string
Context string
}
seen := make(map[string]bool)
var backlinks []BacklinkResult
entries, _ := commit.ListEntries()
for _, entry := range entries {
if !entry.IsRegular() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
entryName := strings.TrimSuffix(entry.Name(), ".md")
if entryName == string(pageName) || entryName == "_Sidebar" || entryName == "_Footer" {
continue
}
blob := entry.Blob()
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
continue
}
for _, term := range searchTerms {
if strings.Contains(content, term) && !seen[entryName] {
seen[entryName] = true
// Extract a line of context around the match
contextLine := ""
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, term) {
contextLine = strings.TrimSpace(line)
if len(contextLine) > 200 {
contextLine = contextLine[:200] + "..."
}
break
}
}
wpName, _ := wiki_service.GitPathToWebPath(entry.Name())
_, linkDisplay := wiki_service.WebPathToUserTitle(wpName)
backlinks = append(backlinks, BacklinkResult{
PageName: linkDisplay,
PageURL: string(wpName),
Context: contextLine,
})
break
}
}
}
// Also search subdirectories (1 level deep)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subTree := entry.Tree()
if subTree == nil {
continue
}
children, _ := subTree.ListEntries()
for _, child := range children {
if !child.IsRegular() || !strings.HasSuffix(child.Name(), ".md") {
continue
}
fullPath := entry.Name() + "/" + strings.TrimSuffix(child.Name(), ".md")
if fullPath == string(pageName) || seen[fullPath] {
continue
}
childName := strings.TrimSuffix(child.Name(), ".md")
if childName == "_Sidebar" || childName == "_Footer" {
continue
}
blob := child.Blob()
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
continue
}
for _, term := range searchTerms {
if strings.Contains(content, term) && !seen[fullPath] {
seen[fullPath] = true
contextLine := ""
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, term) {
contextLine = strings.TrimSpace(line)
if len(contextLine) > 200 {
contextLine = contextLine[:200] + "..."
}
break
}
}
wpChild, _ := wiki_service.GitPathToWebPath(child.Name())
_, childDisplay := wiki_service.WebPathToUserTitle(wpChild)
backlinks = append(backlinks, BacklinkResult{
PageName: childDisplay,
PageURL: entry.Name() + "/" + string(wpChild),
Context: contextLine,
})
break
}
}
}
}
ctx.Data["Backlinks"] = backlinks
ctx.Data["BacklinkCount"] = len(backlinks)
ctx.HTML(http.StatusOK, tplWikiBacklinks)
}
// WikiRecentChanges shows all recent wiki edits across all pages.
func WikiRecentChanges(ctx *context.Context) {
ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived
ctx.Data["Title"] = "Recent changes"
if !repo_service.HasWiki(ctx, ctx.Repo.Repository) {
ctx.Data["Title"] = ctx.Tr("repo.wiki")
ctx.HTML(http.StatusOK, tplWikiStart)
return
}
wikiGitRepo, _, err := findWikiRepoCommit(ctx)
if err != nil {
if !git.IsErrNotExist(err) {
ctx.ServerError("findWikiRepoCommit", err)
}
return
}
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
branch := ctx.Repo.Repository.DefaultWikiBranch
if branch == "" {
branch = "main"
}
// Get all commits (no file filter = all wiki changes).
commits, err := wikiGitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: branch,
Page: page,
},
)
if err != nil {
ctx.ServerError("CommitsByFileAndRange", err)
return
}
type RecentChange struct {
PageName string
PageURL string
Author string
Message string
When timeutil.TimeStamp
SHA string
}
var changes []RecentChange
for _, commit := range commits {
// Get files changed by comparing to parent
var changedFiles []string
parents := commit.Parents
if len(parents) > 0 {
changedFiles, _ = commit.GetFilesChangedSinceCommit(parents[0].String())
}
pageNames := make([]string, 0)
for _, f := range changedFiles {
if strings.HasSuffix(f, ".md") {
name := strings.TrimSuffix(f, ".md")
if name != "_Sidebar" && name != "_Footer" {
pageNames = append(pageNames, name)
}
}
}
displayPage := ""
pageURL := ""
if len(pageNames) == 1 {
displayPage = pageNames[0]
pageURL = strings.ReplaceAll(pageNames[0], " ", "-")
} else if len(pageNames) > 1 {
displayPage = pageNames[0] + " (+" + strconv.Itoa(len(pageNames)-1) + " more)"
pageURL = strings.ReplaceAll(pageNames[0], " ", "-")
} else if len(changedFiles) > 0 {
// Non-markdown files changed (images, etc.)
displayPage = changedFiles[0]
}
msg := commit.MessageTitle()
changes = append(changes, RecentChange{
PageName: displayPage,
PageURL: pageURL,
Author: commit.Author.Name,
Message: msg,
When: timeutil.TimeStamp(commit.Author.When.Unix()),
SHA: commit.ID.String()[:10],
})
}
ctx.Data["RecentChanges"] = changes
ctx.Data["CurrentPage"] = page
ctx.Data["HasNextPage"] = len(commits) >= setting.Git.CommitsRangeSize
ctx.Data["HasPrevPage"] = page > 1
ctx.HTML(http.StatusOK, tplWikiRecentChanges)
}
// WikiDiff shows the diff between a commit and its parent for a wiki page.
func WikiDiff(ctx *context.Context) {
ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived
if !repo_service.HasWiki(ctx, ctx.Repo.Repository) {
ctx.Data["Title"] = ctx.Tr("repo.wiki")
ctx.HTML(http.StatusOK, tplWikiStart)
return
}
wikiGitRepo, _, err := findWikiRepoCommit(ctx)
if err != nil {
if !git.IsErrNotExist(err) {
ctx.ServerError("findWikiRepoCommit", err)
}
return
}
commitID := ctx.FormString("commit")
if commitID == "" {
ctx.Redirect(ctx.Repo.RepoLink + "/wiki/")
return
}
pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*"))
if len(pageName) == 0 {
pageName = "Home"
}
_, displayName := wiki_service.WebPathToUserTitle(pageName)
ctx.Data["Title"] = "Diff: " + displayName
ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
ctx.Data["title"] = displayName
commit, err := wikiGitRepo.GetCommit(commitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return
}
ctx.Data["CommitID"] = commit.ID.String()[:10]
ctx.Data["CommitMessage"] = commit.MessageTitle()
ctx.Data["CommitAuthor"] = commit.Author.Name
ctx.Data["CommitWhen"] = timeutil.TimeStamp(commit.Author.When.Unix())
// Get the file path for this wiki page
wikiPath := string(pageName) + ".md"
// Get content at this commit
blob, _ := commit.GetBlobByPath(wikiPath)
newContent := ""
if blob != nil {
newContent, _ = blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
}
// Get content at parent commit
oldContent := ""
if len(commit.Parents) > 0 {
parentCommit, err := wikiGitRepo.GetCommit(commit.Parents[0].String())
if err == nil {
parentBlob, _ := parentCommit.GetBlobByPath(wikiPath)
if parentBlob != nil {
oldContent, _ = parentBlob.GetBlobContent(setting.UI.MaxDisplayFileSize)
}
}
}
// Build a simple line-by-line diff
type DiffLine struct {
Type string // "add", "del", "ctx"
Content string
OldNum int
NewNum int
}
oldLines := strings.Split(oldContent, "\n")
newLines := strings.Split(newContent, "\n")
var diffLines []DiffLine
// Simple diff: show removed lines then added lines
// For a proper diff we'd use a real diff algorithm, but this gives useful output
oldSet := make(map[string]bool)
newSet := make(map[string]bool)
for _, l := range oldLines {
oldSet[l] = true
}
for _, l := range newLines {
newSet[l] = true
}
oldNum := 0
newNum := 0
maxLines := len(oldLines)
if len(newLines) > maxLines {
maxLines = len(newLines)
}
// Walk through both files showing context, deletions, and additions
for i := 0; i < maxLines; i++ {
if i < len(oldLines) && i < len(newLines) && oldLines[i] == newLines[i] {
oldNum++
newNum++
diffLines = append(diffLines, DiffLine{Type: "ctx", Content: oldLines[i], OldNum: oldNum, NewNum: newNum})
} else {
if i < len(oldLines) {
oldNum++
diffLines = append(diffLines, DiffLine{Type: "del", Content: oldLines[i], OldNum: oldNum})
}
if i < len(newLines) {
newNum++
diffLines = append(diffLines, DiffLine{Type: "add", Content: newLines[i], NewNum: newNum})
}
}
}
ctx.Data["DiffLines"] = diffLines
ctx.Data["HasDiff"] = oldContent != newContent
ctx.Data["IsNewPage"] = oldContent == ""
ctx.Data["IsDeletedPage"] = newContent == ""
ctx.HTML(http.StatusOK, tplWikiDiff)
}
func WikiRevision(ctx *context.Context) { func WikiRevision(ctx *context.Context) {
ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived
@@ -790,6 +1191,13 @@ func EditWikiPost(ctx *context.Context) {
return return
} }
// If page was renamed, create a redirect at the old path.
if oldWikiName != newWikiName {
_, newDisplay := wiki_service.WebPathToUserTitle(newWikiName)
redirectContent := "---\nredirect: " + string(newWikiName) + "\n---\nThis page has moved to [" + newDisplay + "](" + wiki_service.WebPathToURLPath(newWikiName) + ").\n"
_ = wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, redirectContent, "redirect: "+string(oldWikiName)+" → "+string(newWikiName))
}
notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message)
ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName)) ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName))
@@ -829,6 +1237,132 @@ func buildWikiBreadcrumbs(pageName wiki_service.WebPath) []WikiBreadcrumb {
return crumbs return crumbs
} }
// extractWikiRedirect checks if page content starts with YAML frontmatter containing
// a "redirect:" field. Returns the target page path or empty string.
func extractWikiRedirect(data []byte) string {
content := string(data)
if !strings.HasPrefix(content, "---\n") {
return ""
}
endIdx := strings.Index(content[4:], "\n---")
if endIdx < 0 {
return ""
}
frontmatter := content[4 : 4+endIdx]
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "redirect:") {
target := strings.TrimSpace(strings.TrimPrefix(line, "redirect:"))
return target
}
}
return ""
}
// wikilinkPattern matches [[Page Name]] and [[Page Name|Display Text]] syntax.
var wikilinkPattern = regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
// preprocessWikilinks replaces [[Page Name]] syntax with HTML links before markdown rendering.
// Existing pages get normal links; non-existent pages get red "new page" links.
func preprocessWikilinks(data []byte, commit *git.Commit, wikiBaseURL string) []byte {
if commit == nil {
return data
}
// Build a set of existing page paths for quick lookup.
existingPages := make(map[string]bool)
collectWikiPages(commit, "", existingPages)
result := wikilinkPattern.ReplaceAllFunc(data, func(match []byte) []byte {
parts := wikilinkPattern.FindSubmatch(match)
if len(parts) < 2 {
return match
}
pagePath := strings.TrimSpace(string(parts[1]))
displayText := pagePath
if len(parts) >= 3 && len(parts[2]) > 0 {
displayText = strings.TrimSpace(string(parts[2]))
}
// Handle anchor links: [[#Section]] or [[Page#Section]]
anchor := ""
if idx := strings.Index(pagePath, "#"); idx >= 0 {
anchor = pagePath[idx:]
pagePath = pagePath[:idx]
}
// Pure anchor link on current page
if pagePath == "" && anchor != "" {
return []byte(`<a href="` + html.EscapeString(anchor) + `">` + html.EscapeString(displayText) + `</a>`)
}
// Normalize the page path for lookup
lookupKey := strings.ReplaceAll(pagePath, " ", "-")
// Check if page exists (try with and without folder prefix)
pageExists := existingPages[lookupKey] ||
existingPages[strings.ToLower(lookupKey)]
escapedURL := html.EscapeString(wikiBaseURL + url.PathEscape(lookupKey) + anchor)
escapedText := html.EscapeString(displayText)
if pageExists {
return []byte(`<a href="` + escapedURL + `">` + escapedText + `</a>`)
}
// Red link for non-existent pages — links to create page
createURL := html.EscapeString(wikiBaseURL + "?action=_new&title=" + url.QueryEscape(pagePath))
return []byte(`<a href="` + createURL + `" class="wiki-link-new" title="Page does not exist (click to create)">` + escapedText + `</a>`)
})
return result
}
// collectWikiPages builds a set of all wiki page paths from the commit tree.
// Stores both raw names and hyphen-normalized names for flexible lookup.
func collectWikiPages(commit *git.Commit, prefix string, pages map[string]bool) {
entries, err := commit.ListEntries()
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
subTree := entry.Tree()
if subTree == nil {
continue
}
children, _ := subTree.ListEntries()
for _, child := range children {
childName := child.Name()
if child.IsRegular() && strings.HasSuffix(childName, ".md") {
pageName := strings.TrimSuffix(childName, ".md")
fullPath := name + "/" + pageName
registerWikiPage(pages, fullPath)
}
}
} else if strings.HasSuffix(name, ".md") {
pageName := strings.TrimSuffix(name, ".md")
if prefix != "" {
pageName = prefix + "/" + pageName
}
registerWikiPage(pages, pageName)
}
}
}
// registerWikiPage adds a page name to the lookup map with multiple normalizations.
func registerWikiPage(pages map[string]bool, name string) {
pages[name] = true
pages[strings.ToLower(name)] = true
// Also store hyphen-normalized version (spaces → hyphens)
hyphenized := strings.ReplaceAll(name, " ", "-")
if hyphenized != name {
pages[hyphenized] = true
pages[strings.ToLower(hyphenized)] = true
}
}
// buildWikiTree builds a hierarchical folder tree from the wiki git repo. // buildWikiTree builds a hierarchical folder tree from the wiki git repo.
func buildWikiTree(commit *git.Commit) []*WikiTreeNode { func buildWikiTree(commit *git.Commit) []*WikiTreeNode {
if commit == nil { if commit == nil {
@@ -28,6 +28,7 @@
</td> </td>
<td> <td>
<strong>{{.Name}}</strong> <strong>{{.Name}}</strong>
{{if .IsRequired}}<span class="ui mini blue label" title="Required status - cannot be deleted">{{svg "octicon-lock" 10}} required</span>{{end}}
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}} {{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}} {{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
</td> </td>
@@ -40,10 +41,14 @@
</td> </td>
<td>{{.SortOrder}}</td> <td>{{.SortOrder}}</td>
<td class="tw-text-right"> <td class="tw-text-right">
{{if .IsRequired}}
<span class="ui tiny icon button disabled" title="Required - cannot be deleted">{{svg "octicon-lock" 14}}</span>
{{else}}
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline"> <form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}} {{$.CsrfTokenHtml}}
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button> <button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
</form> </form>
{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
+37 -12
View File
@@ -11,7 +11,7 @@
This organization doesn't have a wiki yet. This organization doesn't have a wiki yet.
</div> </div>
<p class="tw-text-center"> <p class="tw-text-center">
Enable the wiki on the <code>.profile</code> (public) or <code>.profile-private</code> (members-only) Enable the wiki on the <code>.mokogitea</code> (public) or <code>.mokogitea-private</code> (members-only)
repository to get started. repository to get started.
</p> </p>
</div> </div>
@@ -47,34 +47,59 @@
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p> <p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
</div> </div>
</div> </div>
{{if .Pages}} {{if .WikiTree}}
<h4>Available pages:</h4> <h4>Available pages:</h4>
<ul> <ul>
{{range .Pages}} {{range .WikiTree}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li> {{if .IsDir}}
{{range .Children}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}}
{{else}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}}
{{end}} {{end}}
</ul> </ul>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div class="wiki-content-parts"> <div class="wiki-content-parts">
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .Pages}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{.WikiContent}} {{.WikiContent}}
</div> </div>
{{if or .WikiSidebarHTML .Pages}} {{if or .WikiSidebarHTML .WikiTree}}
<div class="render-content markup wiki-content-sidebar"> <div class="render-content markup wiki-content-sidebar">
{{if .WikiSidebarHTML}} {{if .WikiSidebarHTML}}
{{.WikiSidebarHTML}} {{.WikiSidebarHTML}}
<div class="ui divider"></div> {{else if .WikiTree}}
{{end}}
{{if .Pages}}
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong> <strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
<ul class="wiki-tree-list"> <ul class="wiki-tree-list">
{{range .Pages}} {{range .WikiTree}}
<li> <li>
{{svg "octicon-file" 14}} {{if .IsDir}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a> <details open>
<summary>{{svg "octicon-file-directory" 14}} <strong>{{.Name}}</strong></summary>
{{if .Children}}
<ul>
{{range .Children}}
<li>
{{if .IsDir}}
{{svg "octicon-file-directory" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li>
{{end}}
</ul>
{{end}}
</details>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li> </li>
{{end}} {{end}}
</ul> </ul>
+1 -5
View File
@@ -21,11 +21,7 @@
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization"> <input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
</div> </div>
</div> </div>
<div class="four fields"> <div class="three fields">
<div class="field">
<label>Version</label>
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
</div>
<div class="field"> <div class="field">
<label>Version Prefix</label> <label>Version Prefix</label>
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko."> <input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
+42
View File
@@ -0,0 +1,42 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row">
<div class="tw-flex tw-items-center tw-gap-2">
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}">
{{svg "octicon-arrow-left" 14}} Back to {{.title}}
</a>
</div>
</div>
<h2>{{svg "octicon-cross-reference" 20}} What links here: {{.title}}</h2>
{{if .Backlinks}}
<div class="ui relaxed divided list">
{{range .Backlinks}}
<div class="item">
<div class="content">
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{if .Context}}
<div class="description">
<code class="tw-text-sm">{{.Context}}</code>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.BacklinkCount}} {{if eq .BacklinkCount 1}}page{{else}}pages{{end}} linking here.</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-unlink" 48}}
<br>
No pages link to "{{.title}}"
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+51
View File
@@ -0,0 +1,51 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<a href="{{.RepoLink}}/wiki/{{.PageURL}}">{{svg "octicon-arrow-left" 14}} {{.title}}</a>
&middot;
<a href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision">Revision history</a>
</div>
</div>
<div class="ui segment">
<h3>{{svg "octicon-diff" 20}} Changes in <code>{{.CommitID}}</code></h3>
<p>
<strong>{{.CommitAuthor}}</strong> &mdash; {{.CommitMessage}}
<br>
<small class="text grey">{{DateUtils.TimeSince .CommitWhen}}</small>
</p>
{{if .IsNewPage}}
<div class="ui info message">New page created</div>
{{else if .IsDeletedPage}}
<div class="ui warning message">Page deleted</div>
{{else if not .HasDiff}}
<div class="ui info message">No content changes in this revision</div>
{{end}}
{{if .HasDiff}}
<div class="diff-file-box" style="overflow-x: auto;">
<table class="chroma" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 13px;">
{{range .DiffLines}}
<tr class="{{if eq .Type "add"}}diff-line-add{{else if eq .Type "del"}}diff-line-del{{else}}diff-line-context{{end}}">
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .OldNum}}{{.OldNum}}{{end}}
</td>
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .NewNum}}{{.NewNum}}{{end}}
</td>
<td style="padding: 0 8px; white-space: pre-wrap; word-break: break-all; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #fff;{{end}}">
{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}} {{.Content}}
</td>
</tr>
{{end}}
</table>
</div>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}
+72
View File
@@ -0,0 +1,72 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<h2>{{svg "octicon-history" 20}} Recent changes</h2>
</div>
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
{{if .RecentChanges}}
<table class="ui compact table">
<thead>
<tr>
<th>Page</th>
<th>Author</th>
<th>Edit summary</th>
<th>When</th>
<th>Commit</th>
</tr>
</thead>
<tbody>
{{range .RecentChanges}}
<tr>
<td>
{{if .PageURL}}
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{else if .PageName}}
{{svg "octicon-file" 14}} {{.PageName}}
{{else}}
<span class="text grey">—</span>
{{end}}
</td>
<td>{{.Author}}</td>
<td class="gt-ellipsis" style="max-width: 400px;">{{.Message}}</td>
<td>{{DateUtils.TimeSince .When}}</td>
<td><code class="tw-text-xs">{{.SHA}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<div class="tw-flex tw-justify-between tw-mt-4">
{{if .HasPrevPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "-" 1}}">
{{svg "octicon-chevron-left" 14}} Newer
</a>
{{else}}
<span></span>
{{end}}
{{if .HasNextPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "+" 1}}">
Older {{svg "octicon-chevron-right" 14}}
</a>
{{end}}
</div>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-history" 48}}
<br>
No recent changes
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+3
View File
@@ -20,6 +20,7 @@
</div> </div>
<div class="scrolling menu"> <div class="scrolling menu">
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a> <a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_recent">{{svg "octicon-history" 14}} Recent changes</a>
<div class="divider"></div> <div class="divider"></div>
{{range .Pages}} {{range .Pages}}
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a> <a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
@@ -34,6 +35,8 @@
<div class="flex-text-block tw-flex-wrap tw-justify-end"> <div class="flex-text-block tw-flex-wrap tw-justify-end">
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]"> <div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a> <a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
<a class="ui basic button tw-px-3" title="What links here" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_backlinks">{{svg "octicon-cross-reference"}}</a>
{{if .LastCommitID}}<a class="ui basic button tw-px-3" title="View last change" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_diff&commit={{.LastCommitID}}">{{svg "octicon-diff"}}</a>{{end}}
<div class="tw-flex-1 gt-ellipsis"> <div class="tw-flex-1 gt-ellipsis">
{{$title}} {{$title}}
<div class="ui sub header gt-ellipsis"> <div class="ui sub header gt-ellipsis">
+10
View File
@@ -86,3 +86,13 @@
max-width: unset; max-width: unset;
} }
} }
/* Wikilinks: red links for non-existent pages */
.wiki .wiki-link-new {
color: var(--color-red);
}
.wiki .wiki-link-new:hover {
color: var(--color-red);
text-decoration: underline;
}