feat(build): improve lint-locale-usage

* add check for unused msgids
* add ability to manually allowlist specific msgids and groups of msgids

* lint-locale-usage should handle .locale.Tr and Unit{...}
* lint-locale-usage should handle simple dynamic msgids
* remove unnecessary DescKey and associated msgids + values
This commit is contained in:
Ellen Emilia Anna Zscheile 2025-07-30 23:38:02 +02:00
commit 31190e385e
12 changed files with 365 additions and 146 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

@ -460,7 +460,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/lint-locale-usage.go --allow-masked-usages-from=build/lint-locale-usage/allowed-masked-usage.txt
.PHONY: lint-md
lint-md: node_modules

View file

@ -0,0 +1,62 @@
# translation tooling test keys
meta.last_line
translation_meta.test
# models/admin/task.go: instances of $TranslatableMessage.Format
# this also gets instantiate as a Messenger once
org.matrix.custom.html
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.
# routers/web/repo/helper.go: "repo.tree_path_not_found_"+refType
repo.tree_path_not_found_branch
repo.tree_path_not_found_commit
repo.tree_path_not_found_tag
# services/context/context.go
relativetime.
# templates/mail/issue/default.tmpl: $.locale.Tr
mail.issue.in_tree_path
# templates/org/team/new.tmpl: ctx.Locale.Tr (print "repo.permissions." $unit.Name ".read")}, and such
repo.permissions.
# templates/package/metadata/arch.tmpl: $.locale.Tr
packages.details.license
# templates/repo/editor/commit_form.tmpl: ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CanCommitToBranch.WontSignReason)
repo.signing.wont_sign.
# 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.type-1.display_name
projects.type-2.display_name
projects.type-3.display_name
# modules/migration/messenger.go: invocations of Messenger
# services/migrations/migrate.go: messenger(...)
# *: repo.migrate.*.description (unknown where they come from)
repo.migrate.

View file

