feat(build): improve lint-locale-usage further (#8736)

Print out a list of all unused msgids
Handle Go files that make calls to translation.
Handle `models/unit/unit.go`, which stores msgids in `$Unit.NameKey`
Handle .locale.Tr in templates
Handle simple dynamically constructed `Tr("msgid-prefix." + SomeFunctionCall())`.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8736
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de>
Co-committed-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de>
This commit is contained in:
Ellen Εμιλία Άννα Zscheile 2025-08-27 23:47:34 +02:00 committed by Gusted
commit f447661345
18 changed files with 720 additions and 320 deletions

2
.gitignore vendored
View file

@ -55,6 +55,8 @@ cpu.out
*.log
*.log.*.gz
/build/lint-locale/lint-locale
/build/lint-locale-usage/lint-locale-usage
/gitea
/gitea-vet
/debug

View file

@ -39,4 +39,5 @@ options/locale/.* @0ko
options/locale_next/.* @0ko
# Personal interest
build/lint-locale-usage/.* @fogti
.*/webhook.* @oliverpool

View file

@ -463,7 +463,7 @@ lint-locale:
.PHONY: lint-locale-usage
lint-locale-usage:
$(GO) run build/lint-locale-usage/lint-locale-usage.go
$(GO) run ./build/lint-locale-usage --allow-masked-usages-from=build/lint-locale-usage/allowed-masked-usage.txt
.PHONY: lint-md
lint-md: node_modules

View file

@ -0,0 +1,50 @@
# translation tooling test keys
meta.last_line
translation_meta.test
# models/admin/task.go: instances of $TranslatableMessage.Format
# this also gets instantiated as a Messenger once
repo.migrate.migrating_failed.error
# models/asymkey/gpg_key_object_verification.go: $ObjectVerification.Reason
# unfortunately, it is non-trivial to parse all the occurences
gpg.error.extract_sign
gpg.error.failed_retrieval_gpg_keys
gpg.error.generate_hash
gpg.error.no_committer_account
# models/system/notice.go: func (n *Notice) TrStr() string
admin.notices.type_1
admin.notices.type_2
# modules/setting/ui.go
themes.names.
# services/context/context.go
relativetime.
# templates/mail/issue/default.tmpl: $.locale.Tr
mail.issue.in_tree_path
# templates/package/metadata/arch.tmpl: $.locale.Tr
packages.details.license
# templates/repo/issue/view_content.tmpl: indirection via $closeTranslationKey
repo.issues.close
repo.pulls.close
# templates/repo/issue/view_content/comments.tmpl: indirection via $refTr
repo.issues.ref_closing_from
repo.issues.ref_issue_from
repo.issues.ref_pull_from
repo.issues.ref_reopening_from
# templates/repo/issue/view_content/comments.tmpl: ctx.Locale.Tr (printf "projects.type-%d.display_name" .OldProject.Type)
projects.
projects.type-1.display_name
projects.type-2.display_name
projects.type-3.display_name
# templates/repo/settings/webhook/link_menu.tmpl, templates/webhook/new.tmpl: repo.settings.web_hook_name_
# tests/integration/repo_archive_text_test.go
repo.settings.

View file

@ -0,0 +1,217 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"strconv"
"strings"
)
func (handler Handler) handleGoTrBasicLit(fset *token.FileSet, argLit *ast.BasicLit, prefix string) {
if argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err != nil {
return
}
// found interesting strings
arg = prefix + arg
if strings.HasSuffix(arg, ".") || strings.HasSuffix(arg, "_") {
prep, trunc := PrepareMsgidPrefix(arg)
if trunc {
handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf("needed to truncate message id prefix: %s", arg))
}
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
} else {
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
}
}
func (handler Handler) handleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.handleGoTrBasicLit(fset, argLit, prefix)
} else if argBinExpr, ok := n.(*ast.BinaryExpr); ok {
if argBinExpr.Op != token.ADD {
// pass
} else if argLit, ok := argBinExpr.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err != nil {
return
}
// found interesting strings
arg = prefix + arg
prep, trunc := PrepareMsgidPrefix(arg)
if trunc {
handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf("needed to truncate message id prefix: %s", arg))
}
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
}
}
}
func (handler Handler) handleGoCommentGroup(fset *token.FileSet, cg *ast.CommentGroup, commentPrefix string) *string {
if cg == nil {
return nil
}
var matches []token.Pos
matchInsPrefix := ""
commentPrefix = "//" + commentPrefix
for _, comment := range cg.List {
ctxt := strings.TrimSpace(comment.Text)
if ctxt == commentPrefix {
matches = append(matches, comment.Slash)
} else if after, found := strings.CutPrefix(ctxt, commentPrefix+"Suffix "); found {
matches = append(matches, comment.Slash)
matchInsPrefix = strings.TrimSpace(after)
}
}
switch len(matches) {
case 0:
return nil
case 1:
return &matchInsPrefix
default:
handler.OnWarning(
fset,
matches[0],
fmt.Sprintf("encountered multiple %s... directives, ignoring", strings.TrimSpace(commentPrefix)),
)
return &matchInsPrefix
}
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution|goParser.ParseComments)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Go parser",
Err: err,
}
}
ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
switch n2 := n.(type) {
case *ast.CallExpr:
if len(n2.Args) == 0 {
return true
}
funSel, ok := n2.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name]
if !ok {
return true
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(n2.Args) <= int(argNum) {
argc := len(n2.Args)
gotUnexpectedInvoke = &argc
} else {
handler.handleGoTrArgument(fset, n2.Args[int(argNum)], "")
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
case *ast.CompositeLit:
ident, ok := n2.Type.(*ast.Ident)
if !ok {
return true
}
// special case: models/unit/unit.go
if strings.HasSuffix(fname, "unit.go") && ident.Name == "Unit" {
if len(n2.Elts) != 6 {
handler.OnWarning(fset, n2.Pos(), "unexpected initialization of 'Unit' (unexpected number of arguments)")
}
// NameKey has index 2
// invoked like '{{ctx.Locale.Tr $unit.NameKey}}'
nameKey, ok := n2.Elts[2].(*ast.BasicLit)
if !ok || nameKey.Kind != token.STRING {
handler.OnWarning(fset, n2.Elts[2].Pos(), "unexpected initialization of 'Unit' (expected string literal as NameKey)")
return true
}
// extract string content
arg, err := strconv.Unquote(nameKey.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, nameKey.ValuePos, arg)
}
}
case *ast.FuncDecl:
matchInsPrefix := handler.handleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey")
if matchInsPrefix == nil {
return true
}
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true
}
ast.Inspect(n2.Body, func(n ast.Node) bool {
// search for return stmts
// TODO: what about nested functions?
if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
ast.Inspect(res, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
handler.handleGoTrArgument(fset, expr, *matchInsPrefix)
}
return true
})
}
return false
}
return true
})
return true
case *ast.GenDecl:
if !(n2.Tok == token.CONST || n2.Tok == token.VAR) {
return true
}
matchInsPrefix := handler.handleGoCommentGroup(fset, n2.Doc, " llu:TrKeys")
if matchInsPrefix == nil {
return true
}
for _, spec := range n2.Specs {
// interpret all contained strings as message IDs
ast.Inspect(spec, func(n ast.Node) bool {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.handleGoTrBasicLit(fset, argLit, *matchInsPrefix)
return false
}
return true
})
}
}
return true
})
return nil
}

