From 18c22b59b540d8b4eb395ac84010fa29ac9749c2 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 15 Jun 2025 23:53:35 -0700 Subject: [PATCH 1/7] Add a tool to detect possible unused language keys and remove those which can be confirmed --- build/clean-locales.go | 92 +++++++++++++++++++++++++++++ options/locale/locale_en-US.ini | 35 ----------- routers/web/user/setting/profile.go | 4 +- 3 files changed, 94 insertions(+), 37 deletions(-) create mode 100644 build/clean-locales.go diff --git a/build/clean-locales.go b/build/clean-locales.go new file mode 100644 index 0000000000000..f4b8f6cfd3f80 --- /dev/null +++ b/build/clean-locales.go @@ -0,0 +1,92 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/setting" +) + +func searchDirs(key string) (bool, error) { + for _, dir := range []string{ + "cmd", + "models", + "modules", + "routers", + "services", + "templates", + } { + found, err := searchLocaleFiles(dir, key) + if err != nil { + return false, err + } + if found { + return true, nil + } + } + return false, nil +} + +func searchLocaleFiles(dir, key string) (bool, error) { + errFound := errors.New("found") + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || (!strings.HasSuffix(d.Name(), ".go") && !strings.HasSuffix(d.Name(), ".tmpl")) { + return nil + } + + bs, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(bs), `"`+key+`"`) { + return errFound + } + + return nil + }) + if err == errFound { + return true, nil + } + return false, err +} + +func main() { + if len(os.Args) != 1 { + println("usage: clean-locales") + os.Exit(1) + } + + iniFile, err := setting.NewConfigProviderForLocale("options/locale/locale_en-US.ini") + if err != nil { + panic(err) + } + + for _, section := range iniFile.Sections() { + for _, key := range section.Keys() { + var trKey string + if section.Name() == "" || section.Name() == "DEFAULT" { + trKey = key.Name() + } else { + trKey = section.Name() + "." + key.Name() + } + + found, err := searchDirs(trKey) + if err != nil { + panic(err) + } + if !found { + println("unused locale key:", trKey) + } + } + } +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8797ef4bc03d3..d241cbb65aab7 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -49,7 +49,6 @@ webauthn_error_unable_to_process = The server could not process your request. webauthn_error_duplicated = The security key is not permitted for this request. Please make sure that the key is not already registered. 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. -webauthn_reload = Reload repository = Repository organization = Organization @@ -57,14 +56,10 @@ mirror = Mirror issue_milestone = Milestone new_repo = New Repository new_migrate = New Migration -new_mirror = New Mirror new_fork = New Repository Fork new_org = New Organization -new_project = New Project new_project_column = New Column -manage_org = Manage Organizations admin_panel = Site Administration -account_settings = Account Settings settings = Settings your_profile = Profile your_starred = Starred @@ -368,13 +363,9 @@ config_write_file_prompt = These configuration options will be written into: %s [home] nav_menu = Navigation Menu uname_holder = Username or Email Address -password_holder = Password switch_dashboard_context = Switch Dashboard Context my_repos = Repositories -show_more_repos = Show more repositories… -collaborative_repos = Collaborative Repositories my_orgs = My Organizations -my_mirrors = My Mirrors view_home = View %s filter = Other Filters filter_by_team_repositories = Filter by team repositories @@ -435,7 +426,6 @@ resent_limit_prompt = You have already requested an activation email recently. P has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (%s). If you haven't received a confirmation email or need to resend a new one, please click on the button below. change_unconfirmed_mail_address = If your registration email address is incorrect, you can change it here and resend a new confirmation email. resend_mail = Click here to resend your activation email -email_not_associate = The email address is not associated with any account. send_reset_mail = Send Account Recovery Email reset_password = Account Recovery invalid_code = Your confirmation code is invalid or has expired. @@ -453,7 +443,6 @@ twofa_passcode_incorrect = Your passcode is incorrect. If you misplaced your dev twofa_scratch_token_incorrect = Your scratch code is incorrect. twofa_required = You must setup Two-Factor Authentication to get access to repositories, or try to login again. login_userpass = Sign In -login_openid = OpenID oauth_signup_tab = Register New Account oauth_signup_title = Complete New Account oauth_signup_submit = Complete Account @@ -481,7 +470,6 @@ authorize_application_with_scopes = With scopes: %s authorize_title = Authorize "%s" to access your account? authorization_failed = Authorization failed authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize. -sspi_auth_failed = SSPI authentication failed password_pwned = The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password and consider changing this password elsewhere too. password_pwned_err = Could not complete request to HaveIBeenPwned last_admin = You cannot remove the last admin. There must be at least one admin. @@ -513,8 +501,6 @@ reset_password = Recover your account reset_password.title = %s, you have requested to recover your account reset_password.text = Please click the following link to recover your account within %s: -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. @@ -559,30 +545,12 @@ yes = Yes no = No confirm = Confirm cancel = Cancel -modify = Update [form] -UserName = Username -RepoName = Repository name -Email = Email address -Password = Password -Retype = Confirm Password -SSHTitle = SSH key name -HttpsUrl = HTTPS URL -PayloadUrl = Payload URL -TeamName = Team name -AuthName = Authorization name -AdminEmail = Admin email NewBranchName = New branch name -CommitSummary = Commit summary -CommitMessage = Commit message -CommitChoice = Commit choice -TreeName = File path -Content = Content SSPISeparatorReplacement = Separator -SSPIDefaultLanguage = Default Language require_error = ` cannot be empty.` alpha_dash_error = ` should contain only alphanumeric, dash ('-') and underscore ('_') characters.` @@ -607,7 +575,6 @@ username_been_taken = The username is already taken. username_change_not_local_user = Non-local users are not allowed to change their username. change_username_disabled = Changing username is disabled. change_full_name_disabled = Changing full name is disabled. -username_has_not_been_changed = Username has not been changed repo_name_been_taken = The repository name is already used. repository_force_private = Force Private is enabled: private repositories cannot be made public. repository_files_already_exist = Files already exist for this repository. Contact the system administrator. @@ -718,7 +685,6 @@ password = Password security = Security avatar = Avatar ssh_gpg_keys = SSH / GPG Keys -social = Social Accounts applications = Applications orgs = Manage Organizations repos = Repositories @@ -1281,7 +1247,6 @@ release = Release releases = Releases tag = Tag released_this = released this -tagged_this = tagged this file.title = %s at %s file_raw = Raw file_history = History diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 98995cd69c435..622c897cc8b6c 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -71,7 +71,7 @@ func ProfilePost(ctx *context.Context) { if form.Name != "" { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeUsername) { - ctx.Flash.Error(ctx.Tr("user.form.change_username_disabled")) + ctx.Flash.Error(ctx.Tr("form.change_username_disabled")) ctx.Redirect(setting.AppSubURL + "/user/settings") return } @@ -107,7 +107,7 @@ func ProfilePost(ctx *context.Context) { if form.FullName != "" { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) { - ctx.Flash.Error(ctx.Tr("user.form.change_full_name_disabled")) + ctx.Flash.Error(ctx.Tr("form.change_full_name_disabled")) ctx.Redirect(setting.AppSubURL + "/user/settings") return } From 760bf0deedd450a9ed0f5b053c08ad7039248486 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 16 Jun 2025 14:47:08 -0700 Subject: [PATCH 2/7] Fix all unused translation keys --- build/clean-locales.go | 40 +++++++++-- options/locale/locale_en-US.ini | 114 -------------------------------- routers/web/repo/migrate.go | 2 +- 3 files changed, 36 insertions(+), 120 deletions(-) diff --git a/build/clean-locales.go b/build/clean-locales.go index f4b8f6cfd3f80..a5faee90c5b35 100644 --- a/build/clean-locales.go +++ b/build/clean-locales.go @@ -12,9 +12,11 @@ import ( "strings" "code.gitea.io/gitea/modules/setting" + + "github.com/gobwas/glob" ) -func searchDirs(key string) (bool, error) { +func searchTranslationKeyInDirs(key string) (bool, error) { for _, dir := range []string{ "cmd", "models", @@ -23,7 +25,7 @@ func searchDirs(key string) (bool, error) { "services", "templates", } { - found, err := searchLocaleFiles(dir, key) + found, err := searchTranslationKeyInDir(dir, key) if err != nil { return false, err } @@ -34,13 +36,15 @@ func searchDirs(key string) (bool, error) { return false, nil } -func searchLocaleFiles(dir, key string) (bool, error) { +func searchTranslationKeyInDir(dir, key string) (bool, error) { errFound := errors.New("found") err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } - if d.IsDir() || (!strings.HasSuffix(d.Name(), ".go") && !strings.HasSuffix(d.Name(), ".tmpl")) { + if d.IsDir() || + (!strings.HasSuffix(d.Name(), ".go") && !strings.HasSuffix(d.Name(), ".tmpl")) || + strings.HasSuffix(d.Name(), "_test.go") { // don't search in test files return nil } @@ -60,6 +64,29 @@ func searchLocaleFiles(dir, key string) (bool, error) { return false, err } +var whitelist = []string{ + "repo.signing.wont_sign.*", + "repo.issues.role.*", + "repo.commitstatus.*", + "admin.dashboard.*", + "admin.dashboard.cron.*", + "admin.dashboard.task.*", + "repo.migrate.*.description", + "actions.runners.status.*", + "projects.*.display_name", + "admin.notices.*", + "form.NewBranchName", // FIXME: used in integration tests only +} + +func isWhitelisted(key string) bool { + for _, w := range whitelist { + if glob.MustCompile(w).Match(key) { + return true + } + } + return false +} + func main() { if len(os.Args) != 1 { println("usage: clean-locales") @@ -79,8 +106,11 @@ func main() { } else { trKey = section.Name() + "." + key.Name() } + if isWhitelisted(trKey) { + continue + } - found, err := searchDirs(trKey) + found, err := searchTranslationKeyInDirs(trKey) if err != nil { panic(err) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index d241cbb65aab7..5a7354608e8ac 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -688,11 +688,8 @@ ssh_gpg_keys = SSH / GPG Keys applications = Applications orgs = Manage Organizations repos = Repositories -delete = Delete Account twofa = Two-Factor Authentication (TOTP) -account_link = Linked Accounts organization = Organizations -uid = UID webauthn = Two-Factor Authentication (Security Keys) public_profile = Public Profile @@ -710,10 +707,8 @@ update_language = Update 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. -continue = Continue cancel = Cancel language = Language ui = Theme @@ -741,7 +736,6 @@ keep_activity_private = Hide Activity from profile page keep_activity_private_popup = Makes the activity visible only for you and the admins lookup_avatar_by_mail = Look Up Avatar by Email Address -federated_avatar_lookup = Federated Avatar Lookup enable_custom_avatar = Use Custom Avatar choose_new_avatar = Choose new avatar update_avatar = Update Avatar @@ -760,7 +754,6 @@ password_incorrect = The current password is incorrect. change_password_success = Your password has been updated. Sign in using your new password from now on. password_change_disabled = Non-local users cannot update their password through the Gitea web interface. -emails = Email Addresses manage_emails = Manage Email Addresses manage_themes = Select default theme manage_openid = Manage OpenID Addresses @@ -805,8 +798,6 @@ principal_desc = These SSH certificate principals are associated with your accou gpg_desc = These public GPG keys are associated with your account. Keep your private keys safe as they allow commits to be verified. ssh_helper = Need help? Have a look at GitHub's guide to create your own SSH keys or solve common problems you may encounter using SSH. gpg_helper = Need help? Have a look at GitHub's guide about GPG. -add_new_key = Add SSH Key -add_new_gpg_key = Add GPG Key key_content_ssh_placeholder = Begins with 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com' key_content_gpg_placeholder = Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----' add_new_principal = Add Principal @@ -870,10 +861,6 @@ hide_openid = Hide from profile ssh_disabled = SSH Disabled ssh_signonly = SSH is currently disabled so these keys are only used for commit signature verification. ssh_externally_managed = This SSH key is externally managed for this user -manage_social = Manage Associated Social Accounts -social_desc = These social accounts can be used to sign in to your account. Make sure you recognize all of them. -unbind = Unlink -unbind_success = The social account has been removed successfully. manage_access_token = Manage Access Tokens generate_new_token = Generate New Token @@ -884,8 +871,6 @@ generate_token_success = Your new token has been generated. Copy it now as it wi generate_token_name_duplicate = %s has been used as an application name already. Please use a new one. delete_token = Delete access_token_deletion = Delete Access Token -access_token_deletion_cancel_action = Cancel -access_token_deletion_confirm_action = Delete access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue? delete_token_success = The token has been deleted. Applications using it no longer have access to your account. repo_and_org_access = Repository and Organization Access @@ -904,9 +889,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 Gitea 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 @@ -921,7 +904,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? @@ -963,7 +945,6 @@ webauthn_alternative_tip = You may want to configure an additional authenticatio manage_account_links = Manage Linked Accounts manage_account_links_desc = These external accounts are linked to your Gitea account. -account_links_not_available = There are currently no external accounts linked to your Gitea account. link_account = Link Account remove_account_link = Remove Linked Account remove_account_link_desc = Removing a linked account will revoke its access to your Gitea account. Continue? @@ -1007,7 +988,6 @@ repo_size = Repository Size template = Template template_select = Select a template. template_helper = Make repository a template -template_description = Template repositories let users generate new repositories with the same directory structure, files, and optional settings. visibility = Visibility visibility_description = Only the owner or the organization members if they have rights, will be able to see it. visibility_helper = Make repository private @@ -1030,8 +1010,6 @@ 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_no_desc = No description provided @@ -1050,11 +1028,6 @@ readme = README readme_helper = Select a README file template. readme_helper_desc = This is the place where you can write a complete description for your project. auto_init = Initialize Repository (Adds .gitignore, License and README) -trust_model_helper = Select trust model for signature verification. Possible options are: -trust_model_helper_collaborator = Collaborator: Trust signatures by collaborators -trust_model_helper_committer = Committer: Trust signatures that match committers -trust_model_helper_collaborator_committer = Collaborator+Committer: Trust signatures by collaborators which match the committer -trust_model_helper_default = Default: Use the default trust model for this installation create_repo = Create Repository default_branch = Default Branch default_branch_label = default @@ -1073,13 +1046,11 @@ 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 determine the LFS server. 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. watchers = Watchers stargazers = Stargazers -stars_remove_warning = This will remove all stars from this repository. forks = Forks stars = Stars reactions_more = and %d more @@ -1109,7 +1080,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.public_access = Public Access desc.template = Template desc.internal = Internal @@ -1139,7 +1109,6 @@ form.name_pattern_not_allowed = The pattern "%s" is not allowed in a repository need_auth = Authorization migrate_options = Migration Options -migrate_service = Migration Service migrate_options_mirror_helper = This repository will be a mirror migrate_options_lfs = Migrate LFS files migrate_options_lfs_endpoint.label = LFS Endpoint @@ -1209,8 +1178,6 @@ unstar = Unstar star = Star fork = Fork action.blocked_user = Cannot perform action because you are blocked by the repository owner. -download_archive = Download Repository -more_operations = More Operations quick_guide = Quick Guide clone_this_repo = Clone this repository @@ -1224,7 +1191,6 @@ no_branch = This repository doesn’t have any branches. 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 @@ -1328,7 +1294,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.commit_email = Commit email editor.invalid_commit_email = The email for the commit is invalid. @@ -1362,7 +1327,6 @@ editor.require_signed_commit = Branch requires a signed commit editor.cherry_pick = Cherry-pick %s onto: editor.revert = Revert %s onto: -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. @@ -1372,9 +1336,6 @@ commits.search_all = All Branches commits.author = Author commits.message = Message 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 commits.gpg_key_id = GPG Key ID @@ -1427,7 +1388,6 @@ projects.column.set_default = "Set Default" projects.column.set_default_desc = "Set this column as default for uncategorized issues and pulls" projects.column.delete = "Delete Column" projects.column.deletion_desc = "Deleting a project column moves all related issues to the default column. Continue?" -projects.column.color = "Color" projects.open = Open projects.close = Close projects.column.assigned_to = Assigned to @@ -1476,8 +1436,6 @@ issues.create = Create Issue 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 predefined set of labels issues.label_templates.info = No labels exist yet. Create a label with 'New Label' or use a predefined label set: issues.label_templates.helper = Select a label set issues.label_templates.use = Use Label Set @@ -1589,8 +1547,6 @@ issues.ref_issue_from = `referenced this issue %[4]s referenced this pull request %[4]s %[2]s` issues.ref_closing_from = `referenced a pull request %[4]s that will close this issue %[2]s` issues.ref_reopening_from = `referenced a pull request %[4]s that will reopen this issue %[2]s` -issues.ref_closed_from = `closed this issue %[4]s %[2]s` -issues.ref_reopened_from = `reopened this issue %[4]s %[2]s` issues.ref_from = `from %[1]s` issues.author = Author issues.author_helper = This user is the author. @@ -1640,7 +1596,6 @@ issues.label.filter_sort.by_size = Smallest size issues.label.filter_sort.reverse_by_size = Largest size issues.num_participants = %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 = Unpin @@ -1694,28 +1649,21 @@ issues.add_time_manually = Manually Add Time 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.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'." -issues.error_modifying_due_date = "Failed to modify the due date." -issues.error_removing_due_date = "Failed to remove the due date." issues.push_commit_1 = "added %d commit %s" issues.push_commits_n = "added %d commits %s" issues.force_push_codes = `force-pushed %[1]s from %[2]s to %[4]s %[6]s` issues.force_push_compare = Compare issues.due_date_form = "yyyy-mm-dd" -issues.due_date_form_add = "Add due date" issues.due_date_form_edit = "Edit" issues.due_date_form_remove = "Remove" -issues.due_date_not_writer = "You need write access to this repository in order to update the due date of an issue." issues.due_date_not_set = "No due date set." issues.due_date_added = "added the due date %s %s" issues.due_date_modified = "modified the due date from %[2]s to %[1]s %[3]s" issues.due_date_remove = "removed the due date %s %s" issues.due_date_overdue = "Overdue" -issues.due_date_invalid = "The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'." issues.dependency.title = Dependencies issues.dependency.issue_no_dependencies = No dependencies set. issues.dependency.pr_no_dependencies = No dependencies set. @@ -1756,7 +1704,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 %s %s" issues.review.remove_review_request = "removed review request for %s %s" issues.review.remove_review_request_self = "refused to review %s" @@ -1781,7 +1728,6 @@ issues.review.requested = Review pending issues.review.rejected = Changes requested issues.review.stale = Updated since approval issues.review.unofficial = Uncounted approval -issues.assignee.error = Not all assignees was added due to an unexpected error. issues.reference_issue.body = Body issues.content_history.deleted = deleted issues.content_history.edited = edited @@ -2105,7 +2051,6 @@ contributors.contribution_type.additions = Additions contributors.contribution_type.deletions = Deletions settings = Settings -settings.desc = Settings is where you can manage the settings for the repository settings.options = Repository settings.public_access = Public Access settings.public_access_desc = Configure public visitor's access permissions to override the defaults of this repository. @@ -2134,7 +2079,6 @@ settings.mirror_settings.docs.pull_mirror_instructions = To set up a pull mirror settings.mirror_settings.docs.more_information_if_disabled = You can find out more about push and pull mirrors here: settings.mirror_settings.docs.doc_link_title = How do I mirror repositories? settings.mirror_settings.docs.doc_link_pull_section = the "Pulling from a remote repository" section of the documentation. -settings.mirror_settings.docs.pulling_remote_title = Pulling from a remote repository settings.mirror_settings.mirrored_repository = Mirrored repository settings.mirror_settings.pushed_repository = Pushed repository settings.mirror_settings.direction = Direction @@ -2238,13 +2182,10 @@ settings.signing_settings = Signing Verification Settings settings.trust_model = Signature Trust Model settings.trust_model.default = Default Trust Model settings.trust_model.default.desc= Use the default repository trust model for this installation. -settings.trust_model.collaborator = Collaborator settings.trust_model.collaborator.long = Collaborator: Trust signatures by collaborators settings.trust_model.collaborator.desc = Valid signatures by collaborators of this repository will be marked "trusted" - (whether they match the committer or not). Otherwise, valid signatures will be marked "untrusted" if the signature matches the committer and "unmatched" if not. -settings.trust_model.committer = Committer settings.trust_model.committer.long = Committer: Trust signatures that match committers (This matches GitHub and will force Gitea signed commits to have Gitea as the committer) settings.trust_model.committer.desc = Valid signatures will only be marked "trusted" if they match the committer, otherwise they will be marked "unmatched". This forces Gitea to be the committer on signed commits with the actual committer marked as Co-authored-by: and Co-committed-by: trailer in the commit. The default Gitea key must match a User in the database. -settings.trust_model.collaboratorcommitter = Collaborator+Committer settings.trust_model.collaboratorcommitter.long = Collaborator+Committer: Trust signatures by collaborators which match the committer settings.trust_model.collaboratorcommitter.desc = Valid signatures by collaborators of this repository will be marked "trusted" if they match the committer. Otherwise, valid signatures will be marked "untrusted" if the signature matches the committer and "unmatched" otherwise. This will force Gitea to be marked as the committer on signed commits with the actual committer marked as Co-Authored-By: and Co-Committed-By: trailer in the commit. The default Gitea key must match a User in the database. settings.wiki_delete = Delete Wiki Data @@ -2303,7 +2244,6 @@ settings.githook_edit_desc = If the hook is inactive, sample content will be pre settings.githook_name = Hook Name settings.githook_content = Hook Content settings.update_githook = Update Hook -settings.add_webhook_desc = Gitea will send POST requests with a specified content type to the target URL. Read more in the webhooks guide. settings.payload_url = Target URL settings.http_method = HTTP Method settings.content_type = POST Content Type @@ -2381,9 +2321,6 @@ settings.update_webhook = Update Webhook settings.update_hook_success = The webhook has been updated. settings.delete_webhook = Remove Webhook settings.recent_deliveries = Recent Deliveries -settings.hook_type = Hook Type -settings.slack_token = Token -settings.slack_domain = Domain settings.slack_channel = Channel settings.add_web_hook_desc = Integrate %s into your repository. settings.web_hook_name_gitea = Gitea @@ -2420,12 +2357,7 @@ settings.branches = Branches settings.protected_branch = Branch Protection settings.protected_branch.save_rule = Save Rule settings.protected_branch.delete_rule = Delete Rule -settings.protected_branch_can_push = Allow push? -settings.protected_branch_can_push_yes = You can push -settings.protected_branch_can_push_no = You cannot push settings.branch_protection = Branch Protection Rules for Branch '%s' -settings.protect_this_branch = Enable Branch Protection -settings.protect_this_branch_desc = Prevents deletion and restricts Git pushing and merging to the branch. settings.protect_disable_push = Disable Push settings.protect_disable_push_desc = No pushing will be allowed to this branch. settings.protect_disable_force_push = Disable Force Push @@ -2477,8 +2409,6 @@ settings.protect_protected_file_patterns = "Protected file patterns (separated u settings.protect_protected_file_patterns_desc = "Protected files are not allowed to be changed directly even if user has rights to add, edit, or delete files in this branch. Multiple patterns can be separated using semicolon (';'). See %[2]s documentation for pattern syntax. Examples: .drone.yml, /docs/**/*.txt." settings.protect_unprotected_file_patterns = "Unprotected file patterns (separated using semicolon ';'):" settings.protect_unprotected_file_patterns_desc = "Unprotected files that are allowed to be changed directly if user has write access, bypassing push restriction. Multiple patterns can be separated using semicolon (';'). See %[2]s documentation for pattern syntax. Examples: .drone.yml, /docs/**/*.txt." -settings.add_protected_branch = Enable protection -settings.delete_protected_branch = Disable protection settings.update_protect_branch_success = Branch protection for rule "%s" has been updated. settings.remove_protected_branch_success = Branch protection for rule "%s" has been removed. settings.remove_protected_branch_failed = Removing branch protection rule "%s" failed. @@ -2495,7 +2425,6 @@ settings.block_admin_merge_override_desc = Administrators must follow branch pro settings.default_branch_desc = Select a default repository branch for pull requests and code commits: settings.merge_style_desc = Merge Styles settings.default_merge_style_desc = Default Merge Style -settings.choose_branch = Choose a branch… settings.no_protected_branch = There are no protected branches. settings.edit_protected_branch = Edit settings.protected_branch_required_rule_name = Required rule name @@ -2572,8 +2501,6 @@ settings.lfs_pointers.associateAccessible=Associate accessible %d OIDs settings.rename_branch_failed_exist=Cannot rename branch because target branch %s exists. settings.rename_branch_failed_not_exist=Cannot rename branch %s because it does not exist. settings.rename_branch_success =Branch %s was successfully renamed to %s. -settings.rename_branch_from=old branch name -settings.rename_branch_to=new branch name settings.rename_branch=Rename branch diff.browse_source = Browse Source @@ -2627,7 +2554,6 @@ 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 diff.submodule_added = Submodule %[1]s added at %[2]s @@ -2647,7 +2573,6 @@ release.compare = Compare release.edit = edit release.ahead.commits = %d 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. @@ -2675,7 +2600,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 = Downloads: %s release.add_tag_msg = Use the title and content of release as tag message. @@ -2685,7 +2609,6 @@ release.tags_for = Tags for %s 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? @@ -2713,7 +2636,6 @@ branch.create_new_branch = Create branch from branch: branch.confirm_create_branch = Create branch branch.warning_rename_default_branch = You are renaming the default branch. branch.rename_branch_to = Rename "%s" to: -branch.confirm_rename_branch = Rename branch branch.create_branch_operation = Create branch branch.new_branch = Create new branch branch.new_branch_from = Create new branch from "%s" @@ -2729,7 +2651,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. @@ -2779,7 +2700,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 @@ -2804,11 +2724,8 @@ settings.confirm_delete_account = Confirm Deletion settings.delete_org_title = Delete Organization settings.delete_org_desc = This organization will be deleted permanently. Continue? settings.hooks_desc = Add webhooks which will be triggered for all repositories under this organization. - settings.labels_desc = Add labels which can be used on issues for all repositories under this organization. -members.membership_visibility = Membership Visibility: -members.public = Visible members.public_helper = make hidden members.private = Hidden members.private_helper = make visible @@ -2819,8 +2736,6 @@ members.remove = Remove members.remove.detail = Remove %[1]s from %[2]s? members.leave = Leave members.leave.detail = Leave %s? -members.invite_desc = Add a new member to %s: -members.invite_now = Invite Now teams.join = Join teams.leave = Leave @@ -2840,7 +2755,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 all repositories and have administrator access to the organization. -teams.members = Team Members teams.update_settings = Update Settings teams.delete_team = Delete Team teams.add_team_member = Add Team Member @@ -2849,14 +2763,9 @@ teams.invite_team_member.list = Pending Invitations 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.read_permission_desc = This team grants Read access: members can view and clone team repositories. teams.write_permission_desc = This team grants Write access: members can read from and push to team repositories. teams.admin_permission_desc = This team grants Admin access: members can read from, push to and add collaborators to team repositories. -teams.create_repo_permission_desc = Additionally, this team grants Create repository 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. @@ -2867,9 +2776,6 @@ teams.specific_repositories = Specific repositories teams.specific_repositories_helper = Members will only have access to repositories explicitly added to the team. Selecting this will not automatically remove repositories already added with All repositories. teams.all_repositories = All repositories teams.all_repositories_helper = Team has access to all repositories. Selecting this will add all existing repositories to the team. -teams.all_repositories_read_permission_desc = This team grants Read access to all repositories: members can view and clone repositories. -teams.all_repositories_write_permission_desc = This team grants Write access to all repositories: members can read from and push to repositories. -teams.all_repositories_admin_permission_desc = This team grants Admin access to all repositories: members can read from, push to and add collaborators to repositories. teams.invite.title = You have been invited to join team %s in organization %s. teams.invite.by = Invited by %s teams.invite.description = Please click the button below to join the team. @@ -3083,7 +2989,6 @@ repos.unadopted = Unadopted Repositories repos.unadopted.no_more = No more unadopted repositories found repos.owner = Owner repos.name = Name -repos.private = Private repos.issues = Issues repos.size = Size repos.lfs_size = LFS Size @@ -3122,7 +3027,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 @@ -3151,7 +3055,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 @@ -3188,7 +3091,6 @@ auths.oauth2_admin_group = Group Claim value for administrator users. (Optional auths.oauth2_restricted_group = Group Claim value for restricted users. (Optional - requires claim name above) auths.oauth2_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above) auths.oauth2_map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding group. -auths.enable_auto_register = Enable Auto Registration auths.sspi_auto_create_users = Automatically create users auths.sspi_auto_create_users_helper = Allow SSPI auth method to automatically create new accounts for users that login for the first time auths.sspi_auto_activate_users = Automatically activate users @@ -3245,7 +3147,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 Authentication User @@ -3320,9 +3221,6 @@ config.send_test_mail_submit = Send config.test_mail_failed = Failed to send a testing email to "%s": %v config.test_mail_sent = A testing 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 @@ -3340,10 +3238,8 @@ config.cookie_name = Cookie Name config.gc_interval_time = GC Interval Time config.session_life_time = Session Life Time config.https_only = HTTPS Only -config.cookie_life_time = Cookie Life Time 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. @@ -3363,7 +3259,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 @@ -3383,13 +3278,9 @@ monitor.trace = Trace monitor.performance_logs = Performance Logs monitor.processes_count = %d Processes monitor.download_diagnosis_report = Download diagnosis report -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 = Cancelling a process may cause data loss -monitor.process.children = Children monitor.queues = Queues monitor.queue = Queue: %s @@ -3509,7 +3400,6 @@ watching = Watching no_subscriptions = No subscriptions [gpg] -default_key=Signed with default key error.extract_sign = Failed to extract signature error.generate_hash = Failed to generate hash of commit error.no_committer_account = No account linked to committer's email address @@ -3522,7 +3412,6 @@ error.probable_bad_default_signature = "WARNING! Although the default key has th [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 @@ -3577,7 +3466,6 @@ composer.registry = Setup this registry in your ~/.composer/config.json.condarc file: @@ -3668,7 +3556,6 @@ owner.settings.cleanuprules.none = No cleanup rules available. Please consult th 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 @@ -3804,7 +3691,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.deletion.failed = Failed to remove variable. variables.deletion.success = The variable has been removed. diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index ea15e90e5c297..8f4f0a73ab4ef 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -314,7 +314,7 @@ func MigrateStatus(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}, } } From 468597269d00a8fcdc64830298ae53ab46ea6d30 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 18 Jun 2025 11:22:06 -0700 Subject: [PATCH 3/7] Improve the performance to scan --- .github/workflows/pull-compliance.yml | 10 ++++ Makefile | 5 ++ build/{clean-locales.go => check-locales.go} | 54 ++++++++++---------- 3 files changed, 43 insertions(+), 26 deletions(-) rename build/{clean-locales.go => check-locales.go} (68%) diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index f6720bf2f6d9d..e8065ce7caea2 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -178,6 +178,16 @@ jobs: GOOS: linux GOARCH: 386 + translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make check-locales + docs: if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed diff --git a/Makefile b/Makefile index bb70b91bb9b0c..b34ef07b25e37 100644 --- a/Makefile +++ b/Makefile @@ -884,6 +884,11 @@ update-translations: mv ./translations/*.ini ./options/locale/ rmdir ./translations +.PHONY: check-locales +check-locales: + @echo "Checking translations..." + $(GO) run build/check-locales.go + .PHONY: generate-gitignore generate-gitignore: ## update gitignore files $(GO) run build/generate-gitignores.go diff --git a/build/clean-locales.go b/build/check-locales.go similarity index 68% rename from build/clean-locales.go rename to build/check-locales.go index a5faee90c5b35..6ff17a8f6a1cb 100644 --- a/build/clean-locales.go +++ b/build/check-locales.go @@ -6,7 +6,6 @@ package main import ( - "errors" "os" "path/filepath" "strings" @@ -16,7 +15,8 @@ import ( "github.com/gobwas/glob" ) -func searchTranslationKeyInDirs(key string) (bool, error) { +func searchTranslationKeyInDirs(keys []string) ([]bool, error) { + res := make([]bool, len(keys)) for _, dir := range []string{ "cmd", "models", @@ -25,20 +25,15 @@ func searchTranslationKeyInDirs(key string) (bool, error) { "services", "templates", } { - found, err := searchTranslationKeyInDir(dir, key) - if err != nil { - return false, err - } - if found { - return true, nil + if err := searchTranslationKeyInDir(dir, keys, &res); err != nil { + return nil, err } } - return false, nil + return res, nil } -func searchTranslationKeyInDir(dir, key string) (bool, error) { - errFound := errors.New("found") - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { +func searchTranslationKeyInDir(dir string, keys []string, res *[]bool) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } @@ -52,16 +47,13 @@ func searchTranslationKeyInDir(dir, key string) (bool, error) { if err != nil { return err } - if strings.Contains(string(bs), `"`+key+`"`) { - return errFound + for i, key := range keys { + if !(*res)[i] && strings.Contains(string(bs), `"`+key+`"`) { + (*res)[i] = true + } } - return nil }) - if err == errFound { - return true, nil - } - return false, err } var whitelist = []string{ @@ -98,6 +90,7 @@ func main() { panic(err) } + keys := []string{} for _, section := range iniFile.Sections() { for _, key := range section.Keys() { var trKey string @@ -109,14 +102,23 @@ func main() { if isWhitelisted(trKey) { continue } + keys = append(keys, trKey) + } + } - found, err := searchTranslationKeyInDirs(trKey) - if err != nil { - panic(err) - } - if !found { - println("unused locale key:", trKey) - } + results, err := searchTranslationKeyInDirs(keys) + if err != nil { + panic(err) + } + + var found bool + for i, result := range results { + if !result { + found = true + println("unused locale key:", keys[i]) } } + if found { + os.Exit(1) // exit with error if any unused locale key is found + } } From 7a1a8a9f62c400d946c5c1b3d67a1423e46564d1 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 28 Jun 2025 10:31:23 -0700 Subject: [PATCH 4/7] Move i18n tools to tools directory --- Makefile | 13 +++++++++---- build/backport-locales.go => tools/i18n/backport.go | 0 build/check-locales.go => tools/i18n/cleanup.go | 0 3 files changed, 9 insertions(+), 4 deletions(-) rename build/backport-locales.go => tools/i18n/backport.go (100%) rename build/check-locales.go => tools/i18n/cleanup.go (100%) diff --git a/Makefile b/Makefile index b34ef07b25e37..78f4d20448556 100644 --- a/Makefile +++ b/Makefile @@ -884,10 +884,15 @@ update-translations: mv ./translations/*.ini ./options/locale/ rmdir ./translations -.PHONY: check-locales -check-locales: - @echo "Checking translations..." - $(GO) run build/check-locales.go +.PHONY: i18n-backport +i18n-backport: + @echo "Backport translations ..." + $(GO) run tools/i18n/backport.go + +.PHONY: i18n-cleanup +i18n-cleanup: + @echo "Checking and Cleanup translations..." + $(GO) run tools/i18n/cleanup.go .PHONY: generate-gitignore generate-gitignore: ## update gitignore files diff --git a/build/backport-locales.go b/tools/i18n/backport.go similarity index 100% rename from build/backport-locales.go rename to tools/i18n/backport.go diff --git a/build/check-locales.go b/tools/i18n/cleanup.go similarity index 100% rename from build/check-locales.go rename to tools/i18n/cleanup.go From 6adf20673fa19267a01d69ab8af883779b616486 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 28 Jun 2025 10:36:56 -0700 Subject: [PATCH 5/7] improvements --- .github/workflows/pull-compliance.yml | 4 ++-- Makefile | 8 ++++---- tools/i18n/{cleanup.go => check.go} | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename tools/i18n/{cleanup.go => check.go} (100%) diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index e8065ce7caea2..a1b0f760c890d 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -178,7 +178,7 @@ jobs: GOOS: linux GOARCH: 386 - translations: + i18n-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -186,7 +186,7 @@ jobs: with: go-version-file: go.mod check-latest: true - - run: make check-locales + - run: make i18n-check docs: if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true' diff --git a/Makefile b/Makefile index 4c19646c103a6..df59ac98a8ff2 100644 --- a/Makefile +++ b/Makefile @@ -903,10 +903,10 @@ i18n-backport: @echo "Backport translations ..." $(GO) run tools/i18n/backport.go -.PHONY: i18n-cleanup -i18n-cleanup: - @echo "Checking and Cleanup translations..." - $(GO) run tools/i18n/cleanup.go +.PHONY: i18n-check +i18n-check: + @echo "Checking unused translations..." + $(GO) run tools/i18n/check.go .PHONY: generate-gitignore generate-gitignore: ## update gitignore files diff --git a/tools/i18n/cleanup.go b/tools/i18n/check.go similarity index 100% rename from tools/i18n/cleanup.go rename to tools/i18n/check.go From e29682ae683517e5e4a342901544ae1cbe90a65d Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 29 Jun 2025 17:36:50 -0700 Subject: [PATCH 6/7] Add feature to check untranslated keys --- tools/i18n/check.go | 190 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 176 insertions(+), 14 deletions(-) diff --git a/tools/i18n/check.go b/tools/i18n/check.go index 6ff17a8f6a1cb..84fb9c98cd4ab 100644 --- a/tools/i18n/check.go +++ b/tools/i18n/check.go @@ -6,17 +6,25 @@ package main import ( + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" + "slices" "strings" + "text/template" + "text/template/parse" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "github.com/gobwas/glob" ) -func searchTranslationKeyInDirs(keys []string) ([]bool, error) { +func searchTranslationKeyInDirs(keys []string) ([]bool, []string, error) { res := make([]bool, len(keys)) + untranslatedKeysSum := make([]string, 0, 20) for _, dir := range []string{ "cmd", "models", @@ -25,15 +33,19 @@ func searchTranslationKeyInDirs(keys []string) ([]bool, error) { "services", "templates", } { - if err := searchTranslationKeyInDir(dir, keys, &res); err != nil { - return nil, err + untranslatedKeys, err := checkTranslationKeysInDir(dir, keys, &res) + if err != nil { + return nil, nil, err } + + untranslatedKeysSum = append(untranslatedKeysSum, untranslatedKeys...) } - return res, nil + return res, untranslatedKeysSum, nil } -func searchTranslationKeyInDir(dir string, keys []string, res *[]bool) error { - return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { +func checkTranslationKeysInDir(dir string, keys []string, res *[]bool) ([]string, error) { + var untranslatedSum []string + if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } @@ -43,17 +55,154 @@ func searchTranslationKeyInDir(dir string, keys []string, res *[]bool) error { return nil } - bs, err := os.ReadFile(path) + // search unused keys in the file + if err := searchUnusedKeyInFile(dir, path, keys, res); err != nil { + return err + } + + // search untranslated keys in the file + untranslated, err := searchUnTranslatedKeyInFile(dir, path, keys) if err != nil { return err } - for i, key := range keys { - if !(*res)[i] && strings.Contains(string(bs), `"`+key+`"`) { - (*res)[i] = true + untranslatedSum = append(untranslatedSum, untranslated...) + + return nil + }); err != nil { + return nil, err + } + return untranslatedSum, nil +} + +func searchUnusedKeyInFile(dir, path string, keys []string, res *[]bool) error { + bs, err := os.ReadFile(path) + if err != nil { + return err + } + for i, key := range keys { + if !(*res)[i] && strings.Contains(string(bs), `"`+key+`"`) { + (*res)[i] = true + } + } + return nil +} + +func searchUntranslatedKeyInGoFile(dir, path string, keys []string) ([]string, error) { + if filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil, nil + } + + var untranslated []string + fs := token.NewFileSet() + node, err := parser.ParseFile(fs, path, nil, 0) + if err != nil { + return nil, err + } + + ast.Inspect(node, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if funIdent, ok := call.Fun.(*ast.SelectorExpr); ok { + switch funIdent.Sel.Name { + case "Tr", "TrString": + if len(call.Args) >= 1 { + if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Kind == token.STRING { + key := strings.Trim(lit.Value, `"`) + if !slices.Contains(keys, key) { + untranslated = append(untranslated, key) + } + } + } + case "TrN": + if len(call.Args) >= 3 { + if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Kind == token.STRING { + key := strings.Trim(lit.Value, `"`) + if !slices.Contains(keys, key) { + untranslated = append(untranslated, key) + } + } + if lit, ok := call.Args[2].(*ast.BasicLit); ok && lit.Kind == token.STRING { + key := strings.Trim(lit.Value, `"`) + if !slices.Contains(keys, key) { + untranslated = append(untranslated, key) + } + } + } } } - return nil + return true }) + + return untranslated, err +} + +func extractI18nKeys(node parse.Node) []string { + switch n := node.(type) { + case *parse.ListNode: + var keys []string + for _, sub := range n.Nodes { + keys = append(keys, extractI18nKeys(sub)...) + } + return keys + case *parse.ActionNode: + return extractI18nKeys(n.Pipe) + case *parse.PipeNode: + var keys []string + for _, cmd := range n.Cmds { + keys = append(keys, extractI18nKeys(cmd)...) + } + return keys + case *parse.CommandNode: + if len(n.Args) >= 2 { + if ident, ok := n.Args[0].(*parse.IdentifierNode); ok && ident.Ident == "ctx.locale.Tr" { + if str, ok := n.Args[1].(*parse.StringNode); ok { + return []string{str.Text} + } + } + } + } + return nil +} + +func searchUntranslatedKeyInTemplateFile(dir, path string, keys []string) ([]string, error) { + if filepath.Ext(path) != ".tmpl" { + return nil, nil + } + + bs, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // The template parser requires the function map otherwise it will return failure + t, err := template.New("test").Funcs(templates.NewFuncMap()).Parse(string(bs)) + if err != nil { + return nil, err + } + + untranslatedKeys := []string{} + keysFoundInTempl := extractI18nKeys(t.Root) + for _, key := range keysFoundInTempl { + if !slices.Contains(keys, key) { + untranslatedKeys = append(untranslatedKeys, key) + } + } + return untranslatedKeys, nil +} + +func searchUnTranslatedKeyInFile(dir, path string, keys []string) ([]string, error) { + untranslatedKeys, err := searchUntranslatedKeyInGoFile(dir, path, keys) + if err != nil { + return nil, err + } + + untranslatedKeysInTmpl, err := searchUntranslatedKeyInTemplateFile(dir, path, keys) + if err != nil { + return nil, err + } + return append(untranslatedKeys, untranslatedKeysInTmpl...), nil } var whitelist = []string{ @@ -106,7 +255,7 @@ func main() { } } - results, err := searchTranslationKeyInDirs(keys) + results, untranslatedKeys, err := searchTranslationKeyInDirs(keys) if err != nil { panic(err) } @@ -114,10 +263,23 @@ func main() { var found bool for i, result := range results { if !result { - found = true - println("unused locale key:", keys[i]) + if !found { + println("unused locale keys found\n---") + found = true + } + println(keys[i]) } } + + if len(untranslatedKeys) > 0 { + found = true + println("\nuntranslated locale keys found\n---") + } + for _, key := range untranslatedKeys { + println(key) + } + println() + if found { os.Exit(1) // exit with error if any unused locale key is found } From c668142c8472640a8cc2ef8778a3dd7d3c1e2696 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Tue, 1 Jul 2025 12:57:35 -0700 Subject: [PATCH 7/7] Using comment with i18n-check: to match dynamic translation keys --- models/actions/runner.go | 1 + models/actions/status.go | 1 + models/git/commit_status.go | 1 + models/issues/comment.go | 2 + modules/auth/password/password.go | 1 + modules/translation/i18n/localestore.go | 1 + modules/translation/translation.go | 1 + modules/web/middleware/binding.go | 2 + routers/web/admin/admin.go | 1 + routers/web/repo/actions/view.go | 1 + routers/web/repo/activity.go | 1 + routers/web/repo/editor_error.go | 1 + routers/web/repo/migrate.go | 1 + services/context/base.go | 1 + services/cron/setting.go | 3 + services/cron/tasks.go | 1 + tools/i18n/check.go | 99 ++++++++++++++----------- 17 files changed, 77 insertions(+), 42 deletions(-) diff --git a/models/actions/runner.go b/models/actions/runner.go index 81d4249ae0b85..6e98f15f5d05a 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -114,6 +114,7 @@ func (r *ActionRunner) StatusName() string { } func (r *ActionRunner) StatusLocaleName(lang translation.Locale) string { + // i18n-check: actions.runners.status.* return lang.TrString("actions.runners.status." + r.StatusName()) } diff --git a/models/actions/status.go b/models/actions/status.go index 2b1d70613c71b..895fd52fb36de 100644 --- a/models/actions/status.go +++ b/models/actions/status.go @@ -43,6 +43,7 @@ func (s Status) String() string { // LocaleString returns the locale string name of the Status func (s Status) LocaleString(lang translation.Locale) string { + // i18n-check: actions.status.* return lang.TrString("actions.status." + s.String()) } diff --git a/models/git/commit_status.go b/models/git/commit_status.go index f85e1b15e5600..7f2f7d4030991 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -206,6 +206,7 @@ func (status *CommitStatus) APIURL(ctx context.Context) string { // LocaleString returns the locale string name of the Status func (status *CommitStatus) LocaleString(lang translation.Locale) string { + // i18n-check: repo.commitstatus.* return lang.TrString("repo.commitstatus." + status.State.String()) } diff --git a/models/issues/comment.go b/models/issues/comment.go index 9bef96d0ddfcc..9d2cdb0bfe71e 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -225,11 +225,13 @@ const ( // LocaleString returns the locale string name of the role func (r RoleInRepo) LocaleString(lang translation.Locale) string { + // i18n-check: repo.issues.role.* return lang.TrString("repo.issues.role." + string(r)) } // LocaleHelper returns the locale tooltip of the role func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { + // i18n-check: repo.issues.role.*_helper return lang.TrString("repo.issues.role." + string(r) + "_helper") } diff --git a/modules/auth/password/password.go b/modules/auth/password/password.go index a1e101dd621cb..e0781cbf96f6e 100644 --- a/modules/auth/password/password.go +++ b/modules/auth/password/password.go @@ -128,6 +128,7 @@ func BuildComplexityError(locale translation.Locale) template.HTML { buffer.WriteString("
    ") for _, c := range requiredList { buffer.WriteString("
  • ") + // i18n-check: form.password_* buffer.WriteString(locale.TrString(c.TrNameOne)) buffer.WriteString("
  • ") } diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go index 4f1ae7e13d2fb..2e5cc697f5fce 100644 --- a/modules/translation/i18n/localestore.go +++ b/modules/translation/i18n/localestore.go @@ -144,6 +144,7 @@ func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML { args[i] = template.HTMLEscapeString(fmt.Sprint(v)) } } + // i18n-check: ignore return template.HTML(l.TrString(trKey, args...)) } diff --git a/modules/translation/translation.go b/modules/translation/translation.go index 36ae58a9f19ad..4e985536533db 100644 --- a/modules/translation/translation.go +++ b/modules/translation/translation.go @@ -238,6 +238,7 @@ func (l *locale) TrN(cnt any, key1, keyN string, args ...any) template.HTML { } else if t, ok := cnt.(int64); ok { c = t } else { + // i18n-check: ignore return l.Tr(keyN, args...) } diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index ee4eca976e367..dfaa8bca1ced4 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -104,8 +104,10 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo trName := field.Tag.Get("locale") if len(trName) == 0 { + // i18n-check: form.* trName = l.TrString("form." + field.Name) } else { + // i18n-check: ignore trName = l.TrString(trName) } diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index 0cd13acf6012b..efa6c67d88d94 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -180,6 +180,7 @@ func DashboardPost(ctx *context.Context) { task := cron.GetTask(form.Op) if task != nil { go task.RunWithUser(ctx.Doer, nil) + // i18n-check: admin.dashboard.* ctx.Flash.Success(ctx.Tr("admin.dashboard.task.started", ctx.Tr("admin.dashboard."+form.Op))) } else { ctx.Flash.Error(ctx.Tr("admin.dashboard.task.unknown", form.Op)) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 7e1b923fa4b05..f19cbf1b9cb87 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -847,6 +847,7 @@ func Run(ctx *context_module.Context) { }) if err != nil { if errLocale := util.ErrorAsLocale(err); errLocale != nil { + // i18n-check: ignore ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...)) ctx.Redirect(redirectURL) } else { diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go index 8232f0cc04bc3..0a883e774acbd 100644 --- a/routers/web/repo/activity.go +++ b/routers/web/repo/activity.go @@ -51,6 +51,7 @@ func Activity(ctx *context.Context) { } ctx.Data["DateFrom"] = timeFrom ctx.Data["DateUntil"] = timeUntil + // i18n-check: repo.activity.period.* ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) canReadCode := ctx.Repo.CanRead(unit.TypeCode) diff --git a/routers/web/repo/editor_error.go b/routers/web/repo/editor_error.go index 245226a039e4d..04904a178157a 100644 --- a/routers/web/repo/editor_error.go +++ b/routers/web/repo/editor_error.go @@ -39,6 +39,7 @@ func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) { if errAs := util.ErrorAsLocale(err); errAs != nil { + // i18n-check: ignore ctx.JSONError(ctx.Tr(errAs.TrKey, errAs.TrArgs...)) } else if errAs, ok := errorAs[git.ErrNotExist](err); ok { ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath)) diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index 8f4f0a73ab4ef..9b3e52191aea4 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -318,6 +318,7 @@ func MigrateStatus(ctx *context.Context) { Args: []any{task.Message}, } } + // i18n-check: repo.migrate.migrating_failed.* message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...) } diff --git a/services/context/base.go b/services/context/base.go index f3f92b7eeb906..a302658bc9828 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -167,6 +167,7 @@ func (b *Base) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { } func (b *Base) Tr(msg string, args ...any) template.HTML { + // i18n-check: ignore return b.Locale.Tr(msg, args...) } diff --git a/services/cron/setting.go b/services/cron/setting.go index 6dad88830abb5..09fac9166293b 100644 --- a/services/cron/setting.go +++ b/services/cron/setting.go @@ -70,6 +70,7 @@ func (b *BaseConfig) DoNoticeOnSuccess() bool { // Please note the `status` string will be concatenated with `admin.dashboard.cron.` and `admin.dashboard.task.` to provide locale messages. Similarly `name` will be composed with `admin.dashboard.` to provide the locale name for the task. func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer string, args ...any) string { realArgs := make([]any, 0, len(args)+2) + // i18n-check: admin.dashboard.* realArgs = append(realArgs, locale.TrString("admin.dashboard."+name)) if doer == "" { realArgs = append(realArgs, "(Cron)") @@ -80,7 +81,9 @@ func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer realArgs = append(realArgs, args...) } if doer == "" { + // i18n-check: admin.dashboard.cron.* return locale.TrString("admin.dashboard.cron."+status, realArgs...) } + // i18n-check: admin.dashboard.task.* return locale.TrString("admin.dashboard.task."+status, realArgs...) } diff --git a/services/cron/tasks.go b/services/cron/tasks.go index f8a7444c49e5c..6e29b5fbb6b89 100644 --- a/services/cron/tasks.go +++ b/services/cron/tasks.go @@ -159,6 +159,7 @@ func RegisterTask(name string, config Config, fun func(context.Context, *user_mo log.Debug("Registering task: %s", name) i18nKey := "admin.dashboard." + name + // i18n-check: admin.dashboard.* if value := translation.NewLocale("en-US").TrString(i18nKey); value == i18nKey { return fmt.Errorf("translation is missing for task %q, please add translation for %q", name, i18nKey) } diff --git a/tools/i18n/check.go b/tools/i18n/check.go index 84fb9c98cd4ab..33de999fb0ab5 100644 --- a/tools/i18n/check.go +++ b/tools/i18n/check.go @@ -6,6 +6,7 @@ package main import ( + "fmt" "go/ast" "go/parser" "go/token" @@ -87,6 +88,55 @@ func searchUnusedKeyInFile(dir, path string, keys []string, res *[]bool) error { return nil } +func searchUntranslatedKeyInCall(path string, fset *token.FileSet, astf *ast.File, call *ast.CallExpr, arg ast.Expr, keys []string) string { + if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING { + key := strings.Trim(lit.Value, `"`) + if !slices.Contains(keys, key) { + return key + } + return "" + } + + var lastCg *ast.CommentGroup + for _, cg := range astf.Comments { + if cg.End() < call.Pos() { + if lastCg == nil || cg.End() > lastCg.End() { + lastCg = cg + } + } + } + if lastCg == nil { + fmt.Printf("no comment found for a dynamic translation key: %s:%d\n", path, fset.Position(call.Pos()).Line) + os.Exit(1) + return "" + } + + transKeyMatch, ok := strings.CutPrefix(lastCg.Text(), "i18n-check:") + if !ok { + fmt.Printf("no comment found for a dynamic translation key: %s:%d\n", path, fset.Position(call.Pos()).Line) + os.Exit(1) + return "" + } + transKeyMatch = strings.TrimSpace(transKeyMatch) + switch transKeyMatch { + case "ignore": // i18n-check: ignore + return "" + default: // i18n-check: + g := glob.MustCompile(transKeyMatch) + found := false + for _, key := range keys { + if g.Match(key) { + found = true + break + } + } + if !found { + return transKeyMatch + } + } + return "" +} + func searchUntranslatedKeyInGoFile(dir, path string, keys []string) ([]string, error) { if filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { return nil, nil @@ -94,7 +144,7 @@ func searchUntranslatedKeyInGoFile(dir, path string, keys []string) ([]string, e var untranslated []string fs := token.NewFileSet() - node, err := parser.ParseFile(fs, path, nil, 0) + node, err := parser.ParseFile(fs, path, nil, parser.ParseComments) if err != nil { return nil, err } @@ -108,26 +158,17 @@ func searchUntranslatedKeyInGoFile(dir, path string, keys []string) ([]string, e switch funIdent.Sel.Name { case "Tr", "TrString": if len(call.Args) >= 1 { - if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Kind == token.STRING { - key := strings.Trim(lit.Value, `"`) - if !slices.Contains(keys, key) { - untranslated = append(untranslated, key) - } + if key := searchUntranslatedKeyInCall(path, fs, node, call, call.Args[0], keys); key != "" { + untranslated = append(untranslated, key) } } case "TrN": if len(call.Args) >= 3 { - if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Kind == token.STRING { - key := strings.Trim(lit.Value, `"`) - if !slices.Contains(keys, key) { - untranslated = append(untranslated, key) - } + if key := searchUntranslatedKeyInCall(path, fs, node, call, call.Args[1], keys); key != "" { + untranslated = append(untranslated, key) } - if lit, ok := call.Args[2].(*ast.BasicLit); ok && lit.Kind == token.STRING { - key := strings.Trim(lit.Value, `"`) - if !slices.Contains(keys, key) { - untranslated = append(untranslated, key) - } + if key := searchUntranslatedKeyInCall(path, fs, node, call, call.Args[2], keys); key != "" { + untranslated = append(untranslated, key) } } } @@ -205,29 +246,6 @@ func searchUnTranslatedKeyInFile(dir, path string, keys []string) ([]string, err return append(untranslatedKeys, untranslatedKeysInTmpl...), nil } -var whitelist = []string{ - "repo.signing.wont_sign.*", - "repo.issues.role.*", - "repo.commitstatus.*", - "admin.dashboard.*", - "admin.dashboard.cron.*", - "admin.dashboard.task.*", - "repo.migrate.*.description", - "actions.runners.status.*", - "projects.*.display_name", - "admin.notices.*", - "form.NewBranchName", // FIXME: used in integration tests only -} - -func isWhitelisted(key string) bool { - for _, w := range whitelist { - if glob.MustCompile(w).Match(key) { - return true - } - } - return false -} - func main() { if len(os.Args) != 1 { println("usage: clean-locales") @@ -248,9 +266,6 @@ func main() { } else { trKey = section.Name() + "." + key.Name() } - if isWhitelisted(trKey) { - continue - } keys = append(keys, trKey) } }