diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 79c0fac063..94d3ac8441 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -1,7 +1,6 @@ "About" = "About"; "Add" = "Add"; "AddFolder" = "Add Folder"; -"AddItem" = "Add Item"; "AnErrorHasOccurred" = "An error has occurred."; "Back" = "Back"; "Bitwarden" = "Bitwarden"; @@ -9,7 +8,6 @@ "Copy" = "Copy"; "CopyPassword" = "Copy password"; "CopyUsername" = "Copy username"; -"Credits" = "Credits"; "Delete" = "Delete"; "DeleteAll" = "Delete all"; "Deleting" = "Deleting…"; @@ -18,22 +16,14 @@ "EditFolder" = "Edit folder"; "Email" = "Email"; "EmailAddress" = "Email address"; -"EmailUs" = "Email us"; -"EmailUsDescription" = "Email us directly to get help or leave feedback."; "EnterPIN" = "Enter your PIN code."; "Favorites" = "Favorites"; -"FileBugReport" = "File a bug report"; -"FileBugReportDescription" = "Open an issue at our GitHub repository."; -"FingerprintDirection" = "Use your fingerprint to verify."; "Folder" = "Folder"; "FolderCreated" = "New folder created."; "FolderDeleted" = "Folder deleted."; "FolderNone" = "No Folder"; "Folders" = "Folders"; "FolderUpdated" = "Folder saved"; -"GoToWebsite" = "Go to website"; -"HelpAndFeedback" = "Help and feedback"; -"Hide" = "Hide"; "InternetConnectionRequiredMessage" = "Please connect to the internet before continuing."; "InternetConnectionRequiredTitle" = "Internet connection required"; "InvalidMasterPassword" = "Invalid master password. Try again."; @@ -42,7 +32,6 @@ "LaunchBrowser" = "Launch browser"; "LogIn" = "Log in"; "LoginCredentials" = "Login credentials"; -"LogInNoun" = "Login"; "LogOut" = "Log out"; "LogoutConfirmation" = "Are you sure you want to log out?"; "RemoveAccount" = "Remove account"; @@ -52,7 +41,6 @@ "MasterPassword" = "Master password"; "More" = "More"; "MyVault" = "My vault"; -"Authenticator" = "Authenticator"; "Name" = "Name"; "No" = "No"; "Notes" = "Notes"; @@ -62,12 +50,8 @@ "Move" = "Move"; "Saving" = "Saving…"; "Settings" = "Settings"; -"Show" = "Show"; "ItemDeleted" = "Item deleted"; "Submit" = "Submit"; -"Sync" = "Sync"; -"ThankYou" = "Thank you"; -"Tools" = "Tools"; "UseFingerprintToUnlock" = "Use fingerprint to unlock"; "UseThisButtonToGenerateANewUniquePassword" = "Use this button to generate a new unique password."; "YouWillOnlyNeedToSetUpAnAuthenticatorKeyDescriptionLong" = "You’ll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in."; @@ -80,31 +64,17 @@ "Username" = "Username"; "ValidationFieldRequired" = "The %1$@ field is required."; "ValueHasBeenCopied" = "%1$@ copied"; -"VerifyFingerprint" = "Verify fingerprint"; "VerifyMasterPassword" = "Verify master password"; "VerifyPIN" = "Verify PIN"; "Version" = "Version"; "View" = "View"; -"VisitOurWebsite" = "Visit our website"; "Website" = "Website"; "Yes" = "Yes"; "Account" = "Account"; "StepOfStep" = "%1$d OF %2$d"; -"AccountCreated" = "Your new account has been created! You may now log in."; -"AddAnItem" = "Add an Item"; "AppExtension" = "App extension"; -"AutofillAccessibilityDescription" = "Use the Bitwarden accessibility service to autofill your logins across apps and the web."; -"AutofillService" = "Autofill service"; -"SetBitwardenAsPasskeyManagerDescription" = "Set Bitwarden as your passkey provider in device settings."; "AvoidAmbiguousCharacters" = "Avoid ambiguous characters"; -"BitwardenAppExtension" = "Bitwarden app extension"; -"BitwardenAppExtensionAlert2" = "The easiest way to add new logins to your vault is from the Bitwarden app extension. Learn more about using the Bitwarden app extension by navigating to the “Settings” screen."; -"BitwardenAppExtensionDescription" = "Use Bitwarden in Safari and other apps to autofill your logins."; -"BitwardenAutofillService" = "Bitwarden Autofill Service"; -"BitwardenAutofillAccessibilityServiceDescription" = "Use the Bitwarden accessibility service to autofill your logins."; "ChangeEmailAddress" = "Change email address"; -"ChangeEmailConfirmation" = "You can change your email address on the bitwarden.com web vault. Do you want to visit the website now?"; -"ChangeMasterPassword" = "Change master password"; "Close" = "Close"; "Continue" = "Continue"; "CreateAccount" = "Create account"; @@ -125,59 +95,38 @@ "Favorite" = "Favorite"; "Fingerprint" = "Fingerprint"; "GeneratePassword" = "Generate password"; -"GetPasswordHint" = "Get your master password hint"; "ImportItems" = "Import items"; -"ImportItemsConfirmation" = "You can bulk import items from the bitwarden.com web vault. Do you want to visit the website now?"; -"ImportItemsDescription" = "Quickly bulk import your items from other password management apps."; "LastSync" = "Last sync:"; "Length" = "Length"; "Lock" = "Lock"; "Immediately" = "Immediately"; -"VaultTimeout" = "Vault timeout"; -"VaultTimeoutAction" = "Vault timeout action"; "VaultTimeoutLogOutConfirmation" = "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?"; "LoggingIn" = "Logging in…"; "LogInToBitwarden" = "Log in to Bitwarden"; -"Manage" = "Manage"; "MasterPasswordConfirmationValMessage" = "Password confirmation is not correct."; "TheMasterPasswordIsThePasswordYouUseToAccessYourVault" = "The master password is the password you use to access your vault."; "MasterPasswordHint" = "Master password hint"; "NewMasterPasswordHint" = "New master password hint"; "MinNumbers" = "Minimum numbers"; "MinSpecial" = "Minimum special"; -"MoreSettings" = "More settings"; -"MustLogInMainApp" = "You must log into the main Bitwarden app before you can use the extension."; "Never" = "Never"; -"NewItemCreated" = "Item added"; -"NoFavorites" = "There are no favorites in your vault."; "NoItems" = "There are no items in your vault."; "NoItemsTap" = "There are no items in your vault for this website/app. Tap to add one."; "NoUsernamePasswordConfigured" = "This login does not have a username or password configured."; "OKGotIt" = "OK, got it!"; -"OptionDefaults" = "Option defaults are set from the main Bitwarden app’s password generator tool."; "Options" = "Options"; "Other" = "Other"; -"PasswordGenerated" = "Password generated"; -"PasswordGenerator" = "Password generator"; "PasswordHint" = "Password hint"; "PasswordHintAlert" = "We’ve sent you an email with your master password hint."; "PasswordOverrideAlert" = "Are you sure you want to overwrite the current password?"; "PushNotificationAlert" = "Bitwarden keeps your vault automatically synced by using push notifications. For the best possible experience, please select “Allow” on the following prompt when asked to allow push notifications."; -"RateTheApp" = "Rate the app"; -"RateTheAppDescription" = "Please consider helping us out with a good review!"; -"RegeneratePassword" = "Regenerate password"; -"SearchVault" = "Search vault"; "Security" = "Security"; "Select" = "Select"; -"SetPIN" = "Set PIN"; -"SetPINDirection" = "Enter a 4 digit PIN code to unlock the app with."; "ItemUpdated" = "Item saved"; "Submitting" = "Submitting…"; "Syncing" = "Syncing…"; "SyncingLogins" = "Syncing logins…"; "SyncingComplete" = "Syncing complete"; -"SyncingFailed" = "Syncing failed"; -"SyncVaultNow" = "Sync vault now"; "TouchID" = "Touch ID"; "TwoStepLogin" = "Two-step login"; "UnlockWithBiometrics" = "Unlock with biometrics"; @@ -185,57 +134,32 @@ "UnlockWithTouchID" = "Unlock with Touch ID"; "UnlockWithOpticID" = "Unlock with Optic ID"; "UnlockWithPIN" = "Unlock with PIN code"; -"Validating" = "Validating"; "VerificationCode" = "Verification code"; -"ViewItem" = "View item"; "WebVault" = "Bitwarden web vault"; -"Lost2FAApp" = "Lost authenticator app?"; "Items" = "Items"; "ExtensionActivated" = "Extension activated!"; -"Icons" = "Icons"; -"Translations" = "Translations"; "ItemsForUri" = "Items for %1$@"; "NoItemsForUri" = "There are no items in your vault for %1$@."; -"BitwardenAutofillServiceOverlay" = "When you select an input field and see a Bitwarden autofill overlay, you can tap it to launch the autofill service."; -"BitwardenAutofillServiceNotificationContent" = "Tap this notification to autofill an item from your vault."; -"BitwardenAutofillServiceOpenAccessibilitySettings" = "Open Accessibility Settings"; -"BitwardenAutofillServiceStep1" = "1. On the Android Accessibility Settings screen, touch “Bitwarden” under the Services heading."; -"BitwardenAutofillServiceStep2" = "2. Switch on the toggle and press OK to accept."; -"Disabled" = "Disabled"; -"Enabled" = "Enabled"; "Off" = "Off"; "On" = "On"; -"Status" = "Status"; -"BitwardenAutofillServiceAlert2" = "The easiest way to add new logins to your vault is from the Bitwarden Autofill Service. Learn more about using the Bitwarden Autofill Service by navigating to the “Settings” screen."; "Autofill" = "Autofill"; -"AutofillOrView" = "Do you want to autofill or view this item?"; -"BitwardenAutofillServiceMatchConfirm" = "Are you sure you want to autofill this item? It is not a complete match for “%1$@”."; "MatchingItems" = "Matching items"; -"PossibleMatchingItems" = "Possible matching items"; "Search" = "Search"; -"BitwardenAutofillServiceSearch" = "You are searching for an autofill item for “%1$@”."; "LearnAboutNewLogins" = "Learn about new logins"; "WeWillWalkYouThroughTheKeyFeaturesToAddANewLogin" = "We’ll walk you through the key features to add a new login."; "LearnOrg" = "Learn about organizations"; -"CannotOpenApp" = "Cannot open the app “%1$@”."; "AuthenticatorAppTitle" = "Authenticator app"; "EnterVerificationCodeApp" = "Enter the 6 digit verification code from your authenticator app."; "EnterVerificationCodeEmail" = "Enter the 6 digit verification code that was emailed to %1$@."; -"LoginUnavailable" = "Login unavailable"; -"NoTwoStepAvailable" = "This account has two-step login set up, however, none of the configured two-step providers are supported on this device. Please use a supported device and/or add additional providers that are better supported across devices (such as an authenticator app)."; "RecoveryCodeTitle" = "Recovery code"; "RememberMe" = "Remember me"; -"SendVerificationCodeAgain" = "Send verification code email again"; -"TwoStepLoginOptions" = "Two-step login options"; "UseAnotherTwoStepMethod" = "Use another two-step login method"; "VerificationEmailNotSent" = "Could not send verification email. Try again."; "VerificationEmailSent" = "Verification email sent"; -"YubiKeyInstruction" = "To continue, hold your YubiKey NEO against the back of the device or insert your YubiKey into your device’s USB port, then touch its button."; "YubiKeyTitle" = "YubiKey security key"; "AddNewAttachment" = "Add new attachment"; "Attachments" = "Attachments"; "UnableToDownloadFile" = "Unable to download file."; -"UnableToOpenFile" = "Your device cannot open this type of file."; "Downloading" = "Downloading…"; "AttachmentLargeWarning" = "This attachment is %1$@ in size. Are you sure you want to download it onto your device?"; "AuthenticatorKey" = "Authenticator key"; @@ -254,26 +178,19 @@ "AttachmentDeleted" = "Attachment deleted"; "ChooseFile" = "Choose file"; "File" = "File"; -"NoFileChosen" = "No file chosen"; "NoAttachments" = "There are no attachments."; -"FileSource" = "File Source"; -"FeatureUnavailable" = "Feature unavailable"; "MaxFileSize" = "Maximum file size is 100 MB."; "RequiredMaximumFileSizeIsX" = "Required. Maximum file size is %1$@."; -"UpdateKey" = "You cannot use this feature until you update your encryption key."; -"EncryptionKeyMigrationRequiredDescriptionLong" = "Encryption key migration required. Please login through the web vault to update your encryption key."; "LearnMore" = "Learn more"; "ApiUrl" = "API server URL"; "CustomEnvironment" = "Custom environment"; "CustomEnvironmentFooter" = "For advanced users. You can specify the base URL of each service independently."; "EnvironmentSaved" = "The environment URLs have been saved."; -"FormattedIncorrectly" = "%1$@ is not correctly formatted."; "IdentityUrl" = "Identity server URL"; "SelfHostedEnvironment" = "Self-hosted environment"; "SelfHostedEnvironmentFooter" = "Specify the base URL of your on-premise hosted Bitwarden installation."; "ServerUrl" = "Server URL"; "WebVaultUrl" = "Web vault server URL"; -"BitwardenAutofillServiceNotificationContentOld" = "Tap this notification to view items from your vault."; "CustomFields" = "Custom fields"; "CopyNumber" = "Copy number"; "CopySecurityCode" = "Copy security code"; @@ -326,24 +243,12 @@ "ShowWebsiteIcons" = "Show website icons"; "ShowWebsiteIconsDescription" = "Show a recognizable image next to each login."; "IconsUrl" = "Icons server URL"; -"AutofillWithBitwarden" = "Autofill with Bitwarden"; -"VaultIsLocked" = "Vault is locked"; -"GoToMyVault" = "Go to my vault"; "Collections" = "Collections"; "NoItemsCollection" = "There are no items in this collection"; "NoItemsFolder" = "There are no items in this folder"; "NoItemsTrash" = "There are no items in the trash."; -"AutofillAccessibilityService" = "Autofill Accessibility Service"; -"AutofillServiceDescription" = "The Bitwarden autofill service uses the Android Autofill Framework to assist in filling login information into other apps on your device."; -"BitwardenAutofillServiceDescription" = "Use the Bitwarden autofill service to fill login information into other apps."; -"BitwardenAutofillServiceOpenAutofillSettings" = "Open Autofill Settings"; "FaceID" = "Face ID"; -"FaceIDDirection" = "Use Face ID to verify."; "UseFaceIDToUnlock" = "Use Face ID To Unlock"; -"VerifyFaceID" = "Verify Face ID"; -"WindowsHello" = "Windows Hello"; -"BitwardenCredentialProviderGoToSettings" = "We were unable to automatically open the Android credential provider settings menu for you. You can navigate to the credential provider settings menu manually from Android Settings > System > Passwords & accounts > Passwords, passkeys and data services."; -"BitwardenAutofillGoToSettings" = "We were unable to automatically open the Android autofill settings menu for you. You can navigate to the autofill settings menu manually from Android Settings > System > Languages and input > Advanced > Autofill service."; "CustomFieldName" = "Custom field name"; "FieldTypeBoolean" = "Boolean"; "FieldTypeHidden" = "Hidden"; @@ -352,27 +257,20 @@ "NewCustomField" = "New custom field"; "SelectTypeField" = "What type of custom field do you want to add?"; "Remove" = "Remove"; -"URIPosition" = "URI %1$@"; "BaseDomain" = "Base domain"; "Default" = "Default"; "Exact" = "Exact"; "Host" = "Host"; "RegEx" = "Regular expression"; "StartsWith" = "Starts with"; -"URIMatchDetection" = "URI match detection"; "MatchDetection" = "Match detection"; -"YesAndSave" = "Yes, and save"; -"AutofillAndSave" = "Autofill and save"; "Organization" = "Organization"; "HoldYubikeyNearTop" = "Hold your Yubikey near the top of the device."; "TryAgain" = "Try again"; "YubiKeyInstructionIos" = "To continue, hold your YubiKey NEO against the back of the device."; -"BitwardenAutofillAccessibilityServiceDescription2" = "The accessibility service may be helpful to use when apps do not support the standard autofill service."; "AutofillActivated" = "AutoFill activated!"; -"MustLogInMainAppAutofill" = "You must log into the main Bitwarden app before you can use AutoFill."; "AutofillSetup" = "Your logins are now easily accessible right from your keyboard while logging into apps and websites."; "AutofillSetup2" = "We recommend disabling any other AutoFill apps under Settings if you do not plan to use them."; -"BitwardenAutofillDescription" = "Access your vault directly from your keyboard to quickly autofill passwords."; "AutofillTurnOn" = "To set up password autofill on your device, follow these instructions:"; "AutofillTurnOn1" = "1. Go to the iOS “Settings” app"; "AutofillTurnOn2" = "2. Tap “Passwords”"; @@ -390,7 +288,6 @@ "Identities" = "Identities"; "Logins" = "Logins"; "SecureNotes" = "Secure notes"; -"AllItems" = "All items"; "CheckingPassword" = "Checking password…"; "CheckPassword" = "Check if password has been exposed."; "PasswordSafe" = "This password was not found in any known data breaches. It should be safe to use."; @@ -400,21 +297,13 @@ "Types" = "Types"; "NoPasswordsToList" = "No passwords to list."; "NoItemsToList" = "There are no items to list."; -"SearchCollection" = "Search collection"; -"SearchFileSends" = "Search file Sends"; -"SearchTextSends" = "Search text Sends"; -"SearchGroup" = "Search %1$@"; -"Type" = "Type"; "MoveDown" = "Move down"; "MoveUp" = "Move Up"; -"Miscellaneous" = "Miscellaneous"; "NoCollectionsToList" = "There are no collections to list."; "MovedItemToOrg" = "%1$@ moved to %2$@."; -"ItemShared" = "Item has been shared."; "SelectOneCollection" = "You must select at least one collection."; "Share" = "Share"; "ShareAll" = "Share all"; -"ShareItem" = "Share Item"; "MoveToOrganization" = "Move to Organization"; "NoOrgsToList" = "No organizations to list."; "MoveToOrgDesc" = "Choose an organization that you wish to move this item to. Moving to an organization transfers ownership of the item to that organization. You will no longer be the direct owner of this item once it has been moved."; @@ -426,59 +315,33 @@ "NoFoldersToList" = "There are no folders to list."; "FingerprintPhrase" = "Fingerprint phrase"; "YourAccountsFingerprint" = "Your account’s fingerprint phrase"; -"LearnOrgConfirmation" = "Bitwarden allows you to share your vault items with others by using an organization account. Would you like to visit the bitwarden.com website to learn more?"; "WeAreUnableToProcessYourRequestPleaseTryAgainOrContactUs" = "We are unable to process your request. Please try again or contact us."; "Export" = "Export"; "ExportVault" = "Export vault"; "LockNow" = "Lock now"; "PIN" = "PIN"; "Unlock" = "Unlock"; -"UnlockVault" = "Unlock vault"; "LoggedInAsOn" = "Logged in as %1$@ on %2$@."; "VaultLockedMasterPassword" = "Your vault is locked. Verify your master password to continue."; "VaultLockedPIN" = "Your vault is locked. Verify your PIN code to continue."; -"VaultLockedIdentity" = "Your vault is locked. Verify your identity to continue."; "Dark" = "Dark"; "Light" = "Light"; "ClearClipboard" = "Clear clipboard"; "ClearClipboardDescription" = "Automatically clear copied values from your clipboard."; "DefaultUriMatchDetection" = "Default URI match detection"; -"DefaultUriMatchDetectionDescription" = "Choose the default way that URI match detection is handled for logins when performing actions such as autofill."; "Theme" = "Theme"; "ThemeDescription" = "Change the application’s color theme."; -"ThemeDefault" = "Default (System)"; -"DefaultDarkTheme" = "Default dark theme"; "CopyNotes" = "Copy note"; -"Exit" = "Exit"; -"ExitConfirmation" = "Are you sure you want to exit Bitwarden?"; "PINRequireMasterPasswordRestart" = "Do you want to require unlocking with your master password when the application is restarted?"; "PINRequireBioOrMasterPasswordRestart" = "Do you want to require unlocking with %1$@ or your master password when the application is restarted?"; "PINRequireUnknownBiometricsOrMasterPasswordRestart" = "Do you want to require unlocking with biometrics or your master password when the application is restarted?"; -"Black" = "Black"; -"Nord" = "Nord"; -"SolarizedDark" = "Solarized Dark"; -"AutofillBlockedUris" = "Autofill blocked URIs"; -"AskToAddLogin" = "Ask to add login"; -"AskToAddLoginDescription" = "Ask to add an item if one isn’t found in your vault."; "OnRestart" = "On app restart"; -"AutofillServiceNotEnabled" = "Autofill makes it easy to securely access your Bitwarden vault from other websites and apps. It looks like you have not set up an autofill service for Bitwarden. Set up autofill for Bitwarden from the “Settings” screen."; -"ThemeAppliedOnRestart" = "Your theme changes will apply when the app is restarted."; "Capitalize" = "Capitalize"; "IncludeNumber" = "Include number"; "Download" = "Download"; "Shared" = "Shared"; "ToggleVisibility" = "Toggle visibility"; -"LoginExpired" = "Your login session has expired."; -"BiometricsDirection" = "Biometric verification"; -"Biometrics" = "Biometrics"; "UseBiometricsToUnlock" = "Use biometrics to unlock"; -"AccessibilityOverlayPermissionAlert" = "Bitwarden needs attention - See “Autofill Accessibility Service” from Bitwarden settings"; -"BitwardenAutofillServiceOverlayPermission" = "3. On the Android App Settings screen for Bitwarden, go to the “Display over other apps” options (under Advanced) and tap the toggle to allow overlay support."; -"OverlayPermission" = "Permission"; -"BitwardenAutofillServiceOpenOverlayPermissionSettings" = "Open Overlay Permission Settings"; -"BitwardenAutofillServiceStep3" = "3. On the Android App Settings screen for Bitwarden, select “Display over other apps” (under “Advanced”) and switch on the toggle to allow the overlay."; -"Denied" = "Denied"; -"Granted" = "Granted"; "FileFormat" = "File format"; "ExportVaultMasterPasswordDescription" = "Enter your master password to export your vault data."; "SendVerificationCodeToEmail" = "Send a verification code to your email"; @@ -488,34 +351,23 @@ "ExportVaultConfirmationTitle" = "Confirm vault export"; "Warning" = "Warning"; "ExportVaultFailure" = "There was a problem exporting your vault. If the problem persists, you’ll need to export from the web vault."; -"ExportVaultSuccess" = "Vault exported successfully"; "Clone" = "Clone"; "PasswordGeneratorPolicyInEffect" = "One or more organization policies are affecting your generator settings"; -"Open" = "Open"; -"UnableToSaveAttachment" = "There was a problem saving this attachment. If the problem persists, you can save it from the web vault."; -"SaveAttachmentSuccess" = "Attachment saved successfully"; -"AutofillTileAccessibilityRequired" = "Please turn on “Autofill Accessibility Service” from Bitwarden Settings to use the Autofill tile."; -"AutofillTileUriNotFound" = "No password fields detected"; "SoftDeleting" = "Sending to trash…"; "ItemSoftDeleted" = "Item has been sent to trash."; "Restore" = "Restore"; "Restoring" = "Restoring…"; "ItemRestored" = "Item restored"; "Trash" = "Trash"; -"SearchTrash" = "Search trash"; "DoYouReallyWantToPermanentlyDeleteCipher" = "Do you really want to permanently delete? This cannot be undone."; "DoYouReallyWantToRestoreCipher" = "Do you really want to restore this item?"; "DoYouReallyWantToSoftDeleteCipher" = "Do you really want to send to the trash?"; -"AccountBiometricInvalidated" = "Biometric unlock for this account is disabled pending verification of master password."; -"AccountBiometricInvalidatedExtension" = "Autofill biometric unlock for this account is disabled pending verification of master password."; "EnableSyncOnRefresh" = "Allow sync on refresh"; "EnableSyncOnRefreshDescription" = "Syncing vault with pull down gesture."; "LogInSso" = "Enterprise single sign-on"; "LogInSsoSummary" = "Quickly log in using your organization’s single sign-on portal. Please enter your organization’s identifier to begin."; "OrgIdentifier" = "Organization identifier"; -"LoginSsoError" = "Currently unable to login with SSO"; "SetMasterPassword" = "Set master password"; -"SetMasterPasswordSummary" = "In order to complete logging in with SSO, please set a master password to access and protect your vault."; "MasterPasswordPolicyInEffect" = "One or more organization policies require your master password to meet the following requirements:"; "PolicyInEffectMinComplexity" = "Minimum complexity score of %1$@"; "PolicyInEffectMinLength" = "Minimum length of %1$@"; @@ -528,62 +380,30 @@ "Loading" = "Loading"; "AcceptPoliciesError" = "Terms of Service and Privacy Policy have not been acknowledged."; "PrivacyPolicy" = "Privacy Policy"; -"AccessibilityDrawOverPermissionAlert" = "Bitwarden needs attention - Turn on “Draw-Over” in “Autofill Services” from Bitwarden Settings"; -"PasskeyManagement" = "Passkey management"; -"AutofillServices" = "Autofill services"; -"InlineAutofill" = "Use inline autofill"; -"InlineAutofillDescription" = "Use inline autofill if your selected IME (keyboard) supports it. If your configuration is not supported (or this option is turned off), the default Autofill overlay will be used."; -"Accessibility" = "Use accessibility"; -"AccessibilityDescription" = "Use the Bitwarden Accessibility Service to autofill your logins across apps and the web. When set up, we’ll display a popup when login fields are selected."; -"AccessibilityDescription2" = "Use the Bitwarden Accessibility Service to autofill your logins across apps and the web. (Requires Draw-Over to be turned on as well)"; -"AccessibilityDescription3" = "Use the Bitwarden Accessibility Service to use the Autofill Quick-Action Tile, and/or show a popup using Draw-Over (if turned on)."; -"AccessibilityDescription4" = "Required to use the Autofill Quick-Action Tile, or to augment the Autofill Service by using Draw-Over (if turned on)."; -"DrawOver" = "Use draw-over"; -"DrawOverDescription" = "Allows the Bitwarden Accessibility Service to display a popup when login fields are selected."; -"DrawOverDescription2" = "If turned on, the Bitwarden Accessibility Service will display a popup when login fields are selected to assist with autofilling your logins."; -"DrawOverDescription3" = "If turned on, accessibility will show a popup to augment the Autofill Service for older apps that don’t support the Android Autofill Framework."; -"PersonalOwnershipSubmitError" = "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections."; "PersonalOwnershipPolicyInEffect" = "An organization policy is affecting your ownership options."; "Send" = "Send"; "AllSends" = "All Sends"; "Sends" = "Sends"; "Text" = "Text"; -"TypeText" = "Text"; "HideTextByDefault" = "When accessing the Send, hide the text by default"; -"TypeFile" = "File"; -"FileTypeIsSelected" = "File type is selected."; -"FileTypeIsNotSelected" = "File type is not selected, tap to select."; -"TextTypeIsSelected" = "Text type is selected."; -"TextTypeIsNotSelected" = "Text type is not selected, tap to select."; "DeletionDate" = "Deletion date"; -"DeletionTime" = "Deletion time"; "DeletionDateInfo" = "The Send will be permanently deleted on the specified date and time."; -"PendingDelete" = "Pending deletion"; -"ExpirationTime" = "Expiration time"; -"Expired" = "Expired"; "MaximumAccessCount" = "Maximum access count"; "MaximumAccessCountInfo" = "If set, users will no longer be able to access this Send once the maximum access count is reached."; -"MaximumAccessCountReached" = "Max access count reached"; "CurrentAccessCount" = "Current access count"; -"NewPassword" = "New password"; -"PasswordInfo" = "Require this password to view the Send."; "RemovePassword" = "Remove password"; "AreYouSureRemoveSendPassword" = "Are you sure you want to remove the password?"; "RemovingSendPassword" = "Removing password"; "SendPasswordRemoved" = "Password removed"; -"NoSends" = "There are no Sends in your account."; "CopyLink" = "Copy link"; "ShareLink" = "Share link"; "SendLink" = "Send link"; -"SearchSends" = "Search Sends"; -"EditSend" = "Edit Send"; "NewSend" = "New send"; "AreYouSureDeleteSend" = "Are you sure you want to delete this Send?"; "SendDeleted" = "Send deleted"; "SendUpdated" = "Send updated"; "NewSendCreated" = "Send created"; "Custom" = "Custom"; -"ShareOnSave" = "Share this Send upon save"; "SendDisabledWarning" = "Due to an enterprise policy, you are only able to delete an existing Send."; "AboutSend" = "About Send"; "HideEmail" = "Hide my email address from recipients"; @@ -593,24 +413,15 @@ "PasswordPrompt" = "Master password re-prompt"; "PasswordConfirmation" = "Master password confirmation"; "PasswordConfirmationDesc" = "This action is protected, to continue please re-enter your master password to verify your identity."; -"CaptchaRequired" = "Captcha required"; -"CaptchaFailed" = "Captcha failed. Please try again."; -"UpdatedMasterPassword" = "Updated master password"; "UpdateMasterPassword" = "Update master password"; "UpdateMasterPasswordWarning" = "Your master password was recently changed by an administrator in your organization. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."; "UpdatingPassword" = "Updating password"; -"UpdatePasswordError" = "Currently unable to update password"; "RemoveMasterPassword" = "Remove master password"; -"RemoveMasterPasswordWarning" = "%1$@ is using SSO with customer-managed encryption. Continuing will remove your master password from your account and require SSO to login."; -"RemoveMasterPasswordWarning2" = "If you do not want to remove your master password, you may leave this organization."; "LeaveOrganization" = "Leave organization"; "LeaveOrganizationName" = "Leave %1$@?"; "Fido2Title" = "FIDO2 WebAuthn"; -"Fido2Instruction" = "To continue, have your FIDO2 WebAuthn compatible security key ready, then follow the instructions after clicking “Authenticate WebAuthn” on the next screen."; -"Fido2Desc" = "Authentication using FIDO2 WebAuthn, you can authenticate using an external security key."; "Fido2AuthenticateWebAuthn" = "Authenticate WebAuthn"; "Fido2ReturnToApp" = "Return to app"; -"Fido2CheckBrowser" = "Please make sure your default browser supports WebAuthn and try again."; "ResetPasswordAutoEnrollInviteWarning" = "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password."; "VaultTimeoutToLarge" = "Your vault timeout exceeds the restrictions set by your organization."; "DisablePersonalVaultExportPolicyInEffect" = "One or more organization policies prevents your from exporting your individual vault."; @@ -628,31 +439,21 @@ "DeletingYourAccount" = "Deleting your account"; "YourAccountHasBeenPermanentlyDeleted" = "Your account has been permanently deleted"; "InvalidVerificationCode" = "Invalid verification code"; -"RequestOTP" = "Request one-time password"; "SendCode" = "Send code"; -"Sending" = "Sending"; -"CopySendLinkOnSave" = "Copy Send link on save"; "SendingCode" = "Sending code"; "Verifying" = "Verifying"; "ResendCode" = "Resend code"; -"AVerificationCodeWasSentToYourEmail" = "A verification code was sent to your email"; -"AnErrorOccurredWhileSendingAVerificationCodeToYourEmailPleaseTryAgain" = "An error occurred while sending a verification code to your email. Please try again"; "EnterTheVerificationCodeThatWasSentToYourEmail" = "Enter the verification code that was sent to your email"; "SubmitCrashLogs" = "Submit crash logs"; -"SubmitCrashLogsDescription" = "Help Bitwarden improve app stability by submitting crash reports."; -"OptionsExpanded" = "Options are expanded, tap to collapse."; -"OptionsCollapsed" = "Options are collapsed, tap to expand."; "UppercaseAtoZ" = "Uppercase (A to Z)"; "LowercaseAtoZ" = "Lowercase (A to Z)"; "NumbersZeroToNine" = "Numbers (0 to 9)"; "SpecialCharacters" = "Special characters (!@#$%^&*)"; -"TapToGoBack" = "Tap to go back"; "PasswordIsVisibleTapToHide" = "Password is visible, tap to hide."; "PasswordIsNotVisibleTapToShow" = "Password is not visible, tap to show."; "FilterByVault" = "Filter items by vault"; "AllVaults" = "All vaults"; "Vaults" = "Vaults"; -"VaultFilterDescription" = "Vault: %1$@"; "All" = "All"; "Totp" = "TOTP"; "VerificationCodes" = "Verification codes"; @@ -661,34 +462,21 @@ "ScanQRCode" = "Scan QR Code"; "CannotScanQRCode" = "Cannot scan QR Code?"; "EnterKeyManually" = "Enter key manually"; -"AddTotp" = "Add TOTP"; "OnceTheKeyIsSuccessfullyEntered" = "Once the key is successfully entered, select Save to store the key safely."; "NeverLockWarning" = "Setting your lock options to “Never” keeps your vault available to anyone with access to your device. If you use this option, you should ensure that you keep your device properly protected."; "EnvironmentPageUrlsError" = "One or more of the URLs entered are invalid. Please revise it and try to save again."; "GenericErrorMessage" = "We were unable to process your request. Please try again or contact us."; -"AllowScreenCapture" = "Allow screen capture"; -"AreYouSureYouWantToEnableScreenCapture" = "Are you sure you want to turn on screen capture?"; "LogInRequested" = "Login requested"; "AreYouTryingToLogIn" = "Are you trying to log in?"; "LogInAttemptByXOnY" = "Login attempt by %1$@ on %2$@"; "DeviceType" = "Device type"; "IpAddress" = "IP address"; "Time" = "Time"; -"Near" = "Near"; "ConfirmLogIn" = "Confirm login"; "DenyLogIn" = "Deny login"; -"JustNow" = "Just now"; -"LogInAccepted" = "Login confirmed"; "LogInDenied" = "Login denied"; "ApproveLoginRequests" = "Approve login requests"; -"UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices" = "Use this device to approve login requests made from other devices"; -"AllowNotifications" = "Allow notifications"; -"ReceivePushNotificationsForNewLoginRequests" = "Receive push notifications for new login requests"; -"NoThanks" = "No thanks"; "ConfimLogInAttempForX" = "Confirm login attempt for %1$@"; -"AllNotifications" = "All notifications"; -"PasswordType" = "Password type"; -"WhatWouldYouLikeToGenerate" = "What would you like to generate?"; "UsernameType" = "Username type"; "PlusAddressedEmail" = "Plus addressed email"; "CatchAllEmail" = "Catch-all email"; @@ -708,25 +496,18 @@ "AreYouSureYouWantToOverwriteTheCurrentUsername" = "Are you sure you want to overwrite the current username?"; "GenerateUsername" = "Generate username"; "EmailType" = "Email Type"; -"WebsiteRequired" = "Website (required)"; -"UnknownXErrorMessage" = "Unknown %1$@ error occurred."; "PlusAddressedEmailDescription" = "Use your email provider’s subaddress capabilities"; "CatchAllEmailDescription" = "Use your domain’s configured catch-all inbox."; "ForwardedEmailDescription" = "Generate an email alias with an external forwarding service."; "Random" = "Random"; "ConnectToWatch" = "Connect to Watch"; -"AccessibilityServiceDisclosure" = "Accessibility Service Disclosure"; -"AccessibilityDisclosureText" = "Bitwarden uses the Accessibility Service to search for login fields in apps and websites, then establish the appropriate field IDs for entering a username & password when a match for the app or site is found. We do not store any of the information presented to us by the service, nor do we make any attempt to control any on-screen elements beyond text entry of credentials."; "Accept" = "Accept"; -"Decline" = "Decline"; "LoginRequestHasAlreadyExpired" = "Login request has already expired."; "LoginAttemptFromXDoYouWantToSwitchToThisAccount" = "Login attempt from:\n%1$@\nDo you want to switch to this account?"; "NewAroundHere" = "New around here?"; "GetMasterPasswordwordHint" = "Get master password hint"; -"LoggingInAsXOnY" = "Logging in as %1$@ on %2$@"; "NotYou" = "Not you?"; "LogInWithMasterPassword" = "Log in with master password"; -"LogInWithAnotherDevice" = "Log in with device"; "LogInInitiated" = "Login initiated"; "ANotificationHasBeenSentToYourDevice" = "A notification has been sent to your device."; "PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice" = "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device."; @@ -739,7 +520,6 @@ "AreYouSureYouWantToDeclineAllPendingLogInRequests" = "Are you sure you want to decline all pending login requests?"; "RequestsDeclined" = "Requests declined"; "NoPendingRequests" = "No pending requests"; -"EnableCamerPermissionToUseTheScanner" = "Enable camera permission to use the scanner"; "Language" = "Language"; "LanguageChangeXDescription" = "The language has been changed to %1$@. Please restart the app to see the change"; "LanguageChangeRequiresAppRestart" = "Language change requires app restart"; @@ -755,7 +535,6 @@ "PasswordFoundInADataBreachAlertDescription" = "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?"; "WeakAndExposedMasterPassword" = "Weak and Exposed Master Password"; "WeakPasswordIdentifiedAndFoundInADataBreachAlertDescription" = "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?"; -"OrganizationSsoIdentifierRequired" = "Organization SSO identifier required."; "AddTheKeyToAnExistingOrNewItem" = "Add the key to an existing or new item"; "ThereAreNoItemsInYourVaultThatMatchX" = "There are no items in your vault that match “%1$@”"; "SearchForAnItemOrAddANewItem" = "Search for an item or add a new item"; @@ -768,12 +547,9 @@ "US" = "US"; "EU" = "EU"; "SelfHosted" = "Self-hosted"; -"DataRegion" = "Data region"; -"Region" = "Region"; "UpdateWeakMasterPasswordWarning" = "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."; "CurrentMasterPasswordRequired" = "Current master password (required)"; "NewMasterPasswordRequired" = "New master password (required)"; -"LoggedIn" = "Logged in!"; "ApproveWithMyOtherDevice" = "Approve with my other device"; "RequestAdminApproval" = "Request admin approval"; "ApproveWithMasterPassword" = "Approve with master password"; @@ -781,114 +557,49 @@ "RememberThisDevice" = "Remember this device"; "Passkey" = "Passkey"; "Passkeys" = "Passkeys"; -"Application" = "Application"; -"YouCannotEditPasskeyApplicationBecauseItWouldInvalidateThePasskey" = "You cannot edit passkey application because it would invalidate the passkey"; "PasskeyWillNotBeCopied" = "Passkey will not be copied"; "ThePasskeyWillNotBeCopiedToTheClonedItemDoYouWantToContinueCloningThisItem" = "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"; -"CopyApplication" = "Copy application"; -"AvailableForTwoStepLogin" = "Available for two-step login"; "MasterPasswordRePromptHelp" = "Master password re-prompt help"; "UnlockingMayFailDueToInsufficientMemoryDecreaseYourKDFMemorySettingsToResolve" = "Unlocking may fail due to insufficient memory. Decrease your KDF memory settings or set up biometric unlock to resolve."; -"InvalidAPIKey" = "Invalid API key"; -"InvalidAPIToken" = "Invalid API token"; "AdminApprovalRequested" = "Admin approval requested"; "YourRequestHasBeenSentToYourAdmin" = "Your request has been sent to your admin."; -"YouWillBeNotifiedOnceApproved" = "You will be notified once approved."; -"TroubleLoggingIn" = "Trouble logging in?"; "LoggingInAsX" = "Logging in as %1$@"; -"VaultTimeoutActionChangedToLogOut" = "Vault timeout action changed to log out"; -"BlockAutoFill" = "Block autofill"; -"AutoFillWillNotBeOfferedForTheseURIs" = "Autofill will not be offered for these URIs."; -"NewBlockedURI" = "New blocked URI"; -"URISaved" = "URI saved"; -"InvalidFormatUseHttpsHttpOrAndroidApp" = "Invalid format. Use https://, http://, or androidapp://"; -"EditURI" = "Edit URI"; -"EnterURI" = "Enter URI"; -"FormatXSeparateMultipleURIsWithAComma" = "Format: %1$@. Separate multiple URIs with a comma."; -"FormatX" = "Format: %1$@"; -"InvalidURI" = "Invalid URI"; -"URIRemoved" = "URI removed"; -"ThereAreNoBlockedURIs" = "There are no blocked URIs"; -"TheURIXIsAlreadyBlocked" = "The URI %1$@ is already blocked"; -"CannotEditMultipleURIsAtOnce" = "Cannot edit multiple URIs at once"; "LoginApproved" = "Login approved"; -"LogInWithDeviceMustBeSetUpInTheSettingsOfTheBitwardenAppNeedAnotherOption" = "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"; "LogInWithDevice" = "Log in with device"; "LoggingInOn" = "Logging in on"; "Vault" = "Vault"; "Appearance" = "Appearance"; "AccountSecurity" = "Account security"; "BitwardenHelpCenter" = "Bitwarden help center"; -"ContactBitwardenSupport" = "Contact Bitwarden support"; -"CopyAppInformation" = "Copy app information"; "SyncNow" = "Sync now"; "UnlockOptions" = "Unlock options"; "SessionTimeout" = "Session timeout"; "SessionTimeoutAction" = "Session timeout action"; "AccountFingerprintPhrase" = "Account fingerprint phrase"; -"PasskeyManagementExplanationLong" = "Use Bitwarden to save new passkeys and log in with passkeys stored in your vault."; -"AutofillServicesExplanationLong" = "The Android Autofill Framework is used to assist in filling login information into other apps on your device."; -"UseInlineAutofillExplanationLong" = "Use inline autofill if your selected keyboard supports it. Otherwise, use the default overlay."; "AdditionalOptions" = "Additional options"; "ContinueToWebApp" = "Continue to web app?"; -"ContinueToX" = "Continue to %1$@?"; -"ContinueToHelpCenter" = "Continue to Help center?"; -"ContinueToContactSupport" = "Continue to contact support?"; "ContinueToPrivacyPolicy" = "Continue to privacy policy?"; "ContinueToAppStore" = "Continue to app store?"; -"ContinueToDeviceSettings" = "Continue to device Settings?"; "ContinueWithoutSyncing" = "Continue without syncing"; "TwoStepLoginDescriptionLong" = "Make your account more secure by setting up two-step login in the Bitwarden web app."; -"ChangeMasterPasswordDescriptionLong" = "You can change your master password on the Bitwarden web app."; "YouCanImportDataToYourVaultOnX" = "You can import data to your vault on %1$@."; -"LearnMoreAboutHowToUseBitwardenOnTheHelpCenter" = "Learn more about how to use Bitwarden on the Help center."; -"ContactSupportDescriptionLong" = "Can’t find what you are looking for? Reach out to Bitwarden support on bitwarden.com."; "PrivacyPolicyDescriptionLong" = "Check out our privacy policy on bitwarden.com."; "ExploreMoreFeaturesOfYourBitwardenAccountOnTheWebApp" = "Explore more features of your Bitwarden account on the web app."; "LearnAboutOrganizationsDescriptionLong" = "Bitwarden allows you to share your vault items with others by using an organization. Learn more on the bitwarden.com website."; "RateAppDescriptionLong" = "Help others find out if Bitwarden is right for them. Visit the app store and leave a rating now."; -"DefaultDarkThemeDescriptionLong" = "Choose the dark theme to use when your device’s dark mode is in use"; "CreatedX" = "Created %1$@"; -"TooManyAttempts" = "Too many attempts"; -"AccountLoggedOutBiometricExceeded" = "Account logged out."; "YourOrganizationPermissionsWereUpdatedRequeringYouToSetAMasterPassword" = "Your organization permissions were updated, requiring you to set a master password."; "YourOrganizationRequiresYouToSetAMasterPassword" = "Your organization requires you to set a master password."; -"SetUpAnUnlockOptionToChangeYourVaultTimeoutAction" = "Set up an unlock option to change your vault timeout action."; "ChooseALoginToSaveThisPasskeyTo" = "Choose a login to save this passkey to"; "SavePasskeyAsNewLogin" = "Save passkey as new login"; -"SavePasskey" = "Save passkey"; "PasskeysForX" = "Passkeys for %1$@"; "PasswordsForX" = "Passwords for %1$@"; -"OverwritePasskey" = "Overwrite passkey?"; "ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey" = "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"; -"DuoTwoStepLoginIsRequiredForYourAccount" = "Duo two-step login is required for your account."; "FollowTheStepsFromDuoToFinishLoggingIn" = "Follow the steps from Duo to finish logging in."; "LaunchDuo" = "Launch Duo"; -"VerificationRequiredByX" = "Verification required by %1$@"; -"VerificationRequiredForThisActionSetUpAnUnlockMethodInBitwardenToContinue" = "Verification required for this action. Set up an unlock method in Bitwarden to continue."; -"ErrorCreatingPasskey" = "Error creating passkey"; -"ErrorReadingPasskey" = "Error reading passkey"; -"ThereWasAProblemCreatingAPasskeyForXTryAgainLater" = "There was a problem creating a passkey for %1$@. Try again later."; -"ThereWasAProblemReadingAPasskeyForXTryAgainLater" = "There was a problem reading your passkey for %1$@. Try again later."; -"VerifyingIdentityEllipsis" = "Verifying identity…"; "Passwords" = "Passwords"; "UnknownAccount" = "Unknown account"; "SetUpAutofill" = "Set up autofill"; -"GetInstantAccessToYourPasswordsAndPasskeys" = "Get instant access to your passwords and passkeys!"; -"SetUpAutoFillDescriptionLong" = "To set up password autofill and passkey management, set Bitwarden as your preferred provider in the iOS Settings."; -"FirstDotGoToYourDeviceSettingsPasswordsPasswordOptions" = "1. Go to your device’s Settings > Passwords > Password Options"; -"SecondDotTurnOnAutoFill" = "2. Turn on AutoFill"; -"ThirdDotSelectBitwardenToUseForPasswordsAndPasskeys" = "3. Select “Bitwarden” to use for passwords and passkeys"; -"YourPasskeyWillBeSavedToYourBitwardenVault" = "Your passkey will be saved to your Bitwarden vault"; -"YourPasskeyWillBeSavedToYourBitwardenVaultForX" = "Your passkey will be saved to your Bitwarden vault for %1$@"; -"PasskeysNotSupportedForThisApp" = "Passkeys not supported for this app"; -"PasskeyOperationFailedBecauseBrowserIsNotPrivileged" = "Passkey operation failed because browser is not privileged"; -"PasskeyOperationFailedBecauseBrowserSignatureDoesNotMatch" = "Passkey operation failed because browser signature does not match"; -"PasskeyOperationFailedBecauseOfMissingAssetLinks" = "Passkey operation failed because of missing asset links"; -"PasskeyOperationFailedBecauseAppNotFoundInAssetLinks" = "Passkey operation failed because app not found in asset links"; -"PasskeyOperationFailedBecauseAppCouldNotBeVerified" = "Passkey operation failed because app could not be verified"; -"RemindMeLater" = "Remind me later"; -"Notice" = "Notice"; "AppInfo" = "App info"; "Browse" = "Browse"; "SelectLanguage" = "Select language"; @@ -903,15 +614,11 @@ "CheckYourEmail" = "Check your email"; "WeSentAnEmailTo" = "We sent an email to %1$@"; "SelectTheLinkInTheEmailToVerifyYourEmailAddressAndContinueCreatingYourAccount" = "Select the link in the email to verify your email address and continue creating your account."; -"NoEmailGoBackToEditYourEmailAddress" = "No email? **[Go back](https://)** to edit your email address."; -"OrLogInYouMayAlreadyHaveAnAccount." = "Or **[log in](https://)**, you may already have an account."; "OpenEmailApp" = "Open email app"; -"VerifyingEmail" = "Verifying email"; "EmailVerified" = "Email verified"; "AccountSuccessfullyCreated" = "Account successfully created!"; "ByContinuingYouAgreeToTheTermsOfServiceAndPrivacyPolicy" = "By continuing, you agree to the **[Terms of Service](%1$@)** and **[Privacy Policy](%2$@)**"; "GetAdviceAnnouncementsAndResearchOpportunitiesFromBitwardenInYourInboxUnsubscribeAtAnyTime." = "Get advice, announcements, and research opportunities from Bitwarden in your inbox. **[Unsubscribe](%1$@)** at any time."; -"OrganizationUnassignedItemsMessageUSEUDescriptionLong" = "Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."; "UserVerificationForPasskey" = "User verification for passkey"; "NewItem" = "New item"; "ExploreTheGenerator" = "Explore the generator"; @@ -959,7 +666,6 @@ "RetypeNewMasterPasswordRequired" = "Re-type new master password (required)"; "BitwardenCannotResetALostOrForgottenMasterPassword" = "Bitwarden cannot reset a lost or forgotten master password."; "LearnAboutWaysToPreventAccountLockout" = "Learn about ways to prevent account lockout"; -"RemoveMasterPasswordMessage" = "%1$@ is using SSO with a self-hosted key server. A master password is no longer required to log in for members of this organization."; "RestartRegistration" = "Restart registration"; "ExpiredLink" = "Expired link"; "PleaseRestartRegistrationOrTryLoggingInYouMayAlreadyHaveAnAccount" = "Please restart registration or try logging in. You may already have an account."; @@ -1113,12 +819,6 @@ "Identification" = "Identification"; "Owner" = "Owner"; "VerifyYourIdentity" = "Verify your identity"; -"LockCurrentAccount" = "Lock current account"; -"ThisOperationIsNotAllowedOnThisAccount" = "This operation is not allowed on this account"; -"AnErrorOccurredWhileTryingToLockTheCurrentUser" = "An error occurred while trying to lock the current user"; -"LockAllAccounts" = "Lock all accounts"; -"AllAccountsHaveBeenLocked" = "All accounts have been locked"; -"AnErrorOccurredWhileTryingToLockAllAccounts" = "An error occurred while trying to lock all accounts"; "ShareErrorDetails" = "Share error details"; "FlightRecorder" = "Flight recorder"; "EnableFlightRecorder" = "Enable flight recorder"; @@ -1142,13 +842,7 @@ "ExpiresOnXDate" = "Expires on %1$@"; "ExpiresTomorrow" = "Expires tomorrow"; "DateRangeXToY" = "%1$@ to %2$@"; -"LogOutAllAccounts" = "Log out all accounts"; -"AllAccountsHaveBeenLoggedOut" = "All accounts have been logged out"; -"AnErrorOccurredWhileTryingToLogOutAllAccounts" = "An error occurred while trying to log out all accounts"; "SelfHostServerURL" = "Self-host server URL"; -"OpenGenerator" = "Open generator"; -"OpenPasswordGenerator" = "Open password generator"; -"GeneratePassphrase" = "Generate passphrase"; "AllowUniversalClipboard" = "Allow Universal Clipboard"; "UseUniversalClipboardToCopyDescriptionLong" = "Use Universal Clipboard to copy here and paste on other devices signed in with the same Apple ID."; "RemoveMasterPasswordConfirmDomain" = "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator."; @@ -1168,7 +862,6 @@ "GoToSettings" = "Go to settings"; "SiriAndShortcutsAccess" = "Siri & Shortcuts access"; "EnableToAllowTheAppToRespondToSiriAndShortcutsUsingAppIntents" = "Enable to allow the app to respond to Siri and Shortcuts using App Intents."; -"ThereIsNoActiveAccount" = "There is no active account."; "NewFileSend" = "New file Send"; "NewTextSend" = "New text Send"; "EditFileSend" = "Edit file Send"; @@ -1185,7 +878,6 @@ "BitwardenCouldNotDecryptThisVaultItemDescriptionLong" = "Bitwarden could not decrypt this vault item. Copy and share this error report with customer success to avoid additional data loss."; "CopyErrorReport" = "Copy error report"; "ErrorCannotDecrypt" = "[error: cannot decrypt]"; -"AccountName" = "Account name"; "AccountsSyncedFromBitwardenApp" = "Accounts synced from Bitwarden app"; "AddANewCodeToSecure" = "Add a new code to secure your accounts."; "AddCode" = "Add code"; diff --git a/Scripts/fix-localizable-strings.sh b/Scripts/fix-localizable-strings.sh index a2da1d74b5..e15582e53a 100755 --- a/Scripts/fix-localizable-strings.sh +++ b/Scripts/fix-localizable-strings.sh @@ -13,9 +13,36 @@ STRINGS_FILES=( "TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings" ) +# The main Localizable.strings file, used with delete-unused. +MAIN_STRINGS="BitwardenResources/Localizations/en.lproj/Localizable.strings" + +# Swift source directories that reference the SwiftGen-generated Localizations enum. +SWIFT_SOURCE_DIRS=( + "AuthenticatorShared" + "Bitwarden" + "BitwardenKit" + "BitwardenResources" + "BitwardenShared" + "TestHarnessShared" +) + +# Build the --swift-source arguments once for use inside the loop. +swift_source_args=() +for dir in "${SWIFT_SOURCE_DIRS[@]}"; do + swift_source_args+=(--swift-source "${REPO_ROOT}/${dir}") +done + # Run each fix-localizable-strings command against every strings file. # Any extra arguments passed to this script (e.g. --dry-run) are forwarded as-is. for strings_file in "${STRINGS_FILES[@]}"; do echo "${strings_file}" python3 "${PYTHON}" delete-duplicates --strings "${REPO_ROOT}/${strings_file}" "$@" + # delete-unused only applies to the main Localizable.strings because it works + # by scanning for Localizations.X references, which maps exclusively to the + # SwiftGen-generated Localizations enum produced from that file. The other + # strings files (AppShortcuts, Watch, TestHarness) use different access + # mechanisms and are not covered by this detection strategy. + if [[ "${strings_file}" == "${MAIN_STRINGS}" ]]; then + python3 "${PYTHON}" delete-unused --strings "${REPO_ROOT}/${strings_file}" "${swift_source_args[@]}" "$@" + fi done diff --git a/Scripts/fix-localizable-strings/delete_duplicate_strings.py b/Scripts/fix-localizable-strings/delete_duplicate_strings.py index 520fe5c9b2..f9f0c8b14d 100644 --- a/Scripts/fix-localizable-strings/delete_duplicate_strings.py +++ b/Scripts/fix-localizable-strings/delete_duplicate_strings.py @@ -7,12 +7,7 @@ removed. """ -import re - -# Matches a complete key/value entry line. -_ENTRY_RE = re.compile( - r'^\s*"(?P(?:[^"\\]|\\.)*)"\s*=\s*"(?:[^"\\]|\\.)*"\s*;\s*$' -) +from strings_file_utils import filter_entries def deduplicate(content: str) -> tuple[str, list[str]]: @@ -31,68 +26,15 @@ def deduplicate(content: str) -> tuple[str, list[str]]: deduplicated file text and ``removed_keys`` is a list of keys that were removed, in the order they were encountered. """ - lines = content.splitlines(keepends=True) - output: list[str] = [] - # Lines buffered since the last blank line; these are candidate comments - # for the next entry. Flushed to output on a blank line or non-entry line. - pending: list[str] = [] seen: set[str] = set() - removed: list[str] = [] - in_block_comment = False - - for line in lines: - stripped = line.strip() - - # --- Multi-line block comment continuation --- - if in_block_comment: - pending.append(line) - if "*/" in line: - in_block_comment = False - continue - - # --- Blank line: break comment-entry association --- - if not stripped: - output.extend(pending) - pending = [] - output.append(line) - continue - - # --- Block comment start (does not end on the same line) --- - if stripped.startswith("/*") and "*/" not in stripped: - in_block_comment = True - pending.append(line) - continue - - # --- Single-line comment (// or /* ... */ on one line) --- - if stripped.startswith("//") or ( - stripped.startswith("/*") and stripped.endswith("*/") - ): - pending.append(line) - continue - - # --- Key/value entry --- - m = _ENTRY_RE.match(line) - if m: - key = m.group("key") - if key not in seen: - seen.add(key) - output.extend(pending) - output.append(line) - else: - removed.append(key) - # pending (the preceding comment) is discarded - pending = [] - continue - - # --- Anything else (should be rare in a well-formed .strings file) --- - output.extend(pending) - pending = [] - output.append(line) - - # Flush any trailing pending content (e.g. trailing comment with no entry after it) - output.extend(pending) - - return "".join(output), removed + + def should_keep(key: str) -> bool: + if key in seen: + return False + seen.add(key) + return True + + return filter_entries(content, should_keep) def delete_duplicates(strings_path: str) -> list[str]: diff --git a/Scripts/fix-localizable-strings/delete_unused_strings.py b/Scripts/fix-localizable-strings/delete_unused_strings.py new file mode 100644 index 0000000000..c2ad47483f --- /dev/null +++ b/Scripts/fix-localizable-strings/delete_unused_strings.py @@ -0,0 +1,129 @@ +""" +delete_unused_strings + +Finds and removes string entries from a Localizable.strings file whose keys are +never referenced in Swift source code. Keys are assumed to be accessed via +``Localizations.X``, where ``X`` is the SwiftGen-generated identifier for the +key (first character lowercased). Any comment block immediately preceding a +removed entry (with no blank lines between them) is also removed. +""" + +import os +import re + +from strings_file_utils import filter_entries + +# Matches any `Localizations.identifier` reference in Swift source, including +# cases where the identifier is on the next line (e.g. `Localizations\n .foo`). +_LOCALIZATIONS_RE = re.compile(r'Localizations\s*\.([a-zA-Z_][a-zA-Z0-9_]*)') + +# Matches any character that is not valid in a Swift identifier. +_NON_IDENTIFIER_RE = re.compile(r'[^a-zA-Z0-9_]') + + +def _normalize_key(key: str) -> str: + """Normalize a ``.strings`` key for comparison against a SwiftGen identifier. + + SwiftGen strips characters that are not valid in Swift identifiers when + generating property names (e.g. ``"NeedSomeInspiration?"`` becomes + ``needSomeInspiration``). This function applies the same stripping and then + lowercases the result, matching the treatment applied to identifiers found + in Swift source via ``find_used_keys``. + + Args: + key: A raw ``.strings`` key, possibly containing trailing punctuation. + + Returns: + The normalized, lowercased key suitable for comparison. + """ + return _NON_IDENTIFIER_RE.sub('', key).lower() + + +def find_used_keys(swift_sources: list[str]) -> set[str]: + """Scan Swift file contents for ``Localizations.X`` references. + + Returns a set of identifiers found in the sources, converted to lowercase + for comparison with the keys from the strings file. While this does mean + that keys differing only in case (e.g. ``"OK"`` vs. ``"Ok"``) will be + treated as the same key, in practice we're not likely to have keys that + only differ by case. + + The internal helper ``Localizations.tr(...)`` is excluded. + + Args: + swift_sources: A list of strings, each being the full text of a Swift + source file. + + Returns: + A set of lowercased identifiers referenced in the given sources, + e.g. ``{"about", "ok", "valuehasbeencopied"}``. + """ + result: set[str] = set() + for content in swift_sources: + for identifier in _LOCALIZATIONS_RE.findall(content): + if identifier == "tr": + continue + result.add(identifier.lower()) + return result + + +def delete_unused_content( + strings_content: str, used_keys: set[str] +) -> tuple[str, list[str]]: + """Remove unused key entries from Localizable.strings content. + + Processes content line by line. Any key not present in ``used_keys`` is + removed. Any comment block (``/* */`` or ``//``) immediately preceding a + removed entry — with no intervening blank lines — is also removed. + + Args: + strings_content: The full text of the ``.strings`` file. + used_keys: The set of lowercased identifiers (as returned by + ``find_used_keys``) that are considered in-use. Each key from the + strings file is lowercased before lookup to match. + + Returns: + A tuple of ``(new_content, removed_keys)`` where ``new_content`` is the + filtered file text and ``removed_keys`` is a list of keys that were + removed, in file order. + """ + return filter_entries(strings_content, lambda key: _normalize_key(key) in used_keys) + + +def delete_unused(strings_path: str, swift_dirs: list[str]) -> list[str]: + """Remove unused entries from a Localizable.strings file in place. + + Walks each directory in ``swift_dirs`` recursively for ``.swift`` files, + reads them, determines which keys are referenced, then removes any + unreferenced keys from the strings file. + + Args: + strings_path: Path to the ``.strings`` file to process. + swift_dirs: List of directory paths to search recursively for Swift + source files. + + Returns: + A list of keys that were removed, in file order. Returns an empty list + if no unused keys were found. + """ + swift_sources: list[str] = [] + for swift_dir in swift_dirs: + for dirpath, _, filenames in os.walk(swift_dir): + for filename in filenames: + if filename.endswith(".swift"): + filepath = os.path.join(dirpath, filename) + with open(filepath, encoding="utf-8") as f: + swift_sources.append(f.read()) + + used_keys = find_used_keys(swift_sources) + + with open(strings_path, encoding="utf-8") as f: + content = f.read() + + new_content, removed = delete_unused_content(content, used_keys) + + if removed: + with open(strings_path, "w", encoding="utf-8") as f: + f.write(new_content) + + return removed diff --git a/Scripts/fix-localizable-strings/main.py b/Scripts/fix-localizable-strings/main.py index 7184442c2f..621e27f972 100644 --- a/Scripts/fix-localizable-strings/main.py +++ b/Scripts/fix-localizable-strings/main.py @@ -8,12 +8,18 @@ python Scripts/fix-localizable-strings/main.py delete-duplicates \\ --strings \\ [--dry-run] + + python Scripts/fix-localizable-strings/main.py delete-unused \\ + --strings \\ + --swift-source [--swift-source ...] \\ + [--dry-run] """ import argparse import sys from delete_duplicate_strings import delete_duplicates, deduplicate +from delete_unused_strings import delete_unused, delete_unused_content, find_used_keys def _pluralize(count: int, singular: str, plural: str) -> str: @@ -45,6 +51,40 @@ def cmd_delete_duplicates(args: argparse.Namespace) -> None: print(f" {key}") +def cmd_delete_unused(args: argparse.Namespace) -> None: + if args.dry_run: + with open(args.strings, encoding="utf-8") as f: + content = f.read() + import os + swift_sources = [] + for swift_dir in args.swift_sources: + for dirpath, _, filenames in os.walk(swift_dir): + for filename in filenames: + if filename.endswith(".swift"): + with open(os.path.join(dirpath, filename), encoding="utf-8") as f: + swift_sources.append(f.read()) + used_keys = find_used_keys(swift_sources) + _, removed = delete_unused_content(content, used_keys) + if not removed: + print(" No unused strings found.") + return + noun = _pluralize(len(removed), "key", "keys") + print(f" Found {len(removed)} unused {noun}:") + for key in removed: + print(f" {key}") + print("\n Dry run — no changes written.") + return + + removed = delete_unused(args.strings, args.swift_sources) + if not removed: + print(" No unused strings found.") + return + noun = _pluralize(len(removed), "key", "keys") + print(f" Removed {len(removed)} unused {noun}:") + for key in removed: + print(f" {key}") + + def build_parser(): parser = argparse.ArgumentParser( description="Tools for maintaining Localizable.strings files." @@ -67,6 +107,30 @@ def build_parser(): help="Report duplicates without modifying the strings file.", ) + unused_parser = subparsers.add_parser( + "delete-unused", + help="Remove string keys that are never referenced in Swift source code.", + ) + unused_parser.add_argument( + "--strings", + required=True, + metavar="PATH", + help="Path to the Localizable.strings file to process.", + ) + unused_parser.add_argument( + "--swift-source", + required=True, + action="append", + dest="swift_sources", + metavar="DIR", + help="Directory to search recursively for Swift source files. May be repeated.", + ) + unused_parser.add_argument( + "--dry-run", + action="store_true", + help="Report unused keys without modifying the strings file.", + ) + return parser @@ -76,6 +140,8 @@ def main(): if args.command == "delete-duplicates": cmd_delete_duplicates(args) + elif args.command == "delete-unused": + cmd_delete_unused(args) else: parser.print_help() sys.exit(1) diff --git a/Scripts/fix-localizable-strings/strings_file_utils.py b/Scripts/fix-localizable-strings/strings_file_utils.py new file mode 100644 index 0000000000..c940fa0bab --- /dev/null +++ b/Scripts/fix-localizable-strings/strings_file_utils.py @@ -0,0 +1,101 @@ +""" +strings_file_utils + +Shared utilities for parsing and filtering Localizable.strings file content. +""" + +import re +from collections.abc import Callable + +# Matches a complete key/value entry line. +_ENTRY_RE = re.compile( + r'^\s*"(?P(?:[^"\\]|\\.)*)"\s*=\s*"(?:[^"\\]|\\.)*"\s*;\s*$' +) + + +def filter_entries( + content: str, should_keep: Callable[[str], bool] +) -> tuple[str, list[str]]: + """Filter key/value entries in Localizable.strings content by a predicate. + + Processes content line by line using a comment state machine. For each + matched entry, calls ``should_keep(key)``. If the predicate returns + ``True``, the entry and any immediately preceding comment block are + written to output. If ``False``, the entry and its preceding comment block + are discarded and the key is appended to the removed list. + + A blank line breaks the association between a comment and the following + entry, so comments separated from an entry by a blank line are always + preserved regardless of whether the entry is kept. + + Args: + content: The full text of the ``.strings`` file. + should_keep: A callable that receives a raw key string and returns + ``True`` if the entry should be kept, ``False`` if it should be + removed. + + Returns: + A tuple of ``(new_content, removed_keys)`` where ``new_content`` is + the filtered file text and ``removed_keys`` is a list of keys that + were removed, in the order they were encountered. + """ + lines = content.splitlines(keepends=True) + output: list[str] = [] + # Lines buffered since the last blank line; these are candidate comments + # for the next entry. Flushed to output on a blank line or non-entry line. + pending: list[str] = [] + removed: list[str] = [] + in_block_comment = False + + for line in lines: + stripped = line.strip() + + # --- Multi-line block comment continuation --- + if in_block_comment: + pending.append(line) + if "*/" in line: + in_block_comment = False + continue + + # --- Blank line: break comment-entry association --- + if not stripped: + output.extend(pending) + pending = [] + output.append(line) + continue + + # --- Block comment start (does not end on the same line) --- + if stripped.startswith("/*") and "*/" not in stripped: + in_block_comment = True + pending.append(line) + continue + + # --- Single-line comment (// or /* ... */ on one line) --- + if stripped.startswith("//") or ( + stripped.startswith("/*") and stripped.endswith("*/") + ): + pending.append(line) + continue + + # --- Key/value entry --- + m = _ENTRY_RE.match(line) + if m: + key = m.group("key") + if should_keep(key): + output.extend(pending) + output.append(line) + else: + removed.append(key) + # pending (the preceding comment) is discarded + pending = [] + continue + + # --- Anything else (should be rare in a well-formed .strings file) --- + output.extend(pending) + pending = [] + output.append(line) + + # Flush any trailing pending content (e.g. trailing comment with no entry after it) + output.extend(pending) + + return "".join(output), removed diff --git a/Scripts/fix-localizable-strings/tests/test_delete_duplicate_strings.py b/Scripts/fix-localizable-strings/tests/test_delete_duplicate_strings.py index 37b61f12d7..d7baae800a 100644 --- a/Scripts/fix-localizable-strings/tests/test_delete_duplicate_strings.py +++ b/Scripts/fix-localizable-strings/tests/test_delete_duplicate_strings.py @@ -48,41 +48,6 @@ def test_file_with_only_blank_lines_is_unchanged(self): self.assertEqual(removed, []) -class TestDeduplicateEntryValueContainingCommentSyntax(unittest.TestCase): - """Entries whose values contain comment-like syntax must not be misclassified - as comments, which would suppress deduplication of subsequent entries.""" - - def test_duplicate_after_entry_with_block_comment_syntax_in_value(self): - content = ( - '"key" = "Use /* to start a block comment";\n' - '"key" = "duplicate";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"key" = "Use /* to start a block comment";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["key"]) - - def test_closing_comment_syntax_in_later_value_does_not_corrupt_output(self): - content = ( - '"key" = "Use /* to start a block comment";\n' - '"key" = "duplicate";\n' - '"other" = "This */ ends nothing";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"key" = "Use /* to start a block comment";\n' - '"other" = "This */ ends nothing";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["key"]) - - class TestDeduplicateRemovesDuplicates(unittest.TestCase): """Cases where duplicates are removed.""" @@ -151,68 +116,6 @@ def test_first_occurrence_remains_at_original_position(self): class TestDeduplicateCommentHandling(unittest.TestCase): """Comment blocks are removed together with their duplicate entry.""" - def test_removes_single_line_block_comment_with_duplicate(self): - content = ( - '"greeting" = "Hello";\n' - '/* This is a duplicate */\n' - '"greeting" = "Hi";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"greeting" = "Hello";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["greeting"]) - - def test_removes_line_comment_with_duplicate(self): - content = ( - '"greeting" = "Hello";\n' - '// duplicate greeting\n' - '"greeting" = "Hi";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"greeting" = "Hello";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["greeting"]) - - def test_removes_multi_line_block_comment_with_duplicate(self): - content = ( - '"greeting" = "Hello";\n' - '/* This comment\n' - ' spans multiple lines */\n' - '"greeting" = "Hi";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"greeting" = "Hello";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["greeting"]) - - def test_removes_stacked_comments_with_duplicate(self): - content = ( - '"greeting" = "Hello";\n' - '/* Comment one */\n' - '// Comment two\n' - '"greeting" = "Hi";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"greeting" = "Hello";\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["greeting"]) - def test_preserves_comment_on_first_occurrence(self): content = ( '/* This comment belongs to the first occurrence */\n' @@ -248,59 +151,6 @@ def test_preserves_comment_on_non_duplicate_entry(self): self.assertEqual(removed, ["greeting"]) -class TestDeduplicateBlankLineBreaksCommentAssociation(unittest.TestCase): - """A blank line between a comment and an entry breaks their association. - The comment is preserved even if the entry is a duplicate.""" - - def test_blank_line_between_comment_and_duplicate_preserves_comment(self): - content = ( - '"greeting" = "Hello";\n' - '/* Orphaned comment */\n' - '\n' - '"greeting" = "Hi";\n' - '"farewell" = "Goodbye";\n' - ) - expected = ( - '"greeting" = "Hello";\n' - '/* Orphaned comment */\n' - '\n' - '"farewell" = "Goodbye";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["greeting"]) - - def test_blank_lines_between_entries_are_preserved(self): - content = ( - '"alpha" = "A";\n' - '\n' - '"beta" = "B";\n' - '\n' - '"gamma" = "G";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, content) - self.assertEqual(removed, []) - - def test_blank_lines_in_output_are_preserved_around_removed_entry(self): - content = ( - '"alpha" = "A";\n' - '\n' - '"alpha" = "A again";\n' - '\n' - '"beta" = "B";\n' - ) - expected = ( - '"alpha" = "A";\n' - '\n' - '\n' - '"beta" = "B";\n' - ) - result, removed = deduplicate(content) - self.assertEqual(result, expected) - self.assertEqual(removed, ["alpha"]) - - class TestDeduplicateReturnedRemovedKeys(unittest.TestCase): """Verify the removed keys list is correct.""" diff --git a/Scripts/fix-localizable-strings/tests/test_delete_unused_strings.py b/Scripts/fix-localizable-strings/tests/test_delete_unused_strings.py new file mode 100644 index 0000000000..0799a1fdb7 --- /dev/null +++ b/Scripts/fix-localizable-strings/tests/test_delete_unused_strings.py @@ -0,0 +1,258 @@ +"""Tests for the delete_unused_strings module.""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from delete_unused_strings import delete_unused, delete_unused_content, find_used_keys + + +class TestFindUsedKeysBasic(unittest.TestCase): + """Basic identifier extraction from Swift source content.""" + + def test_empty_source_returns_empty_set(self): + result = find_used_keys([""]) + self.assertEqual(result, set()) + + def test_no_sources_returns_empty_set(self): + result = find_used_keys([]) + self.assertEqual(result, set()) + + def test_simple_identifier_returned_lowercase(self): + result = find_used_keys(["Localizations.about"]) + self.assertEqual(result, {"about"}) + + def test_camel_case_identifier_returned_lowercase(self): + result = find_used_keys(["Localizations.valueHasBeenCopied"]) + self.assertEqual(result, {"valuehasbeencopied"}) + + def test_all_caps_identifier_returned_lowercase(self): + # "OK" in the strings file becomes Localizations.ok in Swift. + result = find_used_keys(["Localizations.ok"]) + self.assertEqual(result, {"ok"}) + + def test_no_localizations_reference_returns_empty_set(self): + result = find_used_keys(["let x = 42\nprint(x)"]) + self.assertEqual(result, set()) + + def test_identifier_on_next_line_is_matched(self): + # SwiftUI sometimes splits long chains: `Localizations\n .identifier` + source = "message: Localizations\n .shareFilesAndData," + result = find_used_keys([source]) + self.assertEqual(result, {"sharefilesanddata"}) + + +class TestFindUsedKeysFiltering(unittest.TestCase): + """The `tr` identifier must be excluded; other enum prefixes must not match.""" + + def test_tr_identifier_is_excluded(self): + result = find_used_keys(['Localizations.tr("About", tableName: "Localizable")']) + self.assertEqual(result, set()) + + def test_tr_and_real_identifier_together(self): + source = 'let x = Localizations.tr("key")\nlet label = Localizations.about' + result = find_used_keys([source]) + self.assertEqual(result, {"about"}) + + def test_other_enum_prefix_does_not_match(self): + result = find_used_keys(["OtherEnum.about"]) + self.assertEqual(result, set()) + + +class TestFindUsedKeysParameterizedCalls(unittest.TestCase): + """Multiple identifiers on a line or in nested calls are all captured.""" + + def test_nested_call_captures_both_identifiers(self): + source = "Localizations.valueHasBeenCopied(Localizations.password)" + result = find_used_keys([source]) + self.assertEqual(result, {"valuehasbeencopied", "password"}) + + def test_multiple_identifiers_on_same_line(self): + source = "let a = Localizations.alpha; let b = Localizations.beta" + result = find_used_keys([source]) + self.assertEqual(result, {"alpha", "beta"}) + + +class TestFindUsedKeysMultipleFiles(unittest.TestCase): + """Identifiers are unioned across all provided file contents.""" + + def test_union_across_two_files(self): + result = find_used_keys(["Localizations.alpha", "Localizations.beta"]) + self.assertEqual(result, {"alpha", "beta"}) + + def test_same_key_in_both_files_appears_once(self): + result = find_used_keys(["Localizations.about", "Localizations.about"]) + self.assertEqual(result, {"about"}) + + +class TestDeleteUnusedContent(unittest.TestCase): + """Unit tests for the pure delete_unused_content function.""" + + def test_empty_content_and_empty_used_keys(self): + result, removed = delete_unused_content("", set()) + self.assertEqual(result, "") + self.assertEqual(removed, []) + + def test_all_keys_used_content_unchanged(self): + content = ( + '"About" = "About";\n' + '"Cancel" = "Cancel";\n' + ) + result, removed = delete_unused_content(content, {"about", "cancel"}) + self.assertEqual(result, content) + self.assertEqual(removed, []) + + def test_all_caps_key_matched_case_insensitively(self): + # "OK" in the strings file is accessed as Localizations.ok in Swift, + # so find_used_keys returns "ok". The lookup must match "OK" to "ok". + content = ( + '"OK" = "OK";\n' + '"UnusedKey" = "Unused";\n' + ) + expected = '"OK" = "OK";\n' + result, removed = delete_unused_content(content, {"ok"}) + self.assertEqual(result, expected) + self.assertEqual(removed, ["UnusedKey"]) + + def test_key_with_trailing_punctuation_matched(self): + # SwiftGen strips non-identifier characters: "NeedSomeInspiration?" + # becomes Localizations.needSomeInspiration in Swift. + content = ( + '"NeedSomeInspiration?" = "Need some inspiration?";\n' + '"UnusedKey" = "Unused";\n' + ) + expected = '"NeedSomeInspiration?" = "Need some inspiration?";\n' + result, removed = delete_unused_content(content, {"needsomeinspiration"}) + self.assertEqual(result, expected) + self.assertEqual(removed, ["UnusedKey"]) + + def test_unused_key_removed_sentinel_preserved(self): + content = ( + '"About" = "About";\n' + '"UnusedKey" = "Unused";\n' + '"Cancel" = "Cancel";\n' + ) + expected = ( + '"About" = "About";\n' + '"Cancel" = "Cancel";\n' + ) + result, removed = delete_unused_content(content, {"about", "cancel"}) + self.assertEqual(result, expected) + self.assertEqual(removed, ["UnusedKey"]) + + def test_removed_list_is_in_file_order(self): + content = ( + '"About" = "About";\n' + '"UnusedAlpha" = "A";\n' + '"Cancel" = "Cancel";\n' + '"UnusedBeta" = "B";\n' + '"Done" = "Done";\n' + ) + _, removed = delete_unused_content(content, {"about", "cancel", "done"}) + self.assertEqual(removed, ["UnusedAlpha", "UnusedBeta"]) + + +class TestDeleteUnusedFileIO(unittest.TestCase): + """Integration tests for the file I/O wrapper.""" + + def setUp(self): + self._tmp_dirs: list[str] = [] + self._tmp_files: list[str] = [] + + def tearDown(self): + for path in self._tmp_files: + try: + os.unlink(path) + except FileNotFoundError: + pass + for path in self._tmp_dirs: + import shutil + shutil.rmtree(path, ignore_errors=True) + + def _write_strings(self, content: str) -> str: + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".strings", delete=False, encoding="utf-8" + ) + f.write(content) + f.close() + self._tmp_files.append(f.name) + return f.name + + def _make_swift_dir(self, files: dict[str, str]) -> str: + """Create a temp directory with the given filename→content mapping.""" + d = tempfile.mkdtemp() + self._tmp_dirs.append(d) + for filename, content in files.items(): + filepath = os.path.join(d, filename) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + return d + + def test_file_modified_when_unused_keys_found(self): + strings_path = self._write_strings( + '"About" = "About";\n' + '"UnusedKey" = "Unused";\n' + ) + swift_dir = self._make_swift_dir({"View.swift": "Localizations.about"}) + delete_unused(strings_path, [swift_dir]) + with open(strings_path, encoding="utf-8") as f: + content = f.read() + self.assertNotIn('"UnusedKey"', content) + + def test_file_mtime_unchanged_when_all_keys_used(self): + strings_path = self._write_strings('"About" = "About";\n') + swift_dir = self._make_swift_dir({"View.swift": "Localizations.about"}) + mtime_before = os.path.getmtime(strings_path) + delete_unused(strings_path, [swift_dir]) + mtime_after = os.path.getmtime(strings_path) + self.assertEqual(mtime_before, mtime_after) + + def test_returns_correct_list_of_removed_keys(self): + strings_path = self._write_strings( + '"About" = "About";\n' + '"UnusedKey" = "Unused";\n' + ) + swift_dir = self._make_swift_dir({"View.swift": "Localizations.about"}) + removed = delete_unused(strings_path, [swift_dir]) + self.assertEqual(removed, ["UnusedKey"]) + + def test_returns_empty_list_when_all_keys_used(self): + strings_path = self._write_strings('"About" = "About";\n') + swift_dir = self._make_swift_dir({"View.swift": "Localizations.about"}) + removed = delete_unused(strings_path, [swift_dir]) + self.assertEqual(removed, []) + + def test_all_caps_key_preserved_when_referenced(self): + strings_path = self._write_strings('"OK" = "OK";\n') + swift_dir = self._make_swift_dir({"View.swift": "Localizations.ok"}) + removed = delete_unused(strings_path, [swift_dir]) + self.assertEqual(removed, []) + + def test_scans_swift_files_recursively(self): + strings_path = self._write_strings( + '"About" = "About";\n' + '"Cancel" = "Cancel";\n' + ) + swift_dir = self._make_swift_dir({ + "Views/AboutView.swift": "Localizations.about", + "Views/Nested/CancelView.swift": "Localizations.cancel", + }) + removed = delete_unused(strings_path, [swift_dir]) + self.assertEqual(removed, []) + + def test_ignores_non_swift_files(self): + strings_path = self._write_strings('"About" = "About";\n') + swift_dir = self._make_swift_dir({ + "notes.txt": "Localizations.about", + }) + # The .txt file should not count as a usage source + removed = delete_unused(strings_path, [swift_dir]) + self.assertEqual(removed, ["About"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/fix-localizable-strings/tests/test_strings_file_utils.py b/Scripts/fix-localizable-strings/tests/test_strings_file_utils.py new file mode 100644 index 0000000000..834713d513 --- /dev/null +++ b/Scripts/fix-localizable-strings/tests/test_strings_file_utils.py @@ -0,0 +1,199 @@ +"""Tests for the strings_file_utils module.""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from strings_file_utils import filter_entries + +# Keys accepted by the test predicate. "sentinel" is always kept to confirm +# that filter_entries does not over-delete. +_KEEP = {"kept", "about", "cancel", "sentinel"} + + +def _should_keep(key: str) -> bool: + return key in _KEEP + + +class TestFilterEntriesEmptyAndCommentOnly(unittest.TestCase): + """Degenerate inputs produce empty or unchanged output.""" + + def test_empty_content(self): + result, removed = filter_entries("", _should_keep) + self.assertEqual(result, "") + self.assertEqual(removed, []) + + def test_comment_only_content_is_unchanged(self): + content = "/* This file is intentionally left blank. */\n" + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, content) + self.assertEqual(removed, []) + + +class TestFilterEntriesCommentRemoval(unittest.TestCase): + """Comments immediately before a removed entry are discarded with it.""" + + def test_single_line_block_comment_removed_with_entry(self): + content = ( + '"sentinel" = "S";\n' + '/* This key is unused */\n' + '"unused" = "Unused";\n' + '"cancel" = "Cancel";\n' + ) + expected = ( + '"sentinel" = "S";\n' + '"cancel" = "Cancel";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_line_comment_removed_with_entry(self): + content = ( + '"sentinel" = "S";\n' + '// unused\n' + '"unused" = "Unused";\n' + '"cancel" = "Cancel";\n' + ) + expected = ( + '"sentinel" = "S";\n' + '"cancel" = "Cancel";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_multiline_block_comment_removed_with_entry(self): + content = ( + '"sentinel" = "S";\n' + '/* This comment\n' + ' spans multiple lines */\n' + '"unused" = "Unused";\n' + '"cancel" = "Cancel";\n' + ) + expected = ( + '"sentinel" = "S";\n' + '"cancel" = "Cancel";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_stacked_comments_removed_with_entry(self): + content = ( + '"sentinel" = "S";\n' + '/* Comment one */\n' + '// Comment two\n' + '"unused" = "Unused";\n' + '"cancel" = "Cancel";\n' + ) + expected = ( + '"sentinel" = "S";\n' + '"cancel" = "Cancel";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_comment_on_kept_entry_is_preserved(self): + content = ( + '/* Section header */\n' + '"about" = "About";\n' + '"unused" = "Unused";\n' + '"sentinel" = "S";\n' + ) + expected = ( + '/* Section header */\n' + '"about" = "About";\n' + '"sentinel" = "S";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + +class TestFilterEntriesBlankLines(unittest.TestCase): + """Blank lines break comment-entry association and are preserved in output.""" + + def test_blank_line_between_comment_and_removed_entry_preserves_comment(self): + content = ( + '"sentinel" = "S";\n' + '/* Orphaned comment */\n' + '\n' + '"unused" = "Unused";\n' + '"cancel" = "Cancel";\n' + ) + expected = ( + '"sentinel" = "S";\n' + '/* Orphaned comment */\n' + '\n' + '"cancel" = "Cancel";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_blank_lines_between_kept_entries_are_preserved(self): + content = ( + '"about" = "About";\n' + '\n' + '"cancel" = "Cancel";\n' + '\n' + '"sentinel" = "S";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, content) + self.assertEqual(removed, []) + + +class TestFilterEntriesCommentSyntaxInValue(unittest.TestCase): + """Entry values containing comment-like syntax must not be misclassified.""" + + def test_block_comment_syntax_in_value_does_not_suppress_filtering(self): + content = ( + '"kept" = "Use /* to start a block comment";\n' + '"unused" = "Unused";\n' + '"sentinel" = "S";\n' + ) + expected = ( + '"kept" = "Use /* to start a block comment";\n' + '"sentinel" = "S";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + def test_closing_comment_syntax_in_value_does_not_corrupt_output(self): + content = ( + '"kept" = "Use /* to start a block comment";\n' + '"unused" = "Unused";\n' + '"about" = "This */ ends nothing";\n' + '"sentinel" = "S";\n' + ) + expected = ( + '"kept" = "Use /* to start a block comment";\n' + '"about" = "This */ ends nothing";\n' + '"sentinel" = "S";\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, expected) + self.assertEqual(removed, ["unused"]) + + +class TestFilterEntriesTrailingPending(unittest.TestCase): + """Trailing comment with no following entry is flushed to output.""" + + def test_trailing_comment_without_entry_is_preserved(self): + content = ( + '"sentinel" = "S";\n' + '/* Trailing comment */\n' + ) + result, removed = filter_entries(content, _should_keep) + self.assertEqual(result, content) + self.assertEqual(removed, []) + + +if __name__ == "__main__": + unittest.main()