View file

@ -0,0 +1,233 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"go/token"
"os"
"strings"
"text/template"
tmplParser "text/template/parse"
fjTemplates "forgejo.org/modules/templates"
"forgejo.org/modules/util"
)
// derived from source: modules/templates/scopedtmpl/scopedtmpl.go, L169-L213
func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.Node) {
switch node.Type() {
case tmplParser.NodeAction:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.ActionNode).Pipe)
case tmplParser.NodeList:
nodeList := node.(*tmplParser.ListNode)
handler.handleTemplateFileNodes(fset, nodeList.Nodes)
case tmplParser.NodePipe:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.PipeNode))
case tmplParser.NodeTemplate:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.TemplateNode).Pipe)
case tmplParser.NodeIf:
nodeIf := node.(*tmplParser.IfNode)
handler.handleTemplateBranchNode(fset, nodeIf.BranchNode)
case tmplParser.NodeRange:
nodeRange := node.(*tmplParser.RangeNode)
handler.handleTemplateBranchNode(fset, nodeRange.BranchNode)
case tmplParser.NodeWith:
nodeWith := node.(*tmplParser.WithNode)
handler.handleTemplateBranchNode(fset, nodeWith.BranchNode)
case tmplParser.NodeCommand:
nodeCommand := node.(*tmplParser.CommandNode)
handler.handleTemplateFileNodes(fset, nodeCommand.Args)
if len(nodeCommand.Args) < 2 {
return
}
funcname := ""
if nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode); ok {
if nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode); ok {
if nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
return
}
funcname = nodeChain.Field[1]
}
} else if nodeField, ok := nodeCommand.Args[0].(*tmplParser.FieldNode); ok {
if len(nodeField.Ident) != 2 || !(nodeField.Ident[0] == "locale" || nodeField.Ident[0] == "Locale") {
return
}
funcname = nodeField.Ident[1]
}
var gotUnexpectedInvoke *int
ltf, ok := handler.LocaleTrFunctions[funcname]
if !ok {
return
}
for _, argNum := range ltf {
if len(nodeCommand.Args) >= int(argNum+2) {
handler.handleTemplateMsgid(fset, nodeCommand.Args[int(argNum+1)])
} else {
argc := len(nodeCommand.Args) - 1
gotUnexpectedInvoke = &argc
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, token.Pos(nodeCommand.Pos), funcname, *gotUnexpectedInvoke)
}
default:
}
}
func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser.Node) {
// the column numbers are a bit "off", but much better than nothing
pos := token.Pos(node.Position())
switch node.Type() {
case tmplParser.NodeString:
nodeString := node.(*tmplParser.StringNode)
// found interesting strings
handler.OnMsgid(fset, pos, nodeString.Text)
case tmplParser.NodePipe:
nodePipe := node.(*tmplParser.PipeNode)
handler.handleTemplatePipeNode(fset, nodePipe)
if len(nodePipe.Cmds) == 0 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (no commands): %s", node.String()))
} else if len(nodePipe.Cmds) != 1 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (too many commands): %s", node.String()))
return
}
nodeCommand := nodePipe.Cmds[0]
if len(nodeCommand.Args) < 2 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (not enough arguments): %s", node.String()))
return
}
nodeIdent, ok := nodeCommand.Args[0].(*tmplParser.IdentifierNode)
if !ok || (nodeIdent.Ident != "print" && nodeIdent.Ident != "printf") {
// handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (bad command): %s", node.String()))
return
}
nodeString, ok := nodeCommand.Args[1].(*tmplParser.StringNode)
if !ok {
//handler.OnWarning(
// fset,
// pos,
// fmt.Sprintf("unsupported invocation of locate function (string should be first argument to %s): %s", nodeIdent.Ident, node.String()),
//)
return
}
msgidPrefix := nodeString.Text
stringPos := token.Pos(nodeString.Pos)
if len(nodeCommand.Args) == 2 {
// found interesting strings
handler.OnMsgid(fset, stringPos, msgidPrefix)
} else {
if nodeIdent.Ident == "printf" {
parts := strings.SplitN(msgidPrefix, "%", 2)
if len(parts) != 2 {
handler.OnWarning(
fset,
stringPos,
fmt.Sprintf("unsupported invocation of locate function (format string doesn't match \"prefix%%smth\" pattern): %s", nodeString.String()),
)
return
}
msgidPrefix = parts[0]
}
msgidPrefixFin, truncated := PrepareMsgidPrefix(msgidPrefix)
if truncated {
handler.OnWarning(fset, stringPos, fmt.Sprintf("needed to truncate message id prefix: %s", msgidPrefix))
}
// found interesting strings
handler.OnMsgidPrefix(fset, stringPos, msgidPrefixFin, truncated)
}
default:
// handler.OnWarning(fset, pos, fmt.Sprintf("unknown invocation of locate function: %s", node.String()))
}
}
func (handler Handler) handleTemplatePipeNode(fset *token.FileSet, pipeNode *tmplParser.PipeNode) {
if pipeNode == nil {
return
}
// NOTE: we can't pass `pipeNode.Cmds` to handleTemplateFileNodes due to incompatible argument types
for _, node := range pipeNode.Cmds {
handler.handleTemplateNode(fset, node)
}
}
func (handler Handler) handleTemplateBranchNode(fset *token.FileSet, branchNode tmplParser.BranchNode) {
handler.handleTemplatePipeNode(fset, branchNode.Pipe)
handler.handleTemplateFileNodes(fset, branchNode.List.Nodes)
if branchNode.ElseList != nil {
handler.handleTemplateFileNodes(fset, branchNode.ElseList.Nodes)
}
}
func (handler Handler) handleTemplateFileNodes(fset *token.FileSet, nodes []tmplParser.Node) {
for _, node := range nodes {
handler.handleTemplateNode(fset, node)
}
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func (handler Handler) HandleTemplateFile(fname string, src any) error {
var tmplContent []byte
switch src2 := src.(type) {
case nil:
var err error
tmplContent, err = os.ReadFile(fname)
if err != nil {
return LocatedError{
Location: fname,
Kind: "ReadFile",
Err: err,
}
}
case []byte:
tmplContent = src2
case string:
// SAFETY: we do not modify tmplContent below
tmplContent = util.UnsafeStringToBytes(src2)
default:
panic("invalid type for 'src'")
}
fset := token.NewFileSet()
fset.AddFile(fname, 1, len(tmplContent)).SetLinesForContent(tmplContent)
// SAFETY: we do not modify tmplContent2 below
tmplContent2 := util.UnsafeBytesToString(tmplContent)
tmpl := template.New(fname)
tmpl.Funcs(fjTemplates.NewFuncMap())
tmplParsed, err := tmpl.Parse(tmplContent2)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Template parser",
Err: err,
}
}
handler.handleTemplateFileNodes(fset, tmplParsed.Root.Nodes)
return nil
}