@ -5,6 +5,7 @@
package main
import (
"bufio"
"fmt"
"go/ast"
goParser "go/parser"
@ -12,6 +13,7 @@ import (
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
@ -63,10 +65,120 @@ 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)
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
}
type StringTrie interface {
Matches(key []string) bool
}
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 {
tmp := make(StringTrieMap)
(*m)[key[0]] = &tmp
}
(*m)[key[0]].(*StringTrieMap).Insert(key[1:])
}
}
func DecodeKeyForStm(key string) []string {
ret := strings.Split(key, ".")
i := len(ret)
for i > 0 && ret[i-1] == "" {
i--
}
return ret[:i]
}
func ParseAllowedMaskedUsages(fname string, usedMsgids *container.Set[string], allowedMaskedPrefixes *StringTrieMap) error {
file, err := os.Open(fname)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasSuffix(line, ".") {
allowedMaskedPrefixes.Insert(DecodeKeyForStm(line))
} else {
(*usedMsgids)[line] = struct{}{}
}
}
return scanner.Err()
}
func (handler Handler) HandleGoTrBasicLit(fset *token.FileSet, argLit *ast.BasicLit) {
if argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err == nil {
// found interesting strings
if strings.HasSuffix(arg, ".") || strings.HasSuffix(arg, "_") {
handler.OnMsgidPrefix(fset, argLit.ValuePos, arg)
} else {
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
}
}
}
func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr) {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.HandleGoTrBasicLit(fset, argLit)
} 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 {
// found interesting strings
handler.OnMsgidPrefix(fset, argLit.ValuePos, arg)
}
}
}
}
// 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)
@ -74,7 +186,7 @@ type Handler struct {
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution)
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution|goParser.ParseComments)
if err != nil {
return LocatedError{
Location: fname,
@ -86,11 +198,7 @@ func (handler Handler) HandleGoFile(fname string, src any) error {
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
}
if call, ok := n.(*ast.CallExpr); ok && len(call.Args) >= 1 {
funSel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
@ -104,27 +212,100 @@ func (handler Handler) HandleGoFile(fname string, src any) error {
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 {
continue
}
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
} else {
if len(call.Args) < int(argNum+1) {
argc := len(call.Args)
gotUnexpectedInvoke = &argc
} else {
handler.HandleGoTrArgument(fset, call.Args[int(argNum)])
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
} else if composite, ok := n.(*ast.CompositeLit); ok {
ident, ok := composite.Type.(*ast.Ident)
if !ok {
return true
}
// special case: models/unit/unit.go
if strings.HasSuffix(fname, "unit.go") && ident.Name == "Unit" && len(composite.Elts) == 6 {
// NameKey has index 2
// invoked like '{{ctx.Locale.Tr $unit.NameKey}}'
nameKey, ok := composite.Elts[2].(*ast.BasicLit)
if !ok || nameKey.Kind != token.STRING {
handler.OnWarning(fset, composite.Elts[2].Pos(), "unexpected initialization of 'Unit'")
return true
}
// extract string content
arg, err := strconv.Unquote(nameKey.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, nameKey.ValuePos, arg)
}
}
} else if function, ok := n.(*ast.FuncDecl); ok {
matches := false
if function.Doc != nil {
for _, comment := range function.Doc.List {
if strings.TrimSpace(comment.Text) == "//llu:returnsTrKey" {
matches = true
break
}
}
}
if !matches {
return true
}
results := function.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, function.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", function.Name.Name))
return true
}
ast.Inspect(function.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)
}
return true
})
}
return false
}
return true
})
return true
} else if decl, ok := n.(*ast.GenDecl); ok && (decl.Tok == token.CONST || decl.Tok == token.VAR) {
matches := false
if decl.Doc != nil {
for _, comment := range decl.Doc.List {
if strings.TrimSpace(comment.Text) == "// llu:TrKeys" {
matches = true
break
}
}
}
if !matches {
return true
}
for _, spec := range decl.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)
return false
}
return true
})
}
}
return true
})
@ -163,22 +344,26 @@ func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.N
return
}
nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode)
if !ok {
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
}
nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode)
if !ok || nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
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
}
ltf, ok := handler.LocaleTrFunctions[nodeChain.Field[1]]
if !ok {
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) {
@ -195,7 +380,7 @@ func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.N
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, token.Pos(nodeChain.Pos), nodeChain.Field[1], *gotUnexpectedInvoke)
handler.OnUnexpectedInvoke(fset, token.Pos(nodeCommand.Pos), funcname, *gotUnexpectedInvoke)
}
default:
@ -273,6 +458,7 @@ func (handler Handler) HandleTemplateFile(fname string, src any) error {
// Possible command line flags:
//
// --allow-missing-msgids don't return an error code if missing message IDs are found
// --allow-unused-msgids don't return an error code if unused message IDs are found
//
// EXIT CODES:
//
@ -281,13 +467,45 @@ func (handler Handler) HandleTemplateFile(fname string, src any) error {
// 2 unable to parse locale ini/json files
// 3 unable to parse go or text/template files
// 4 found missing message IDs
// 5 found unused message IDs
// 6 invalid command line argument
// 7 unable to parse allowed masked usages file
//
// SPECIAL GO DOC COMMENTS:
//
// //llu:returnsTrKey
// can be used in front of functions to indicate
// that the function returns message IDs
//
// // llu:TrKeys
// can be used in front of 'const' and 'var' blocks
// in order to mark all contained strings as message IDs
//
//nolint:forbidigo
func main() {
allowMissingMsgids := false
allowUnusedMsgids := false
usedMsgids := make(container.Set[string])
allowedMaskedPrefixes := make(StringTrieMap)
for _, arg := range os.Args[1:] {
if arg == "--allow-missing-msgids" {
switch arg {
case "--allow-missing-msgids":
allowMissingMsgids = true
case "--allow-unused-msgids":
allowUnusedMsgids = true
default:
if argval, found := strings.CutPrefix(arg, "--allow-masked-usages-from="); found {
if err := ParseAllowedMaskedUsages(argval, &usedMsgids, &allowedMaskedPrefixes); err != nil {
fmt.Printf("%s:\tERROR: unable to parse masked usages: %s\n", argval, err.Error())
os.Exit(7)
}
} else {
fmt.Printf("<command line>:\tERROR: unknown argument: %s\n", arg)
os.Exit(6)
}
}
}
@ -335,16 +553,25 @@ func main() {
gotAnyMsgidError := false
handler := Handler{
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string) {
// TODO: perhaps we should check if we have any strings with such a prefix, but that's slow...
allowedMaskedPrefixes.Insert(DecodeKeyForStm(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[msgid] = struct{}{}
}
},
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 +604,27 @@ func main() {
os.Exit(1)
}
unusedMsgids := []string{}
for msgid := range msgids {
if !usedMsgids.Contains(msgid) && !allowedMaskedPrefixes.Matches(DecodeKeyForStm(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)
@ -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

@ -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":