View file

@ -5,22 +5,19 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strconv"
"sort"
"strings"
"text/template"
tmplParser "text/template/parse"
"forgejo.org/modules/container"
fjTemplates "forgejo.org/modules/templates"
"forgejo.org/modules/translation/localeiter"
"forgejo.org/modules/util"
)
// this works by first gathering all valid source string IDs from `en-US` reference files
@ -63,241 +60,180 @@ func InitLocaleTrFunctions() map[string][]uint {
type Handler struct {
OnMsgid func(fset *token.FileSet, pos token.Pos, msgid string)
OnMsgidPrefix func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool)
OnUnexpectedInvoke func(fset *token.FileSet, pos token.Pos, funcname string, argc int)
OnWarning func(fset *token.FileSet, pos token.Pos, msg string)
LocaleTrFunctions map[string][]uint
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
type StringTrie interface {
Matches(key []string) bool
}
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution)
type StringTrieMap map[string]StringTrie
func (m StringTrieMap) Matches(key []string) bool {
if len(key) == 0 || m == nil {
return true
}
value, ok := m[key[0]]
if !ok {
return false
}
if value == nil {
return true
}
return value.Matches(key[1:])
}
func (m StringTrieMap) Insert(key []string) {
if m == nil {
return
}
switch len(key) {
case 0:
return
case 1:
m[key[0]] = nil
default:
if value, ok := m[key[0]]; ok {
if value == nil {
return
}
} else {
m[key[0]] = make(StringTrieMap)
}
m[key[0]].(StringTrieMap).Insert(key[1:])
}
}
func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], allowedMaskedPrefixes StringTrieMap, chkMsgid func(msgid string) bool) error {
file, err := os.Open(fname)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Go parser",
Kind: "Open",
Err: err,
}
}
defer file.Close()
ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 1 {
return true
}
funSel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name]
if !ok {
return true
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(call.Args) >= int(argNum+1) {
argLit, ok := call.Args[int(argNum)].(*ast.BasicLit)
if !ok || argLit.Kind != token.STRING {
scanner := bufio.NewScanner(file)
lno := 0
for scanner.Scan() {
lno++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
if linePrefix, found := strings.CutSuffix(line, "."); found {
allowedMaskedPrefixes.Insert(strings.Split(linePrefix, "."))
} else {
argc := len(call.Args)
gotUnexpectedInvoke = &argc
if !chkMsgid(line) {
return LocatedError{
Location: fmt.Sprintf("%s: line %d", fname, lno),
Kind: "undefined msgid",
Err: errors.New(line),
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
return true
})
return nil
}
// derived from source: modules/templates/scopedtmpl/scopedtmpl.go, L169-L213
func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.Node) {
switch node.Type() {
case tmplParser.NodeAction:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.ActionNode).Pipe)
case tmplParser.NodeList:
nodeList := node.(*tmplParser.ListNode)
handler.handleTemplateFileNodes(fset, nodeList.Nodes)
case tmplParser.NodePipe:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.PipeNode))
case tmplParser.NodeTemplate:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.TemplateNode).Pipe)
case tmplParser.NodeIf:
nodeIf := node.(*tmplParser.IfNode)
handler.handleTemplateBranchNode(fset, nodeIf.BranchNode)
case tmplParser.NodeRange:
nodeRange := node.(*tmplParser.RangeNode)
handler.handleTemplateBranchNode(fset, nodeRange.BranchNode)
case tmplParser.NodeWith:
nodeWith := node.(*tmplParser.WithNode)
handler.handleTemplateBranchNode(fset, nodeWith.BranchNode)
case tmplParser.NodeCommand:
nodeCommand := node.(*tmplParser.CommandNode)
handler.handleTemplateFileNodes(fset, nodeCommand.Args)
if len(nodeCommand.Args) < 2 {
return
}
nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode)
if !ok {
return
}
nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode)
if !ok || nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
return
}
ltf, ok := handler.LocaleTrFunctions[nodeChain.Field[1]]
if !ok {
return
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(nodeCommand.Args) >= int(argNum+2) {
nodeString, ok := nodeCommand.Args[int(argNum+1)].(*tmplParser.StringNode)
if ok {
// found interesting strings
// the column numbers are a bit "off", but much better than nothing
handler.OnMsgid(fset, token.Pos(nodeString.Pos), nodeString.Text)
}
} else {
argc := len(nodeCommand.Args) - 1
gotUnexpectedInvoke = &argc
usedMsgids.Add(line)
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, token.Pos(nodeChain.Pos), nodeChain.Field[1], *gotUnexpectedInvoke)
}
default:
}
}
func (handler Handler) handleTemplatePipeNode(fset *token.FileSet, pipeNode *tmplParser.PipeNode) {
if pipeNode == nil {
return
}
// NOTE: we can't pass `pipeNode.Cmds` to handleTemplateFileNodes due to incompatible argument types
for _, node := range pipeNode.Cmds {
handler.handleTemplateNode(fset, node)
}
}
func (handler Handler) handleTemplateBranchNode(fset *token.FileSet, branchNode tmplParser.BranchNode) {
handler.handleTemplatePipeNode(fset, branchNode.Pipe)
handler.handleTemplateFileNodes(fset, branchNode.List.Nodes)
if branchNode.ElseList != nil {
handler.handleTemplateFileNodes(fset, branchNode.ElseList.Nodes)
}
}
func (handler Handler) handleTemplateFileNodes(fset *token.FileSet, nodes []tmplParser.Node) {
for _, node := range nodes {
handler.handleTemplateNode(fset, node)
}
}
func (handler Handler) HandleTemplateFile(fname string, src any) error {
var tmplContent []byte
switch src2 := src.(type) {
case nil:
var err error
tmplContent, err = os.ReadFile(fname)
if err != nil {
if err := scanner.Err(); err != nil {
return LocatedError{
Location: fname,
Kind: "ReadFile",
Kind: "Scanner",
Err: err,
}
}
case []byte:
tmplContent = src2
case string:
// SAFETY: we do not modify tmplContent below
tmplContent = util.UnsafeStringToBytes(src2)
default:
panic("invalid type for 'src'")
}
fset := token.NewFileSet()
fset.AddFile(fname, 1, len(tmplContent)).SetLinesForContent(tmplContent)
// SAFETY: we do not modify tmplContent2 below
tmplContent2 := util.UnsafeBytesToString(tmplContent)
tmpl := template.New(fname)
tmpl.Funcs(fjTemplates.NewFuncMap())
tmplParsed, err := tmpl.Parse(tmplContent2)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Template parser",
Err: err,
}
}
handler.handleTemplateFileNodes(fset, tmplParsed.Root.Nodes)
return nil
}
// This command assumes that we get started from the project root directory
//
// Possible command line flags:
//
// --allow-missing-msgids don't return an error code if missing message IDs are found
//
// EXIT CODES:
//
// 0 success, no issues found
// 1 unable to walk directory tree
// 2 unable to parse locale ini/json files
// 3 unable to parse go or text/template files
// 4 found missing message IDs
//
// Truncating a message id prefix to the last dot
func PrepareMsgidPrefix(s string) (string, bool) {
index := strings.LastIndexByte(s, 0x2e)
if index == -1 {
return "", true
}
return s[:index], index != len(s)-1
}
func Usage() {
outp := flag.CommandLine.Output()
fmt.Fprintf(outp, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(outp, "\nThis command assumes that it gets started from the project root directory.\n")
fmt.Fprintf(outp, "\nExit codes:\n")
for _, i := range []string{
"0\tsuccess, no issues found",
"1\tunable to walk directory tree",
"2\tunable to parse locale ini/json files",
"3\tunable to parse go or text/template files",
"4\tfound missing message IDs",
"5\tfound unused message IDs",
} {
fmt.Fprintf(outp, "\t%s\n", i)
}
fmt.Fprintf(outp, "\nSpecial Go doc comments:\n")
for _, i := range []string{
"//llu:returnsTrKey",
"\tcan be used in front of functions to indicate",
"\tthat the function returns message IDs",
"\tWARNING: this currently doesn't support nested functions properly",
"",
"//llu:returnsTrKeySuffix prefix.",
"\tsimilar to llu:returnsTrKey, but the given prefix is prepended",
"\tto the found strings before interpreting them as msgids",
"",
"// llu:TrKeys",
"\tcan be used in front of 'const' and 'var' blocks",
"\tin order to mark all contained strings as message IDs",
"",
"// llu:TrKeysSuffix prefix.",
"\tlike llu:returnsTrKeySuffix, but for 'const' and 'var' blocks",
} {
if i == "" {
fmt.Fprintf(outp, "\n")
} else {
fmt.Fprintf(outp, "\t%s\n", i)
}
}
}
//nolint:forbidigo
func main() {
allowMissingMsgids := false
for _, arg := range os.Args[1:] {
if arg == "--allow-missing-msgids" {
allowMissingMsgids = true
}
}
allowUnusedMsgids := false
usedMsgids := make(container.Set[string])
allowedMaskedPrefixes := make(StringTrieMap)
onError := func(err error) {
if err == nil {
return
}
fmt.Println(err.Error())
os.Exit(3)
// It's possible for execl to hand us an empty os.Args.
if len(os.Args) == 0 {
flag.CommandLine = flag.NewFlagSet("lint-locale-usage", flag.ExitOnError)
} else {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}
flag.CommandLine.Usage = Usage
flag.Usage = Usage
flag.BoolVar(
&allowMissingMsgids,
"allow-missing-msgids",
false,
"don't return an error code if missing message IDs are found",
)
flag.BoolVar(
&allowUnusedMsgids,
"allow-unused-msgids",
false,
"don't return an error code if unused message IDs are found",
)
msgids := make(container.Set[string])
@ -334,17 +270,50 @@ func main() {
gotAnyMsgidError := false
flag.Func(
"allow-masked-usages-from",
"supply a file containing a newline-separated list of allowed masked usages",
func(argval string) error {
return ParseAllowedMaskedUsages(argval, usedMsgids, allowedMaskedPrefixes, func(msgid string) bool {
return msgids.Contains(msgid)
})
},
)
flag.Parse()
onError := func(err error) {
if err == nil {
return
}
fmt.Println(err.Error())
os.Exit(3)
}
handler := Handler{
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) {
msgidPrefixSplit := strings.Split(msgidPrefix, ".")
if !truncated {
allowedMaskedPrefixes.Insert(msgidPrefixSplit)
} else if !allowedMaskedPrefixes.Matches(msgidPrefixSplit) {
gotAnyMsgidError = true
fmt.Printf("%s:\tmissing msgid prefix: %s\n", fset.Position(pos).String(), msgidPrefix)
}
},
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string) {
if !msgids.Contains(msgid) {
gotAnyMsgidError = true
fmt.Printf("%s:\tmissing msgid: %s\n", fset.Position(pos).String(), msgid)
} else {
usedMsgids.Add(msgid)
}
},
OnUnexpectedInvoke: func(fset *token.FileSet, pos token.Pos, funcname string, argc int) {
gotAnyMsgidError = true
fmt.Printf("%s:\tunexpected invocation of %s with %d arguments\n", fset.Position(pos).String(), funcname, argc)
},
OnWarning: func(fset *token.FileSet, pos token.Pos, msg string) {
fmt.Printf("%s:\tWARNING: %s\n", fset.Position(pos).String(), msg)
},
LocaleTrFunctions: InitLocaleTrFunctions(),
}
@ -377,7 +346,27 @@ func main() {
os.Exit(1)
}
unusedMsgids := []string{}
for msgid := range msgids {
if !usedMsgids.Contains(msgid) && !allowedMaskedPrefixes.Matches(strings.Split(msgid, ".")) {
unusedMsgids = append(unusedMsgids, msgid)
}
}
sort.Strings(unusedMsgids)
if len(unusedMsgids) != 0 {
fmt.Printf("=== unused msgids (%d): ===\n", len(unusedMsgids))
for _, msgid := range unusedMsgids {
fmt.Printf("- %s\n", msgid)
}
}
if !allowMissingMsgids && gotAnyMsgidError {
os.Exit(4)
}
if !allowUnusedMsgids && len(unusedMsgids) != 0 {
os.Exit(5)
}
}

View file

@ -36,6 +36,7 @@ type ObjectVerification struct {
TrustStatus string
}
// llu:TrKeys
const (
// BadSignature is used as the reason when the signature has a KeyID that is in the db
// but no key that has that ID verifies the signature. This is a suspicious failure.

View file

@ -469,6 +469,8 @@ func (issue *Issue) GetLastEventTimestamp() timeutil.TimeStamp {
}
// GetLastEventLabel returns the localization label for the current issue.
//
//llu:returnsTrKey
func (issue *Issue) GetLastEventLabel() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {
@ -494,6 +496,8 @@ func (issue *Issue) GetLastComment(ctx context.Context) (*Comment, error) {
}
// GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username.
//
//llu:returnsTrKey
func (issue *Issue) GetLastEventLabelFake() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {

View file

@ -48,6 +48,7 @@ const (
AbuseCategoryTypeIllegalContent // 4
)
// llu:TrKeys
var AbuseCategoriesTranslationKeys = map[AbuseCategoryType]string{
AbuseCategoryTypeSpam: "moderation.abuse_category.spam",
AbuseCategoryTypeMalware: "moderation.abuse_category.malware",

View file

@ -182,6 +182,8 @@ func init() {
}
// GetCardConfig retrieves the types of configurations project column cards could have
//
//llu:returnsTrKey
func GetCardConfig() []CardConfig {
return []CardConfig{
{CardTypeTextOnly, "repo.projects.card_type.text_only"},

View file

@ -26,6 +26,8 @@ const (
)
// GetTemplateConfigs retrieves the template configs of configurations project columns could have
//
//llu:returnsTrKey
func GetTemplateConfigs() []TemplateConfig {
return []TemplateConfig{
{TemplateTypeNone, "repo.projects.type.none"},

View file

@ -271,7 +271,6 @@ type Unit struct {
Name string
NameKey string
URI string
DescKey string
Idx int
MaxAccessMode perm.AccessMode // The max access mode of the unit. i.e. Read means this unit can only be read.
}
@ -299,7 +298,6 @@ var (
"code",
"repo.code",
"/",
"repo.code.desc",
0,
perm.AccessModeOwner,
}
@ -309,7 +307,6 @@ var (
"issues",
"repo.issues",
"/issues",
"repo.issues.desc",
1,
perm.AccessModeOwner,
}
@ -319,7 +316,6 @@ var (
"ext_issues",
"repo.ext_issues",
"/issues",
"repo.ext_issues.desc",
1,
perm.AccessModeRead,
}
@ -329,7 +325,6 @@ var (
"pulls",
"repo.pulls",
"/pulls",
"repo.pulls.desc",
2,
perm.AccessModeOwner,
}
@ -339,7 +334,6 @@ var (
"releases",
"repo.releases",
"/releases",
"repo.releases.desc",
3,
perm.AccessModeOwner,
}
@ -349,7 +343,6 @@ var (
"wiki",
"repo.wiki",
"/wiki",
"repo.wiki.desc",
4,
perm.AccessModeOwner,
}
@ -359,7 +352,6 @@ var (
"ext_wiki",
"repo.ext_wiki",
"/wiki",
"repo.ext_wiki.desc",
4,
perm.AccessModeRead,
}
@ -369,7 +361,6 @@ var (
"projects",
"repo.projects",
"/projects",
"repo.projects.desc",
5,
perm.AccessModeOwner,
}
@ -379,7 +370,6 @@ var (
"packages",
"repo.packages",
"/packages",
"packages.desc",
6,
perm.AccessModeRead,
}
@ -389,7 +379,6 @@ var (
"actions",
"repo.actions",
"/actions",
"actions.unit.desc",
7,
perm.AccessModeOwner,
}

View file

@ -15,7 +15,6 @@ version = Version
powered_by = Powered by %s
page = Page
template = Template
language = Language
notifications = Notifications
active_stopwatch = Active time tracker
tracked_time_summary = Summary of tracked time based on filters of issue list
@ -53,11 +52,7 @@ webauthn_error_empty = You must set a name for this key.
webauthn_error_timeout = Timeout reached before your key could be read. Please reload this page and retry.
repository = Repository
organization = Organization
mirror = Mirror
new_mirror = New mirror
new_fork = New repository fork
new_project = New project
new_project_column = New column
admin_panel = Site administration
settings = Settings
@ -104,7 +99,6 @@ disabled = Disabled
locked = Locked
copy = Copy
copy_generic = Copy to clipboard
copy_url = Copy URL
copy_hash = Copy hash
copy_path = Copy path
@ -168,14 +162,6 @@ filter.private = Private
[search]
search = Search…
type_tooltip = Search type
fuzzy = Fuzzy
fuzzy_tooltip = Include results that also match the search term closely
union = Union
union_tooltip = Include results that match any of the whitespace separated keywords
exact = Exact
exact_tooltip = Include only results that match the exact search term
regexp = RegExp
regexp_tooltip = Interpret the search term as a regular expression
repo_kind = Search repos…
user_kind = Search users…
org_kind = Search orgs…
@ -367,7 +353,6 @@ save_config_failed = Failed to save configuration: %v
enable_update_checker_helper_forgejo = It will periodically check for new Forgejo versions by checking a TXT DNS record at release.forgejo.org.
invalid_admin_setting = Administrator account setting is invalid: %v
invalid_log_root_path = The log path is invalid: %v
allow_dots_in_usernames = Allow users to use dots in their usernames. Doesn't affect existing accounts.
no_reply_address = Hidden email domain
no_reply_address_helper = Domain name for users with a hidden email address. For example, the username "joe" will be logged in Git as "joe@noreply.example.org" if the hidden email domain is set to "noreply.example.org".
password_algorithm = Password hash algorithm
@ -535,8 +520,6 @@ totp_enrolled.subject = You have activated TOTP as 2FA method
totp_enrolled.text_1.no_webauthn = You have just enabled TOTP for your account. This means that for all future logins to your account, you must use TOTP as a 2FA method.
totp_enrolled.text_1.has_webauthn = You have just enabled TOTP for your account. This means that for all future logins to your account, you could use TOTP as a 2FA method or use any of your security keys.
register_success = Registration successful
issue_assigned.pull = @%[1]s assigned you to pull request %[2]s in repository %[3]s.
issue_assigned.issue = @%[1]s assigned you to issue %[2]s in repository %[3]s.
@ -581,7 +564,6 @@ yes = Yes
no = No
confirm = Confirm
cancel = Cancel
modify = Update
[form]
UserName = Username
@ -733,17 +715,14 @@ form.name_chars_not_allowed = Username "%s" contains invalid characters.
profile = Profile
account = Account
appearance = Appearance
password = Password
security = Security
avatar = Avatar
ssh_gpg_keys = SSH / GPG keys
applications = Applications
orgs = Organizations
repos = Repositories
delete = Delete account
twofa = Two-factor authentication (TOTP)
organization = Organizations
uid = UID
webauthn = Two-factor authentication (Security keys)
blocked_users = Blocked users
storage_overview = Storage overview
@ -765,12 +744,10 @@ update_language = Change language
update_language_not_found = Language "%s" is not available.
update_language_success = Language has been updated.
update_profile_success = Your profile has been updated.
change_username = Your username has been changed.
change_username_prompt = Note: Changing your username also changes your account URL.
change_username_redirect_prompt = The old username will redirect until someone claims it.
change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old username during the cooldown period.
change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old username during the cooldown period.
continue = Continue
cancel = Cancel
language = Language
language.title = Default language
@ -961,9 +938,7 @@ permissions_list = Permissions:
manage_oauth2_applications = Manage OAuth2 applications
edit_oauth2_application = Edit OAuth2 Application
oauth2_applications_desc = OAuth2 applications enables your third-party application to securely authenticate users at this Forgejo instance.
remove_oauth2_application = Remove OAuth2 Application
remove_oauth2_application_desc = Removing an OAuth2 application will revoke access to all signed access tokens. Continue?
remove_oauth2_application_success = The application has been deleted.
create_oauth2_application = Create a new OAuth2 application
create_oauth2_application_button = Create application
@ -977,7 +952,6 @@ oauth2_client_id = Client ID
oauth2_client_secret = Client secret
oauth2_regenerate_secret = Regenerate secret
oauth2_regenerate_secret_hint = Lost your secret?
oauth2_client_secret_hint = The secret will not be shown again after you leave or refresh this page. Please ensure that you have saved it.
oauth2_application_edit = Edit
oauth2_application_create_description = OAuth2 applications gives your third-party application access to user accounts on this instance.
oauth2_application_remove_description = Removing an OAuth2 application will prevent it from accessing authorized user accounts on this instance. Continue?
@ -1118,11 +1092,8 @@ open_with_editor = Open with %s
download_zip = Download ZIP
download_tar = Download TAR.GZ
download_bundle = Download BUNDLE
generate_repo = Generate repository
generate_from = Generate from
repo_desc = Description
repo_desc_helper = Enter short description (optional)
repo_lang = Language
repo_gitignore_helper = Select .gitignore templates
repo_gitignore_helper_desc = Choose which files not to track from a list of templates for common languages. Typical artifacts generated by each language's build tools are included on .gitignore by default.
issue_labels = Labels
@ -1160,7 +1131,6 @@ mirror_lfs = Large File Storage (LFS)
mirror_lfs_desc = Activate mirroring of LFS data.
mirror_lfs_endpoint = LFS endpoint
mirror_lfs_endpoint_desc = Sync will attempt to use the clone url to <a target="_blank" rel="noopener noreferrer" href="%s">determine the LFS server</a>. You can also specify a custom endpoint if the repository LFS data is stored somewhere else.
mirror_last_synced = Last synchronized
mirror_password_placeholder = (Unchanged)
mirror_password_blank_placeholder = (Unset)
mirror_password_help = Change the username to erase a stored password.
@ -1169,7 +1139,6 @@ stargazers = Stargazers
stars_remove_warning = This will remove all stars from this repository.
forks = Forks
stars = Stars
reactions_more = and %d more
unit_disabled = The site administrator has disabled this repository section.
language_other = Other
adopt_search = Enter username to search for unadopted repositories… (leave blank to find all)
@ -1187,9 +1156,9 @@ blame.ignore_revs.failed = Failed to ignore revisions in <a href="%s">.git-blame
author_search_tooltip = Shows a maximum of 30 users
summary_card_alt = Summary card of repository %s
tree_path_not_found_commit = Path %[1]s doesn't exist in commit %[2]s
tree_path_not_found_branch = Path %[1]s doesn't exist in branch %[2]s
tree_path_not_found_tag = Path %[1]s doesn't exist in tag %[2]s
tree_path_not_found.commit = Path %[1]s doesn't exist in commit %[2]s
tree_path_not_found.branch = Path %[1]s doesn't exist in branch %[2]s
tree_path_not_found.tag = Path %[1]s doesn't exist in tag %[2]s
transfer.accept = Accept transfer
transfer.accept_desc = Transfer to "%s"
@ -1199,7 +1168,6 @@ transfer.no_permission_to_accept = You do not have permission to accept this tra
transfer.no_permission_to_reject = You do not have permission to reject this transfer.
desc.private = Private
desc.public = Public
desc.template = Template
desc.internal = Internal
desc.archived = Archived
@ -1301,7 +1269,6 @@ unwatch = Unwatch
star = Star
unstar = Unstar
fork = Fork
download_archive = Download repository
more_operations = More operations
no_desc = No description
@ -1315,29 +1282,22 @@ broken_message = The Git data underlying this repository cannot be read. Contact
code = Code
code.desc = Access source code, files, commits and branches.
branch = Branch
tree = Tree
clear_ref = `Clear current reference`
filter_branch_and_tag = Filter branch or tag
find_tag = Find tag
branches = Branches
tag = Tag
tags = Tags
issues = Issues
pulls = Pull requests
project = Projects
packages = Packages
actions = Actions
release = Release
releases = Releases
labels = Labels
milestones = Milestones
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
org_labels_desc_manage = manage
commits = Commits
commit = Commit
n_commit_one=%s commit
n_commit_few=%s commits
n_branch_one=%s branch
@ -1431,7 +1391,6 @@ editor.propose_file_change = Propose file change
editor.new_branch_name = Name the new branch for this commit
editor.new_branch_name_desc = New branch name…
editor.cancel = Cancel
editor.filename_cannot_be_empty = The filename cannot be empty.
editor.filename_is_invalid = The filename is invalid: "%s".
editor.invalid_commit_mail = Invalid mail for creating a commit.
editor.branch_does_not_exist = Branch "%s" does not exist in this repository.
@ -1465,7 +1424,6 @@ editor.cherry_pick = Cherry-pick %s onto:
editor.revert = Revert %s onto:
editor.commit_email = Commit email
commits.desc = Browse source code change history.
commits.commits = Commits
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.
commits.nothing_to_compare = These branches are equal.
@ -1477,8 +1435,6 @@ commits.message = Message
commits.browse_further = Browse further
commits.renamed_from = Renamed from %s
commits.date = Date
commits.older = Older
commits.newer = Newer
commits.signed_by = Signed by
commits.signed_by_untrusted_user = Signed by untrusted user
commits.signed_by_untrusted_user_unmatched = Signed by untrusted user who does not match committer
@ -1503,7 +1459,6 @@ commitstatus.success = Success
ext_issues = External issues
projects = Projects
projects.desc = Manage issues and pulls in project boards.
projects.description = Description (optional)
projects.description_placeholder = Description
projects.create = Create project
@ -1540,7 +1495,6 @@ projects.card_type.desc = Card previews
projects.card_type.images_and_text = Images and text
projects.card_type.text_only = Text only
issues.desc = Organize bug reports, tasks and milestones.
issues.filter_assignees = Filter assignee
issues.filter_milestones = Filter milestone
issues.filter_projects = Filter project
@ -1583,7 +1537,6 @@ issues.new_label = New label
issues.new_label_placeholder = Label name
issues.new_label_desc_placeholder = Description
issues.create_label = Create label
issues.label_templates.title = Load a label preset
issues.label_templates.info = No labels exist yet. Create a label with "New label" or use a label preset:
issues.label_templates.helper = Select a label preset
issues.label_templates.use = Use label preset
@ -1628,7 +1581,6 @@ issues.filter_assginee_no_assignee = No assignee
issues.filter_poster = Author
issues.filter_poster_no_select = All authors
issues.filter_type = Type
issues.filter_type.all_issues = All issues
issues.filter_type.all_pull_requests = All pull requests
issues.filter_type.assigned_to_you = Assigned to you
issues.filter_type.created_by_you = Created by you
@ -1751,7 +1703,6 @@ issues.label.filter_sort.reverse_by_size = Largest size
issues.num_participants_one = %d participant
issues.num_participants_few = %d participants
issues.attachment.open_tab = `Click to see "%s" in a new tab`
issues.attachment.download = `Click to download "%s"`
issues.subscribe = Subscribe
issues.unsubscribe = Unsubscribe
issues.unpin_issue = Unpin issue
@ -1799,7 +1750,6 @@ issues.del_time_history= `deleted spent time %s`
issues.add_time_hours = Hours
issues.add_time_minutes = Minutes
issues.add_time_sum_to_small = No time was entered.
issues.time_spent_total = Total time spent
issues.time_spent_from_all_authors = `Total time spent: %s`
issues.due_date = Due date
issues.push_commit_1 = added %d commit %s
@ -1855,7 +1805,6 @@ issues.review.dismissed_label = Dismissed
issues.review.left_comment = left a comment
issues.review.content.empty = You need to leave a comment indicating the requested change(s).
issues.review.reject = requested changes %s
issues.review.wait = was requested for review %s
issues.review.add_review_request = requested review from %[1]s %[2]s
issues.review.add_review_requests = requested reviews from %[1]s %[2]s
issues.review.remove_review_request = removed review request for %[1]s %[2]s
@ -1885,13 +1834,11 @@ issues.content_history.delete_from_history_confirm = Delete from history?
issues.content_history.options = Options
issues.blocked_by_user = You cannot create issues in this repository because you are blocked by the repository owner.
comment.blocked_by_user = Commenting is not possible because you are blocked by the repository owner or the author.
issues.reopen.blocked_by_user = You cannot reopen this issue because you are blocked by the repository owner or the poster of this issue.
issues.summary_card_alt = Summary card of an issue titled "%s" in repository %s
compare.compare_base = base
compare.compare_head = compare
pulls.desc = Enable pull requests and code reviews.
pulls.new = New pull request
pulls.view = View pull request
pulls.edit.already_changed = Unable to save changes to the pull request. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes
@ -1917,7 +1864,6 @@ pulls.show_changes_since_your_last_review = Show changes since your last review
pulls.showing_only_single_commit = Showing only changes of commit %[1]s
pulls.showing_specified_commit_range = Showing only changes between %[1]s..%[2]s
pulls.select_commit_hold_shift_for_range = Select commit. Hold shift + click to select a range
pulls.review_only_possible_for_full_diff = Review is only possible when viewing the full diff
pulls.filter_changes_by_commit = Filter by commit
pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request.
pulls.nothing_to_compare_have_tag = The selected branches/tags are equal.
@ -2107,7 +2053,6 @@ ext_wiki = External Wiki
wiki = Wiki
wiki.welcome = Welcome to the wiki.
wiki.welcome_desc = The wiki lets you write and share documentation with collaborators.
wiki.desc = Write and share documentation with collaborators.
wiki.create_first_page = Create the first page
wiki.page = Page
wiki.filter_page = Filter page
@ -2737,11 +2682,9 @@ diff.protected = Protected
diff.image.side_by_side = Side by side
diff.image.swipe = Swipe
diff.image.overlay = Overlay
diff.has_escaped = This line has hidden Unicode characters
diff.show_file_tree = Show file tree
diff.hide_file_tree = Hide file tree
releases.desc = Track project versions and downloads.
release.releases = Releases
release.detail = Release details
release.tags = Tags
@ -2753,7 +2696,6 @@ release.compare = Compare
release.edit = Edit
release.ahead.commits = <strong>%d</strong> commits
release.ahead.target = to %s since this release
tag.ahead.target = to %s since this tag
release.source_code = Source code
release.new_subheader = Releases organize project versions.
release.edit_subheader = Releases organize project versions.
@ -2781,7 +2723,6 @@ release.deletion_tag_success = The tag has been deleted.
release.tag_name_already_exist = A release with this tag name already exists.
release.tag_name_invalid = The tag name is not valid.
release.tag_name_protected = The tag name is protected.
release.tag_already_exist = This tag name already exists.
release.downloads = Downloads
release.download_count_one = %s download
release.download_count_few = %s downloads
@ -2802,7 +2743,6 @@ release.summary_card_alt = Summary card of an release titled "%s" in repository
branch.name = Branch name
branch.already_exists = A branch named "%s" already exists.
branch.delete_head = Delete
branch.delete = Delete branch "%s"
branch.delete_html = Delete branch
branch.delete_desc = Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
@ -2842,7 +2782,6 @@ tag.create_tag_from = Create new tag from "%s"
tag.create_success = Tag "%s" has been created.
topic.manage_topics = Manage topics
topic.done = Done
topic.count_prompt = You cannot select more than 25 topics
topic.format_prompt = Topics must start with a letter or number, can include dashes ("-") and dots ("."), can be up to 35 characters long. Letters must be lowercase.
@ -2913,7 +2852,6 @@ form.create_org_not_allowed = You are not allowed to create an organization.
settings = Settings
settings.options = Organization
settings.full_name = Full name
settings.email = Contact email
settings.website = Website
settings.location = Location
@ -2943,8 +2881,6 @@ settings.hooks_desc = Add webhooks which will be triggered for <strong>all repos
settings.labels_desc = Add labels which can be used on issues for <strong>all repositories</strong> under this organization.
members.membership_visibility = Membership visibility:
members.public = Visible
members.public_helper = Make hidden
members.private = Hidden
members.private_helper = Make visible
@ -2955,8 +2891,6 @@ members.remove = Remove
members.remove.detail = Remove %[1]s from %[2]s?
members.leave = Leave
members.leave.detail = Are you sure you want to leave organization "%s"?
members.invite_desc = Add a new member to %s:
members.invite_now = Invite now
teams.join = Join
teams.leave = Leave
@ -2974,7 +2908,6 @@ teams.admin_access_helper = Members can pull and push to team repositories and a
teams.no_desc = This team has no description
teams.settings = Settings
teams.owners_permission_desc = Owners have full access to <strong>all repositories</strong> and have <strong>administrator access</strong> to the organization.
teams.members = Team members
teams.update_settings = Update settings
teams.delete_team = Delete team
teams.add_team_member = Add team member
@ -2984,11 +2917,7 @@ teams.delete_team_title = Delete team
teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue?
teams.delete_team_success = The team has been deleted.
teams.admin_permission_desc = This team grants <strong>Administrator</strong> access: members can read from, push to and add collaborators to team repositories.
teams.create_repo_permission_desc = Additionally, this team grants <strong>Create repository</strong> permission: members can create new repositories in organization.
teams.repositories = Team repositories
teams.remove_all_repos_title = Remove all team repositories
teams.remove_all_repos_desc = This will remove all repositories from the team.
teams.add_all_repos_title = Add all repositories
teams.add_all_repos_desc = This will add all the organization's repositories to the team.
teams.add_nonexistent_repo = The repository you're trying to add doesn't exist, please create it first.
teams.add_duplicate_users = User is already a team member.
@ -3201,7 +3130,6 @@ repos.unadopted = Unadopted repositories
repos.unadopted.no_more = No unadopted repositories found.
repos.owner = Owner
repos.name = Name
repos.private = Private
repos.issues = Issues
repos.size = Size
repos.lfs_size = LFS size
@ -3240,7 +3168,6 @@ auths.updated = Updated
auths.auth_type = Authentication type
auths.auth_name = Authentication name
auths.security_protocol = Security protocol
auths.domain = Domain
auths.host = Host
auths.port = Port
auths.bind_dn = Bind DN
@ -3270,7 +3197,6 @@ auths.user_attribute_in_group = User attribute listed in group
auths.map_group_to_team = Map LDAP groups to Organization teams (leave the field empty to skip)
auths.map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
auths.enable_ldap_groups = Enable LDAP groups
auths.ms_ad_sa = MS AD search attributes
auths.smtp_auth = SMTP authentication type
auths.smtphost = SMTP host
auths.smtpport = SMTP port
@ -3336,7 +3262,6 @@ auths.delete_auth_desc = Deleting an authentication source prevents users from u
auths.still_in_used = The authentication source is still in use. Convert or delete any users using this authentication source first.
auths.deletion_success = The authentication source has been deleted.
auths.login_source_exist = The authentication source "%s" already exists.
auths.login_source_of_type_exist = An authentication source of this type already exists.
auths.unable_to_initialize_openid = Unable to initialize OpenID Connect Provider: %s
auths.invalid_openIdConnectAutoDiscoveryURL = Invalid Auto Discovery URL (this must be a valid URL starting with http:// or https://)
@ -3355,7 +3280,6 @@ config.run_mode = Run mode
config.git_version = Git version
config.app_data_path = App data path
config.repo_root_path = Repository root path
config.lfs_root_path = LFS root path
config.log_file_root_path = Log path
config.script_type = Script type
config.reverse_auth_user = Reverse proxy authentication user
@ -3433,9 +3357,6 @@ config.send_test_mail_submit = Send
config.test_mail_failed = Failed to send a test email to "%s": %v
config.test_mail_sent = A test email has been sent to "%s".
config.oauth_config = OAuth configuration
config.oauth_enabled = Enabled
config.cache_config = Cache configuration
config.cache_adapter = Cache adapter
config.cache_interval = Cache interval
@ -3454,10 +3375,8 @@ config.cookie_name = Cookie name
config.gc_interval_time = GC interval time
config.session_life_time = Session lifetime
config.https_only = HTTPS only
config.cookie_life_time = Cookie lifetime
config.picture_config = Picture and avatar configuration
config.picture_service = Picture service
config.disable_gravatar = Disable Gravatar
config.enable_federated_avatar = Enable federated avatars
config.open_with_editor_app_help = The "Open with" editors for the clone menu. If left empty, the default will be used. Expand to see the default.
@ -3477,7 +3396,6 @@ config.git_gc_timeout = GC Operation timeout
config.log_config = Log configuration
config.logger_name_fmt = Logger: %s
config.disabled_logger = Disabled
config.access_log_mode = Access log mode
config.access_log_template = Access log template
config.xorm_log_sql = Log SQL
@ -3496,14 +3414,10 @@ monitor.stacktrace = Stacktrace
monitor.processes_count = %d Processes
monitor.download_diagnosis_report = Download diagnosis report
monitor.duration = Duration (s)
monitor.desc = Description
monitor.start = Start Time
monitor.execute_time = Execution Time
monitor.last_execution_result = Result
monitor.process.cancel = Cancel process
monitor.process.cancel_desc = Canceling a process may cause data loss
monitor.process.cancel_notices = Cancel: <strong>%s</strong>?
monitor.process.children = Children
monitor.queues = Queues
monitor.queue = Queue: %s
@ -3642,11 +3556,9 @@ error.probable_bad_default_signature = WARNING! Although the default key has thi
[units]
unit = Unit
error.no_unit_allowed_repo = You are not allowed to access any section of this repository.
error.unit_not_allowed = You are not allowed to access this repository section.
[packages]
title = Packages
desc = Manage repository packages.
empty = There are no packages yet.
empty.documentation = For more information on the package registry, see <a target="_blank" rel="noopener noreferrer" href="%s">the documentation</a>.
empty.repo = Did you upload a package, but it's not shown here? Go to <a href="%[1]s">package settings</a> and link it to this repo.
@ -3707,7 +3619,6 @@ composer.registry = Setup this registry in your <code>~/.composer/config.json</c
composer.install = To install the package using Composer, run the following command:
composer.dependencies = Dependencies
composer.dependencies.development = Development dependencies
conan.details.repository = Repository
conan.registry = Setup this registry from the command line:
conan.install = To install the package using Conan, run the following command:
conda.registry = Setup this registry as a Conda repository in your <code>.condarc</code> file:
@ -3806,7 +3717,6 @@ owner.settings.cleanuprules.none = There are no cleanup rules yet.
owner.settings.cleanuprules.preview = Cleanup rule preview
owner.settings.cleanuprules.preview.overview = %d packages are scheduled to be removed.
owner.settings.cleanuprules.preview.none = Cleanup rule does not match any packages.
owner.settings.cleanuprules.enabled = Enabled
owner.settings.cleanuprules.pattern_full_match = Apply pattern to full package name
owner.settings.cleanuprules.keep.title = Versions that match these rules are kept, even if they match a removal rule below.
owner.settings.cleanuprules.keep.count = Keep the most recent
@ -3840,7 +3750,6 @@ management = Manage secrets
[actions]
actions = Actions
unit.desc = Manage integrated CI/CD pipelines with Forgejo Actions.
status.unknown = Unknown
status.waiting = Waiting
@ -3931,7 +3840,6 @@ variables.none = There are no variables yet.
variables.deletion = Remove variable
variables.deletion.description = Removing a variable is permanent and cannot be undone. Continue?
variables.description = Variables will be passed to certain actions and cannot be read otherwise.
variables.id_not_exist = Variable with ID %d does not exist.
variables.edit = Edit Variable
variables.not_found = Failed to find the variable.
variables.deletion.failed = Failed to remove variable.

View file

@ -35,7 +35,7 @@ func HandleGitError(ctx *context.Context, msg string, err error) {
case ctx.Repo.IsViewCommit:
refType = "commit"
}
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found_"+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName))
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found."+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName))
ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + refType + "/" + url.PathEscape(ctx.Repo.RefName)
ctx.NotFound(msg, err)
} else {

View file

@ -35,7 +35,7 @@ func TaskStatus(ctx *context.Context) {
var translatableMessage admin_model.TranslatableMessage
if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil {
translatableMessage = admin_model.TranslatableMessage{
Format: "migrate.migrating_failed.error",
Format: "repo.migrate.migrating_failed.error",
Args: []any{task.Message},
}
}

View file

@ -427,6 +427,7 @@ func (diffFile *DiffFile) ShouldBeHidden() bool {
return diffFile.IsGenerated || diffFile.IsViewed
}
//llu:returnsTrKey
func (diffFile *DiffFile) ModeTranslationKey(mode string) string {
switch mode {
case "040000":

View file

@ -53,7 +53,7 @@ func sendMailActionRun(to *user_model.User, run *actions_model.ActionRun, priorS
if run.Status.IsSuccess() {
subject = locale.TrString("mail.actions.successful_run_after_failure_subject", run.Title, run.Repo.FullName())
} else {
subject = locale.TrString("mail.actions.not_successful_run", run.Title, run.Repo.FullName())
subject = locale.TrString("mail.actions.not_successful_run_subject", run.Title, run.Repo.FullName())
}
commitSHA := run.CommitSHA