From 97e948958f686308a9779be75d7940210626cd35 Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 11:31:35 -0400 Subject: [PATCH 1/8] Deepen the gear audit: tier set, upgrade track, gem quality The audit checked enchants, sockets and item level. Add three more of the questions worth asking before a raid, all from unprotected item data: - Tier set count via C_Item.GetSetBonusesForSpecializationByItemID across the five tier slots -- there is no "N of 5" API, so we tally the slots whose item grants a set bonus for the current spec, which also counts Catalyst-made pieces. - Upgrade track and progress per slot ("Champion 6/8"). No API returns this for an equipped item, so it is scanned from the item tooltip via C_TooltipInfo; best-effort and English-only by nature, guarded so a bad read never takes the audit down, and simply omitted when it doesn't match. - Below-Epic gems, flagged like a missing enchant. Surfaced in /sp gear, the Gear options page, and (tier count) the chat announce. Enchant rank and embellishment were left out: both need a per-season table or fragile matching and would rot silently. API surface verified against warcraft.wiki.gg and a reference addon before building. Not verified in-game -- the tooltip scan for the upgrade track is the part most worth confirming live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .luacheckrc | 3 +- Announce.lua | 3 ++ CHANGELOG.md | 20 ++++++++ Gear.lua | 130 +++++++++++++++++++++++++++++++++++++++++++++++++- Options.lua | 17 +++++-- README.md | 3 +- StatPanel.toc | 2 +- 7 files changed, 170 insertions(+), 8 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index a2dcdfe..746bc09 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -52,6 +52,7 @@ read_globals = { -- Namespaced APIs "C_Timer", "C_PlayerInfo", "C_PetBattles", "C_SpecializationInfo", "C_Item", "C_ChatInfo", "C_AddOns", "C_PaperDollInfo", + "C_TooltipInfo", "TooltipUtil", -- Character and stats "UnitName", "UnitClass", "UnitLevel", "UnitExists", "UnitArmor", "UnitStat", @@ -65,7 +66,7 @@ read_globals = { "IsPlayerMoving", "IsFlying", -- Items - "GetInventoryItemLink", "GetItemStats", "GetDetailedItemLevelInfo", + "GetInventoryItemLink", "GetInventoryItemID", "GetItemStats", "GetDetailedItemLevelInfo", -- Chat and performance "SendChatMessage", "GetFramerate", "GetNetStats", diff --git a/Announce.lua b/Announce.lua index 56fb531..53d07aa 100644 --- a/Announce.lua +++ b/Announce.lua @@ -117,6 +117,9 @@ function Announce:Build() issues[#issues + 1] = audit.emptySockets .. " empty socket" .. (audit.emptySockets == 1 and "" or "s") end + if audit.tierCount then + parts[#parts + 1] = string.format("%d/%d tier set", audit.tierCount, audit.tierTotal) + end if #issues > 0 then parts[#parts + 1] = table.concat(issues, ", ") end diff --git a/CHANGELOG.md b/CHANGELOG.md index 7776b95..34f5572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to StatPanel are recorded here. This project follows [Semantic Versioning](https://semver.org/) and the format of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.2.0] - 2026-07-23 + +### Added + +- **Deeper gear audit.** `/sp gear`, the Gear options page and the chat announce + now report more than enchants and sockets: + - **Tier set count** — how many class set pieces you have equipped, e.g. + `4/5`. Counted through the current set-bonus API, so Catalyst-made pieces + count the same as drops. + - **Upgrade track and progress** per slot — `Champion 6/8`, dimmed once a slot + is maxed — plus a tally of items not yet fully upgraded. There is no API for + an equipped item's track, so this is read from the item tooltip: best-effort + on English clients, and quietly omitted rather than guessed elsewhere. + - **Below-Epic gems** — a rare gem sitting in an epic item is flagged like a + missing enchant, since it's an easily-forgotten upgrade. + + Enchant *rank* and embellishment detection were left out on purpose: both need + a per-season maintained table or fragile tooltip matching, and would quietly + rot between patches rather than fail loudly. + ## [2.1.0] - 2026-07-22 ### Added diff --git a/Gear.lua b/Gear.lua index 2da7e52..a3c60b2 100644 --- a/Gear.lua +++ b/Gear.lua @@ -51,6 +51,24 @@ local ENCHANTABLE = { -------------------------------------------------------------------------------- local GetItemLevel = (C_Item and C_Item.GetDetailedItemLevelInfo) or _G.GetDetailedItemLevelInfo local GetStats = (C_Item and C_Item.GetItemStats) or _G.GetItemStats +local GetGemQuality = C_Item and C_Item.GetItemQualityByID +local GetSetBonuses = C_Item and C_Item.GetSetBonusesForSpecializationByItemID +local GetTooltipData = C_TooltipInfo and C_TooltipInfo.GetInventoryItem +local isSecret = SP.IsSecret or function() return false end + +-- Slots that hold a class tier piece, and the count that makes a full set. The +-- set count has no single API: we ask each of these slots whether its item +-- grants a set bonus for the current spec and tally the answers. +local TIER_SLOTS = { 1, 3, 5, 10, 7 } -- Head, Shoulder, Chest, Hands, Legs +local TIER_TOTAL = #TIER_SLOTS + +-- Upgrade tracks in ascending order, current-season naming. Only used to +-- recognise the track word in the tooltip line; membership here is what tells a +-- real "Champion 6/8" line apart from any other "word N/M" text. +local UPGRADE_TRACKS = { + Explorer = true, Adventurer = true, Veteran = true, + Champion = true, Hero = true, Myth = true, +} -- An item link carries its enchant and gems inline: -- item:itemID:enchantID:gem1:gem2:gem3:gem4:... @@ -94,6 +112,76 @@ local function countEmptySockets(link) return empty end +-- Counts gems below Epic quality in a parsed gem list. A rare gem in an epic +-- item is a small, easily-forgotten upgrade -- the socketed equivalent of +-- leaving on a lower-rank enchant. +local function countLowGems(gems) + if not GetGemQuality or type(gems) ~= "table" then return 0 end + local low = 0 + for _, gemID in ipairs(gems) do + if gemID and gemID > 0 then + local ok, quality = pcall(GetGemQuality, gemID) + -- Enum.ItemQuality: 4 = Epic. Below that is a downgrade worth noting. + if ok and type(quality) == "number" and quality < 4 then + low = low + 1 + end + end + end + return low +end + +-- Equipped tier pieces (0..TIER_TOTAL), or nil if the API or spec is +-- unavailable. There is no "N of 5" API; we ask each tier slot's item whether it +-- grants a set bonus for the current spec and count the ones that do -- which is +-- also how Catalyst-made pieces get counted, since they carry the real bonus. +local function countTierPieces() + if not GetSetBonuses then return nil end + local index = GetSpecialization and GetSpecialization() + local specID = index and GetSpecializationInfo(index) + if not specID then return nil end + + local count = 0 + for _, slotID in ipairs(TIER_SLOTS) do + local itemID = GetInventoryItemID("player", slotID) + if itemID then + local ok, spells = pcall(GetSetBonuses, specID, itemID) + if ok and type(spells) == "table" and #spells > 0 then + count = count + 1 + end + end + end + return count +end + +-- Reads an equipped item's upgrade track and progress by scanning its tooltip, +-- because no API returns it for an arbitrary equipped item. Returns track name, +-- current and max upgrade level (e.g. "Champion", 6, 8), or nil. +-- +-- This is best-effort: the track word is localized, so on a non-English client +-- it simply finds nothing and the audit omits upgrade info rather than guessing. +-- Every step is guarded -- a tooltip read must never take the audit down. +local function scanUpgrade(slotID) + if not GetTooltipData then return nil end + + local ok, data = pcall(GetTooltipData, "player", slotID) + if not ok or type(data) ~= "table" or type(data.lines) ~= "table" then return nil end + if TooltipUtil and TooltipUtil.SurfaceArgs then pcall(TooltipUtil.SurfaceArgs, data) end + + for _, line in ipairs(data.lines) do + if TooltipUtil and TooltipUtil.SurfaceArgs then pcall(TooltipUtil.SurfaceArgs, line) end + local text = line.leftText + -- Item tooltip lines are not secret values in 12.0, but guard anyway: + -- a secret string can't be pattern-matched. + if type(text) == "string" and not isSecret(text) then + local track, cur, max = text:match("(%a+)%s+(%d+)/(%d+)") + if track and UPGRADE_TRACKS[track] then + return track, tonumber(cur), tonumber(max) + end + end + end + return nil +end + -------------------------------------------------------------------------------- -- AUDIT -------------------------------------------------------------------------------- @@ -105,6 +193,10 @@ function Gear:Audit() missingEnchants = 0, emptySockets = 0, emptySlots = 0, + lowGems = 0, + notMaxUpgrade = 0, + tierCount = countTierPieces(), -- nil when unavailable + tierTotal = TIER_TOTAL, lowest = nil, highest = nil, average = 0, @@ -124,9 +216,16 @@ function Gear:Audit() entry.enchanted = parsed and parsed.enchant ~= 0 or false entry.needsEnchant = ENCHANTABLE[slot.id] and not entry.enchanted or false entry.emptySockets = countEmptySockets(link) + entry.lowGems = parsed and countLowGems(parsed.gems) or 0 + + entry.upgradeTrack, entry.upgradeCur, entry.upgradeMax = scanUpgrade(slot.id) + if entry.upgradeCur and entry.upgradeMax and entry.upgradeCur < entry.upgradeMax then + result.notMaxUpgrade = result.notMaxUpgrade + 1 + end if entry.needsEnchant then result.missingEnchants = result.missingEnchants + 1 end result.emptySockets = result.emptySockets + entry.emptySockets + result.lowGems = result.lowGems + entry.lowGems if entry.itemLevel > 0 then total = total + entry.itemLevel @@ -175,13 +274,26 @@ function Gear:PrintReport() if slot.emptySockets > 0 then notes[#notes + 1] = colorize(slot.emptySockets .. " empty socket", 0.95, 0.6, 0.25) end + if slot.lowGems > 0 then + notes[#notes + 1] = colorize(slot.lowGems .. " rare gem", 0.95, 0.6, 0.25) + end + + -- The upgrade track sits after the item level, dimmed when maxed and + -- highlighted when there is headroom left. + local track = "" + if slot.upgradeTrack and slot.upgradeCur and slot.upgradeMax then + local maxed = slot.upgradeCur >= slot.upgradeMax + track = " " .. colorize( + string.format("%s %d/%d", slot.upgradeTrack, slot.upgradeCur, slot.upgradeMax), + maxed and 0.5 or 0.55, maxed and 0.5 or 0.7, maxed and 0.55 or 0.95) + end local marker = "" if audit.lowest and slot.id == audit.lowest.id then marker = colorize(" <- lowest", 0.6, 0.6, 0.65) end - print(string.format(" %-12s %d%s%s", slot.name, slot.itemLevel, + print(string.format(" %-12s %d%s%s%s", slot.name, slot.itemLevel, track, #notes > 0 and (" " .. table.concat(notes, ", ")) or "", marker)) end end @@ -192,7 +304,18 @@ function Gear:PrintReport() print(string.format(" %-12s %.2f", "Equipped", equipped)) end - if audit.missingEnchants == 0 and audit.emptySockets == 0 and audit.emptySlots == 0 then + if audit.tierCount then + local full = audit.tierCount >= audit.tierTotal + print(" " .. colorize(string.format("Tier set %d/%d", audit.tierCount, audit.tierTotal), + full and 0.35 or 0.6, full and 0.85 or 0.6, full and 0.4 or 0.65)) + end + if audit.notMaxUpgrade > 0 then + print(" " .. colorize(audit.notMaxUpgrade .. " item" + .. (audit.notMaxUpgrade == 1 and "" or "s") .. " not fully upgraded", 0.6, 0.6, 0.65)) + end + + if audit.missingEnchants == 0 and audit.emptySockets == 0 and audit.emptySlots == 0 + and audit.lowGems == 0 then print(" " .. colorize("Everything is enchanted and socketed.", 0.35, 0.85, 0.4)) end end @@ -211,6 +334,9 @@ function Gear:WarningText() if audit.emptySockets > 0 then issues[#issues + 1] = audit.emptySockets .. " socket" .. (audit.emptySockets == 1 and "" or "s") end + if audit.lowGems > 0 then + issues[#issues + 1] = audit.lowGems .. " rare gem" .. (audit.lowGems == 1 and "" or "s") + end if audit.emptySlots > 0 then issues[#issues + 1] = audit.emptySlots .. " empty slot" .. (audit.emptySlots == 1 and "" or "s") end diff --git a/Options.lua b/Options.lua index 43f0907..0f68da8 100644 --- a/Options.lua +++ b/Options.lua @@ -1130,8 +1130,10 @@ local function buildGear(content, stack) local audit = SP.Gear:Audit() local warning = SP.Gear:WarningText() - summary.text:SetText(string.format("Average equipped item level %.2f. %s", - audit.average, warning or "Nothing missing.")) + local tier = audit.tierCount + and string.format(" Tier set %d/%d.", audit.tierCount, audit.tierTotal) or "" + summary.text:SetText(string.format("Average equipped item level %.2f.%s %s", + audit.average, tier, warning or "Nothing missing.")) for _, row in ipairs(list.rows) do row:Hide() end @@ -1176,9 +1178,18 @@ local function buildGear(content, stack) if slot.emptySockets > 0 then notes[#notes + 1] = slot.emptySockets .. " empty socket" end + if slot.lowGems > 0 then + notes[#notes + 1] = slot.lowGems .. " rare gem" + end + + local track = "" + if slot.upgradeTrack and slot.upgradeCur and slot.upgradeMax then + track = string.format(" %s %d/%d", + slot.upgradeTrack, slot.upgradeCur, slot.upgradeMax) + end local isLowest = audit.lowest and slot.id == audit.lowest.id - row.info:SetText(string.format("%d%s%s", slot.itemLevel, + row.info:SetText(string.format("%d%s%s%s", slot.itemLevel, track, #notes > 0 and (" " .. table.concat(notes, ", ")) or "", isLowest and " (lowest)" or "")) diff --git a/README.md b/README.md index b0cd0e0..3a896fa 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ item level in the title, session speed peak, and framerate with both latencies.* - Live preview docked beside the options window while you configure - Right-click menu on the panel for quick toggles, presets and profiles - Minimap button and a LibDataBroker feed -- Gear audit: per-slot item level, missing enchants, empty sockets +- Gear audit: per-slot item level and upgrade track, tier set count, missing + enchants, empty sockets and below-Epic gems - Chat announce for your gear summary --- diff --git a/StatPanel.toc b/StatPanel.toc index d5126cf..0fd980a 100644 --- a/StatPanel.toc +++ b/StatPanel.toc @@ -2,7 +2,7 @@ ## Title: StatPanel ## Notes: Fully customizable secondary stat, speed and FPS display ## Author: Fennell -## Version: 2.1.0 +## Version: 2.2.0 ## SavedVariables: SPAddonDB ## OptionalDeps: LibSharedMedia-3.0, LibDataBroker-1.1, LibDBIcon-1.0 ## IconTexture: Interface\Icons\INV_Misc_Gear_01 From b738b33ba895059007f03db2f6a8eec5a29b4b63 Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 14:19:35 -0400 Subject: [PATCH 2/8] Add a locale framework and route the slash + menu strings through it StatPanel had no localization at all -- every user-facing string was an inline English literal. Add a tiny hand-rolled locale table (no embedded library, in keeping with the rest of the addon): - Locale.lua creates SP.L, whose __index returns the key itself, so the English string IS the key and any untranslated or missing string falls back to readable English. A matching non-English client locale file overlays it; other locales' files load into a throwaway table. - Locales/README.md documents how to contribute a translation. - Loaded first in the .toc, before anything that reads SP.L. Route the slash-command output (SPMain) and the right-click menu (Menu) through SP.L as the first tranche, converting the concatenated messages to format strings so translators can reorder. The remaining files -- Options, Config, Presets, Gear, Announce and the rest -- are the next tranches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Locale.lua | 31 +++++++++++++++++++++++++++++++ Locales/README.md | 39 +++++++++++++++++++++++++++++++++++++++ Menu.lua | 19 ++++++++++--------- SPMain.lua | 43 ++++++++++++++++++++++--------------------- StatPanel.toc | 3 ++- 5 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 Locale.lua create mode 100644 Locales/README.md diff --git a/Locale.lua b/Locale.lua new file mode 100644 index 0000000..e47e0bb --- /dev/null +++ b/Locale.lua @@ -0,0 +1,31 @@ +-- Locale.lua (Translation table + registration) +-- +-- A tiny locale system, hand-rolled rather than AceLocale, because StatPanel +-- embeds no libraries. Every user-facing string is looked up in SP.L; the +-- English base always loads, and a matching non-English client locale overlays +-- it, so an untranslated key falls back to English and a missing key falls back +-- to itself (the key text). Nothing ever comes back nil. +-- +-- Load order matters: this file and the Locales/ tables come first in the .toc, +-- before anything that reads SP.L. + +local addonName, SP = ... + +-- __index returns the key itself, so a string with no entry at all still shows +-- readable text instead of nil -- the last-resort safety net beneath the +-- English base. +SP.L = setmetatable({}, { + __index = function(_, key) return key end, +}) + +-- Each Locales/xxYY.lua calls this and assigns its strings onto the result: +-- local L = SP.Locale("deDE"); L["Show panel"] = "Panel anzeigen" +-- enUS is the base and always applies. A file whose locale matches the client +-- overlays it. Any other locale's assignments land on a throwaway table and are +-- discarded, so every translation file can load unconditionally. +function SP.Locale(locale) + if locale == "enUS" or locale == GetLocale() then + return SP.L + end + return {} +end diff --git a/Locales/README.md b/Locales/README.md new file mode 100644 index 0000000..a7547ac --- /dev/null +++ b/Locales/README.md @@ -0,0 +1,39 @@ +# Translations + +StatPanel routes every user-facing string through `SP.L`, so the addon can be +translated without touching the code. The **English string is the key** — there +is no separate English value file to maintain, and any string a translation +doesn't cover falls back to English automatically. + +## Adding a language + +1. Create `Locales/.lua`, where `` is the client value returned + by `GetLocale()` — `deDE`, `frFR`, `ruRU`, `koKR`, `zhCN`, `zhTW`, `esES`, + `esMX`, `ptBR`, `itIT`. + +2. Start it like this and translate the right-hand side only: + + ```lua + local _, SP = ... + local L = SP.Locale("deDE") + + L["Show panel"] = "Panel anzeigen" + L["Lock position"] = "Position sperren" + -- ...only the strings you translate; the rest stay English. + ``` + + `SP.Locale` returns the live table when the file's locale matches the + client, and a throwaway table otherwise — so every locale file can load on + every client without guards. + +3. Add the file to `StatPanel.toc`, right after `Locale.lua`: + + ``` + Locale.lua + Locales\deDE.lua + Media.lua + ``` + +The keys are the exact English strings as they appear in the UI. You do not need +to translate all of them at once — partial translations are fine and fall back +to English key by key. diff --git a/Menu.lua b/Menu.lua index 3e7dab9..ccd61dd 100644 --- a/Menu.lua +++ b/Menu.lua @@ -5,6 +5,7 @@ -- API (11.0+); if it's ever missing we just open the full options instead. local addonName, SP = ... +local L = SP.L local REFRESH = _G.MenuResponse and _G.MenuResponse.Refresh @@ -19,11 +20,11 @@ function SP:ShowContextMenu(owner) -- Live toggles. Returning MenuResponse.Refresh keeps the menu open and -- re-reads the checkbox so it visibly ticks/unticks under the cursor. - root:CreateCheckbox("Show panel", + root:CreateCheckbox(L["Show panel"], function() return SP.db.enabled end, function() SP:TogglePanel(); SP.UI:RefreshAll(); return REFRESH end) - root:CreateCheckbox("Lock position", + root:CreateCheckbox(L["Lock position"], function() return SP.db.panel.locked end, function() SP.db.panel.locked = not SP.db.panel.locked @@ -31,7 +32,7 @@ function SP:ShowContextMenu(owner) return REFRESH end) - root:CreateCheckbox("Show FPS", + root:CreateCheckbox(L["Show FPS"], function() return SP.db.footer.showFPS end, function() SP.db.footer.showFPS = not SP.db.footer.showFPS @@ -42,7 +43,7 @@ function SP:ShowContextMenu(owner) root:CreateDivider() -- Presets submenu. - local presets = root:CreateButton("Apply preset") + local presets = root:CreateButton(L["Apply preset"]) for _, name in ipairs(SP.Presets.order) do presets:CreateButton(name, function() SP.Presets:Apply(name) @@ -51,7 +52,7 @@ function SP:ShowContextMenu(owner) end -- Profiles submenu, as a radio group so the active one shows a dot. - local profiles = root:CreateButton("Profile") + local profiles = root:CreateButton(L["Profile"]) for _, name in ipairs(SP.Config:ProfileList()) do profiles:CreateRadio(name, function() return SP.Config:CurrentProfile() == name end, @@ -65,7 +66,7 @@ function SP:ShowContextMenu(owner) -- Announce targets. "Print to my chat only" is first so a misclick -- previews rather than broadcasts. - local announce = root:CreateButton("Announce to") + local announce = root:CreateButton(L["Announce to"]) for _, channel in ipairs(SP.Announce.channels) do if channel.value ~= "WHISPER" then announce:CreateButton(channel.name, function() @@ -74,12 +75,12 @@ function SP:ShowContextMenu(owner) end end - root:CreateButton("Audit my gear", function() SP.Gear:PrintReport() end) + root:CreateButton(L["Audit my gear"], function() SP.Gear:PrintReport() end) root:CreateDivider() - root:CreateButton("Open options", function() SP:OpenOptions() end) - root:CreateButton("Reset position", function() + root:CreateButton(L["Open options"], function() SP:OpenOptions() end) + root:CreateButton(L["Reset position"], function() SP.db.panel.pos = SP.Config:DeepCopy(SP.Config.DEFAULTS.panel.pos) SP:Refresh() end) diff --git a/SPMain.lua b/SPMain.lua index 7c412fb..aacf8db 100644 --- a/SPMain.lua +++ b/SPMain.lua @@ -1,6 +1,7 @@ -- SPMain.lua (Initialization and slash commands) local addonName, SP = ... +local L = SP.L local PREFIX = "|cff88bbffStatPanel|r: " @@ -50,17 +51,17 @@ end -- SLASH COMMANDS -------------------------------------------------------------------------------- local function usage() - SP:Print("commands:") - print(" |cffffd100/sp|r - open the options") - print(" |cffffd100/sp toggle|r - show or hide the panel") - print(" |cffffd100/sp lock|r - lock or unlock dragging") - print(" |cffffd100/sp reset|r - move the panel back to the center") - print(" |cffffd100/sp preset |r - apply a preset (" .. table.concat(SP.Presets.order, ", ") .. ")") - print(" |cffffd100/sp profile |r - switch profiles") - print(" |cffffd100/sp peak|r - report and clear the session speed record") - print(" |cffffd100/sp minimap|r - show or hide the minimap button") - print(" |cffffd100/sp gear|r - audit enchants, sockets and item level") - print(" |cffffd100/sp announce [channel]|r - report your gear to chat") + SP:Print(L["commands:"]) + print(L[" |cffffd100/sp|r - open the options"]) + print(L[" |cffffd100/sp toggle|r - show or hide the panel"]) + print(L[" |cffffd100/sp lock|r - lock or unlock dragging"]) + print(L[" |cffffd100/sp reset|r - move the panel back to the center"]) + print(L[" |cffffd100/sp preset |r - apply a preset (%s)"]:format(table.concat(SP.Presets.order, ", "))) + print(L[" |cffffd100/sp profile |r - switch profiles"]) + print(L[" |cffffd100/sp peak|r - report and clear the session speed record"]) + print(L[" |cffffd100/sp minimap|r - show or hide the minimap button"]) + print(L[" |cffffd100/sp gear|r - audit enchants, sockets and item level"]) + print(L[" |cffffd100/sp announce [channel]|r - report your gear to chat"]) end SLASH_STATPANEL1 = "/sp" @@ -75,37 +76,37 @@ SlashCmdList["STATPANEL"] = function(input) SP:OpenOptions() elseif command == "toggle" then - SP:Print(SP:TogglePanel() and "panel shown." or "panel hidden.") + SP:Print(SP:TogglePanel() and L["panel shown."] or L["panel hidden."]) elseif command == "lock" then SP.db.panel.locked = not SP.db.panel.locked - SP:Print(SP.db.panel.locked and "panel locked." or "panel unlocked.") + SP:Print(SP.db.panel.locked and L["panel locked."] or L["panel unlocked."]) SP.UI:RefreshAll() elseif command == "reset" then SP.db.panel.pos = SP.Config:DeepCopy(SP.Config.DEFAULTS.panel.pos) SP:Refresh() - SP:Print("position reset.") + SP:Print(L["position reset."]) elseif command == "preset" then if SP.Presets:Apply(rest) then - SP:Print("applied the '" .. rest .. "' preset.") + SP:Print(L["applied the '%s' preset."]:format(rest)) SP.UI:RefreshAll() else - SP:Print("unknown preset. Available: " .. table.concat(SP.Presets.order, ", ")) + SP:Print(L["unknown preset. Available: %s"]:format(table.concat(SP.Presets.order, ", "))) end elseif command == "profile" then if rest ~= "" and SP.Config:SetProfile(rest) then - SP:Print("switched to profile '" .. rest .. "'.") + SP:Print(L["switched to profile '%s'."]:format(rest)) SP.UI:RefreshAll() else - SP:Print("profiles: " .. table.concat(SP.Config:ProfileList(), ", ")) + SP:Print(L["profiles: %s"]:format(table.concat(SP.Config:ProfileList(), ", "))) end elseif command == "peak" then SP:ResetPeakSpeed() - SP:Print("session speed record cleared.") + SP:Print(L["session speed record cleared."]) elseif command == "gear" then SP.Gear:PrintReport() @@ -121,8 +122,8 @@ SlashCmdList["STATPANEL"] = function(input) elseif command == "minimap" then SP.Broker:SetHidden(not SPAddonDB.global.minimap.hide) - SP:Print(SPAddonDB.global.minimap.hide and "minimap button hidden." - or "minimap button shown.") + SP:Print(SPAddonDB.global.minimap.hide and L["minimap button hidden."] + or L["minimap button shown."]) else usage() diff --git a/StatPanel.toc b/StatPanel.toc index 0fd980a..d264ca9 100644 --- a/StatPanel.toc +++ b/StatPanel.toc @@ -2,7 +2,7 @@ ## Title: StatPanel ## Notes: Fully customizable secondary stat, speed and FPS display ## Author: Fennell -## Version: 2.2.0 +## Version: 2.3.0 ## SavedVariables: SPAddonDB ## OptionalDeps: LibSharedMedia-3.0, LibDataBroker-1.1, LibDBIcon-1.0 ## IconTexture: Interface\Icons\INV_Misc_Gear_01 @@ -12,6 +12,7 @@ ## X-Curse-Project-ID: 1122269 ## X-Wago-ID: 96d2OlGO +Locale.lua Media.lua Config.lua Presets.lua From 361d26b73aa01227180c6049952e5f94fbe0ec0e Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 14:30:56 -0400 Subject: [PATCH 3/8] Whitelist GetLocale for luacheck Locale.lua reads GetLocale() to pick the client locale; it wasn't in the declared API list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .luacheckrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.luacheckrc b/.luacheckrc index 746bc09..6502e0b 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -61,7 +61,7 @@ read_globals = { "GetCombatRating", "GetCombatRatingBonus", "GetCritChance", "GetHaste", "GetMasteryEffect", "GetDodgeChance", "GetParryChance", "GetBlockChance", "GetLifesteal", "GetAvoidance", "GetSpellCritChance", - "GetAverageItemLevel", "GetUnitSpeed", "GetRealmName", + "GetAverageItemLevel", "GetUnitSpeed", "GetRealmName", "GetLocale", "IsInInstance", "GetInstanceInfo", "IsInGroup", "IsInRaid", "IsInGuild", "IsPlayerMoving", "IsFlying", From 40ba4eb07ad32dd1c683fb17b38ede301c26324c Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 18:08:26 -0400 Subject: [PATCH 4/8] Route Options, Config and Presets strings through SP.L The bulk of the sweep. Every option label, tooltip, header, note, dropdown display name and button caption in Options.lua now looks up SP.L; Config.lua's profile/import error messages and the preset descriptions in Presets.lua do too. Concatenated messages became format strings so translators can reorder. Config keys (path=), dropdown data values (value=), page ids, value-format templates, media/font names, preset names and section titles are deliberately left untouched -- they are identifiers and lookup keys, not display text. Because SP.L falls back to the key itself, every wrap is a no-op on an English client and only takes effect once a translation provides the string. The mechanical wrapping was done by script over the unambiguous patterns (label/tooltip/text/name/UI:Header/UI:Note) and reviewed as a whole; the concatenated and escaped strings were done by hand. Co-Authored-By: Claude Opus 4.8 (1M context) --- Config.lua | 21 +- Options.lua | 605 ++++++++++++++++++++++++++-------------------------- Presets.lua | 45 ++-- 3 files changed, 337 insertions(+), 334 deletions(-) diff --git a/Config.lua b/Config.lua index 761b447..becb771 100644 --- a/Config.lua +++ b/Config.lua @@ -10,6 +10,7 @@ -- re-reads the whole profile on every refresh. local addonName, SP = ... +local L = SP.L local Config = {} SP.Config = Config @@ -478,8 +479,8 @@ end -- Creates a profile, optionally seeded from an existing one (a copy). function Config:NewProfile(name, copyFrom) name = (name or ""):trim() - if name == "" then return false, "Profile name cannot be empty." end - if SPAddonDB.profiles[name] then return false, "A profile named '" .. name .. "' already exists." end + if name == "" then return false, L["Profile name cannot be empty."] end + if SPAddonDB.profiles[name] then return false, L["A profile named '%s' already exists."]:format(name) end local source = copyFrom and SPAddonDB.profiles[copyFrom] or DEFAULTS SPAddonDB.profiles[name] = deepCopy(source) @@ -487,8 +488,8 @@ function Config:NewProfile(name, copyFrom) end function Config:DeleteProfile(name) - if name == "Default" then return false, "The Default profile cannot be deleted." end - if not SPAddonDB.profiles[name] then return false, "No such profile." end + if name == "Default" then return false, L["The Default profile cannot be deleted."] end + if not SPAddonDB.profiles[name] then return false, L["No such profile."] end SPAddonDB.profiles[name] = nil @@ -657,7 +658,7 @@ end -- Returns the imported profile name on success, or nil plus an error message. function Config:Import(text, targetName) - if type(text) ~= "string" then return nil, "Nothing to import." end + if type(text) ~= "string" then return nil, L["Nothing to import."] end text = text:gsub("%s+", "") @@ -666,22 +667,22 @@ function Config:Import(text, targetName) -- but it can't stop a table constructor like {x=("a"):rep(2^30)} from -- allocating gigabytes during pcall, and string methods resolve even in an -- empty environment. Reject oversize strings before they reach loadstring. - if #text > 262144 then return nil, "That import string is too large to be a profile." end + if #text > 262144 then return nil, L["That import string is too large to be a profile."] end local payload = text:match("^SP1!(.+)$") - if not payload then return nil, "That doesn't look like a StatPanel export string." end + if not payload then return nil, L["That doesn't look like a StatPanel export string."] end local decoded = base64Decode(payload) - if not decoded then return nil, "The import string is corrupt." end + if not decoded then return nil, L["The import string is corrupt."] end -- The chunk is a bare table constructor; load it in an empty environment so -- a malformed or hostile string can't reach into the game's globals. local chunk, err = loadstring("return " .. decoded) - if not chunk then return nil, "Could not read the import string: " .. (err or "?") end + if not chunk then return nil, L["Could not read the import string: %s"]:format(err or "?") end setfenv(chunk, {}) local ok, result = pcall(chunk) - if not ok or type(result) ~= "table" then return nil, "The import string did not contain a profile." end + if not ok or type(result) ~= "table" then return nil, L["The import string did not contain a profile."] end targetName = (targetName or ""):trim() if targetName == "" then targetName = self:CurrentProfile() end diff --git a/Options.lua b/Options.lua index e132c93..70d3fa3 100644 --- a/Options.lua +++ b/Options.lua @@ -4,6 +4,7 @@ -- right. Pages are built the first time they're opened so login cost stays low. local addonName, SP = ... +local L = SP.L local UI = SP.UI local Config = SP.Config @@ -25,40 +26,40 @@ local function mediaValues(kind) end local ALIGN_VALUES = { - { name = "Left", value = "LEFT" }, - { name = "Center", value = "CENTER" }, - { name = "Right", value = "RIGHT" }, + { name = L["Left"], value = "LEFT" }, + { name = L["Center"], value = "CENTER" }, + { name = L["Right"], value = "RIGHT" }, } local FILL_VALUES = { - { name = "Proportional to value", value = "value" }, - { name = "Always full", value = "full" }, - { name = "No fill (text only)", value = "none" }, + { name = L["Proportional to value"], value = "value" }, + { name = L["Always full"], value = "full" }, + { name = L["No fill (text only)"], value = "none" }, } local COLOR_MODE_VALUES = { - { name = "Per-stat colors", value = "stat" }, - { name = "Class color", value = "class" }, - { name = "Single color", value = "single" }, - { name = "Value gradient", value = "gradient" }, + { name = L["Per-stat colors"], value = "stat" }, + { name = L["Class color"], value = "class" }, + { name = L["Single color"], value = "single" }, + { name = L["Value gradient"], value = "gradient" }, } local ROW_STYLE_VALUES = { - { name = "Bars", value = "bar" }, - { name = "Text only", value = "text" }, + { name = L["Bars"], value = "bar" }, + { name = L["Text only"], value = "text" }, } local TITLE_MODE_VALUES = { - { name = "Item level", value = "ilvl" }, - { name = "Player name", value = "name" }, - { name = "Specialization", value = "spec" }, - { name = "Custom text", value = "custom" }, - { name = "Hidden", value = "none" }, + { name = L["Item level"], value = "ilvl" }, + { name = L["Player name"], value = "name" }, + { name = L["Specialization"], value = "spec" }, + { name = L["Custom text"], value = "custom" }, + { name = L["Hidden"], value = "none" }, } local SOURCE_VALUES = { - { name = "Total effect (character sheet)", value = "total" }, - { name = "Bonus from rating only", value = "bonus" }, + { name = L["Total effect (character sheet)"], value = "total" }, + { name = L["Bonus from rating only"], value = "bonus" }, } local function fontFlagValues() @@ -82,45 +83,45 @@ end -- PAGE: GENERAL -------------------------------------------------------------------------------- local function buildGeneral(content, stack) - stack:Add(UI:Header(content, "Panel")) + stack:Add(UI:Header(content, L["Panel"])) stack:Add(UI:Check(content, { - label = "Enable StatPanel", path = "enabled", - tooltip = "Master switch. Turning this off hides the panel entirely.", + label = L["Enable StatPanel"], path = "enabled", + tooltip = L["Master switch. Turning this off hides the panel entirely."], })) stack:Add(UI:Check(content, { - label = "Lock position", path = "panel.locked", - tooltip = "Stops the panel from being dragged.", + label = L["Lock position"], path = "panel.locked", + tooltip = L["Stops the panel from being dragged."], })) stack:Add(UI:Check(content, { - label = "Keep on screen", path = "panel.clamp", - tooltip = "Prevents dragging the panel off the edge of the screen.", + label = L["Keep on screen"], path = "panel.clamp", + tooltip = L["Prevents dragging the panel off the edge of the screen."], })) stack:Add(UI:Slider(content, { - label = "Scale", path = "panel.scale", + label = L["Scale"], path = "panel.scale", min = 0.4, max = 3.0, step = 0.05, decimals = 2, })) stack:Add(UI:Slider(content, { - label = "Opacity", path = "panel.alpha", + label = L["Opacity"], path = "panel.alpha", min = 0, max = 1, step = 0.01, decimals = 2, })) stack:Add(UI:Dropdown(content, { - label = "Frame layer", path = "panel.strata", values = strataValues, - tooltip = "Which layer the panel draws on. Raise it if another addon covers the panel.", + label = L["Frame layer"], path = "panel.strata", values = strataValues, + tooltip = L["Which layer the panel draws on. Raise it if another addon covers the panel."], })) stack:Add(UI:Slider(content, { - label = "Update interval (seconds)", path = "updateInterval", + label = L["Update interval (seconds)"], path = "updateInterval", min = 0.02, max = 1.0, step = 0.02, decimals = 2, - tooltip = "How often values refresh. Higher values use less CPU.", + tooltip = L["How often values refresh. Higher values use less CPU."], })) stack:Add(UI:Dropdown(content, { - label = "Stat values show", path = "valueSource", values = SOURCE_VALUES, - tooltip = "Total effect matches the character sheet. Bonus from rating shows only what your gear's rating contributes.", + label = L["Stat values show"], path = "valueSource", values = SOURCE_VALUES, + tooltip = L["Total effect matches the character sheet. Bonus from rating shows only what your gear's rating contributes."], })) stack:Gap(10) - stack:Add(UI:Header(content, "Visibility")) + stack:Add(UI:Header(content, L["Visibility"])) -- Two rules covering opposite states hide the panel everywhere, which reads -- as the addon being broken rather than as a setting. Ticking one clears @@ -132,60 +133,60 @@ local function buildGeneral(content, stack) end stack:Add(UI:Check(content, { - label = "Hide during combat", path = "panel.hideInCombat", + label = L["Hide during combat"], path = "panel.hideInCombat", onChange = clears("panel.onlyInCombat"), refreshAll = true, - tooltip = "Turning this on clears 'Show only during combat'.", + tooltip = L["Turning this on clears 'Show only during combat'."], })) stack:Add(UI:Check(content, { - label = "Show only during combat", path = "panel.onlyInCombat", + label = L["Show only during combat"], path = "panel.onlyInCombat", onChange = clears("panel.hideInCombat"), refreshAll = true, - tooltip = "Turning this on clears 'Hide during combat'.", + tooltip = L["Turning this on clears 'Hide during combat'."], })) - stack:Add(UI:Check(content, { label = "Hide while dead", path = "panel.hideWhenDead" })) - stack:Add(UI:Check(content, { label = "Hide in vehicles", path = "panel.hideInVehicle" })) - stack:Add(UI:Check(content, { label = "Hide in pet battles", path = "panel.hideInPetBattle" })) + stack:Add(UI:Check(content, { label = L["Hide while dead"], path = "panel.hideWhenDead" })) + stack:Add(UI:Check(content, { label = L["Hide in vehicles"], path = "panel.hideInVehicle" })) + stack:Add(UI:Check(content, { label = L["Hide in pet battles"], path = "panel.hideInPetBattle" })) stack:Add(UI:Check(content, { - label = "Hide inside instances", path = "panel.hideInInstance", + label = L["Hide inside instances"], path = "panel.hideInInstance", onChange = clears("panel.hideOutOfInstance"), refreshAll = true, - tooltip = "Turning this on clears 'Hide outside instances'.", + tooltip = L["Turning this on clears 'Hide outside instances'."], })) stack:Add(UI:Check(content, { - label = "Hide outside instances", path = "panel.hideOutOfInstance", + label = L["Hide outside instances"], path = "panel.hideOutOfInstance", onChange = clears("panel.hideInInstance"), refreshAll = true, - tooltip = "Turning this on clears 'Hide inside instances'.", + tooltip = L["Turning this on clears 'Hide inside instances'."], })) stack:Gap(10) - stack:Add(UI:Header(content, "Mouseover fade")) + stack:Add(UI:Header(content, L["Mouseover fade"])) stack:Add(UI:Check(content, { - label = "Only show on mouseover", path = "panel.mouseoverOnly", - tooltip = "Fades the panel out until you hover over it.", + label = L["Only show on mouseover"], path = "panel.mouseoverOnly", + tooltip = L["Fades the panel out until you hover over it."], })) stack:Add(UI:Slider(content, { - label = "Faded opacity", path = "panel.fadeAlpha", + label = L["Faded opacity"], path = "panel.fadeAlpha", min = 0, max = 1, step = 0.01, decimals = 2, })) stack:Add(UI:Slider(content, { - label = "Fade duration (seconds)", path = "panel.fadeDuration", + label = L["Fade duration (seconds)"], path = "panel.fadeDuration", min = 0, max = 2, step = 0.05, decimals = 2, })) - stack:Add(UI:Check(content, { label = "Show tooltips on hover", path = "panel.tooltips" })) + stack:Add(UI:Check(content, { label = L["Show tooltips on hover"], path = "panel.tooltips" })) stack:Gap(10) - stack:Add(UI:Header(content, "Minimap and options")) + stack:Add(UI:Header(content, L["Minimap and options"])) -- These two live in the account-wide store rather than the profile, so -- switching looks doesn't move your minimap button. stack:Add(UI:Check(content, { - label = "Show the minimap button", - tooltip = "Left-click opens these options, right-click opens the quick menu. Drag it around the minimap edge.", + label = L["Show the minimap button"], + tooltip = L["Left-click opens these options, right-click opens the quick menu. Drag it around the minimap edge."], get = function() return not SPAddonDB.global.minimap.hide end, set = function(value) SP.Broker:SetHidden(not value) end, })) stack:Add(UI:Check(content, { - label = "Show a live preview while configuring", - tooltip = "Docks the real panel beside this window so you can see changes as you make them.", + label = L["Show a live preview while configuring"], + tooltip = L["Docks the real panel beside this window so you can see changes as you make them."], get = function() return SPAddonDB.global.livePreview end, set = function(value) SPAddonDB.global.livePreview = value @@ -195,11 +196,11 @@ local function buildGeneral(content, stack) stack:Gap(10) stack:Add(UI:ButtonRow(content, { - { text = "Reset position", width = 130, onClick = function() + { text = L["Reset position"], width = 130, onClick = function() SP.db.panel.pos = Config:DeepCopy(Config.DEFAULTS.panel.pos) SP:Refresh() end }, - { text = "Reset peak speed", width = 130, onClick = function() + { text = L["Reset peak speed"], width = 130, onClick = function() SP.ResetPeakSpeed() end }, })) @@ -209,90 +210,90 @@ end -- PAGE: PANEL -------------------------------------------------------------------------------- local function buildPanel(content, stack) - stack:Add(UI:Header(content, "Size")) + stack:Add(UI:Header(content, L["Size"])) stack:Add(UI:Check(content, { - label = "Auto-size width to content", path = "panel.autoWidth", - tooltip = "Grows and shrinks the panel to fit the widest row.", + label = L["Auto-size width to content"], path = "panel.autoWidth", + tooltip = L["Grows and shrinks the panel to fit the widest row."], })) stack:Add(UI:Slider(content, { - label = "Width", path = "panel.width", min = 80, max = 600, step = 1, + label = L["Width"], path = "panel.width", min = 80, max = 600, step = 1, })) stack:Add(UI:Slider(content, { - label = "Minimum width (auto-size)", path = "panel.minWidth", min = 60, max = 400, step = 1, + label = L["Minimum width (auto-size)"], path = "panel.minWidth", min = 60, max = 400, step = 1, })) stack:Add(UI:Slider(content, { - label = "Side padding", path = "panel.paddingX", min = 0, max = 40, step = 1, + label = L["Side padding"], path = "panel.paddingX", min = 0, max = 40, step = 1, })) stack:Add(UI:Slider(content, { - label = "Top padding", path = "panel.paddingTop", min = 0, max = 40, step = 1, + label = L["Top padding"], path = "panel.paddingTop", min = 0, max = 40, step = 1, })) stack:Add(UI:Slider(content, { - label = "Bottom padding", path = "panel.paddingBottom", min = 0, max = 40, step = 1, + label = L["Bottom padding"], path = "panel.paddingBottom", min = 0, max = 40, step = 1, })) stack:Add(UI:Slider(content, { - label = "Gap between sections", path = "panel.sectionGap", min = 0, max = 40, step = 1, + label = L["Gap between sections"], path = "panel.sectionGap", min = 0, max = 40, step = 1, })) stack:Add(UI:Slider(content, { - label = "Section header spacing", path = "panel.headerStep", min = 0, max = 40, step = 1, - tooltip = "Set to 0 to remove section headers entirely.", + label = L["Section header spacing"], path = "panel.headerStep", min = 0, max = 40, step = 1, + tooltip = L["Set to 0 to remove section headers entirely."], })) stack:Gap(10) - stack:Add(UI:Header(content, "Background")) + stack:Add(UI:Header(content, L["Background"])) stack:Add(UI:Dropdown(content, { - label = "Background texture", path = "panel.bgTexture", + label = L["Background texture"], path = "panel.bgTexture", values = mediaValues("background"), preview = "background", })) - stack:Add(UI:Color(content, { label = "Background color and transparency", path = "panel.bgColor" })) - stack:Add(UI:Check(content, { label = "Tile the background", path = "panel.bgTile" })) + stack:Add(UI:Color(content, { label = L["Background color and transparency"], path = "panel.bgColor" })) + stack:Add(UI:Check(content, { label = L["Tile the background"], path = "panel.bgTile" })) stack:Add(UI:Slider(content, { - label = "Tile size", path = "panel.bgTileSize", min = 8, max = 256, step = 1, + label = L["Tile size"], path = "panel.bgTileSize", min = 8, max = 256, step = 1, })) stack:Gap(10) - stack:Add(UI:Header(content, "Border")) + stack:Add(UI:Header(content, L["Border"])) stack:Add(UI:Dropdown(content, { - label = "Border style", path = "panel.borderStyle", values = mediaValues("border"), + label = L["Border style"], path = "panel.borderStyle", values = mediaValues("border"), })) - stack:Add(UI:Color(content, { label = "Border color and transparency", path = "panel.borderColor" })) + stack:Add(UI:Color(content, { label = L["Border color and transparency"], path = "panel.borderColor" })) stack:Add(UI:Slider(content, { - label = "Border thickness", path = "panel.borderSize", min = 1, max = 32, step = 1, - tooltip = "Only affects pixel-style borders; textured borders use their own size.", + label = L["Border thickness"], path = "panel.borderSize", min = 1, max = 32, step = 1, + tooltip = L["Only affects pixel-style borders; textured borders use their own size."], })) stack:Add(UI:Slider(content, { - label = "Border inset", path = "panel.borderInset", min = -16, max = 16, step = 1, + label = L["Border inset"], path = "panel.borderInset", min = -16, max = 16, step = 1, })) stack:Gap(10) - stack:Add(UI:Header(content, "Title")) + stack:Add(UI:Header(content, L["Title"])) - stack:Add(UI:Check(content, { label = "Show title", path = "panel.showTitle" })) + stack:Add(UI:Check(content, { label = L["Show title"], path = "panel.showTitle" })) stack:Add(UI:Dropdown(content, { - label = "Title shows", path = "panel.titleMode", values = TITLE_MODE_VALUES, + label = L["Title shows"], path = "panel.titleMode", values = TITLE_MODE_VALUES, })) stack:Add(UI:Dropdown(content, { - label = "Title alignment", path = "panel.titleAlign", values = ALIGN_VALUES, + label = L["Title alignment"], path = "panel.titleAlign", values = ALIGN_VALUES, })) stack:Add(UI:EditBox(content, { - label = "Item level format", path = "panel.titleFormat", - tooltip = "Tokens: $equipped, $overall, $name, $spec, $class, $level", + label = L["Item level format"], path = "panel.titleFormat", + tooltip = L["Tokens: $equipped, $overall, $name, $spec, $class, $level"], })) - stack:Add(UI:Note(content, "Tokens: $equipped $overall $name $spec $class $level")) + stack:Add(UI:Note(content, L["Tokens: $equipped $overall $name $spec $class $level"])) stack:Add(UI:Slider(content, { - label = "Item level decimals", path = "panel.titleDecimals", min = 0, max = 2, step = 1, + label = L["Item level decimals"], path = "panel.titleDecimals", min = 0, max = 2, step = 1, })) - stack:Add(UI:EditBox(content, { label = "Custom title text", path = "panel.titleText" })) + stack:Add(UI:EditBox(content, { label = L["Custom title text"], path = "panel.titleText" })) stack:Gap(10) - stack:Add(UI:Header(content, "Divider")) + stack:Add(UI:Header(content, L["Divider"])) - stack:Add(UI:Check(content, { label = "Show divider under title", path = "panel.showDivider" })) - stack:Add(UI:Color(content, { label = "Divider color", path = "panel.dividerColor" })) + stack:Add(UI:Check(content, { label = L["Show divider under title"], path = "panel.showDivider" })) + stack:Add(UI:Color(content, { label = L["Divider color"], path = "panel.dividerColor" })) stack:Add(UI:Slider(content, { - label = "Divider thickness", path = "panel.dividerThickness", min = 1, max = 8, step = 1, + label = L["Divider thickness"], path = "panel.dividerThickness", min = 1, max = 8, step = 1, })) end @@ -300,134 +301,134 @@ end -- PAGE: ROWS & BARS -------------------------------------------------------------------------------- local function buildBars(content, stack) - stack:Add(UI:Header(content, "Row style")) + stack:Add(UI:Header(content, L["Row style"])) stack:Add(UI:Dropdown(content, { - label = "Draw rows as", path = "bars.style", values = ROW_STYLE_VALUES, - tooltip = "Bars draw a status bar per stat. Text only draws a single colored line per stat.", + label = L["Draw rows as"], path = "bars.style", values = ROW_STYLE_VALUES, + tooltip = L["Bars draw a status bar per stat. Text only draws a single colored line per stat."], })) stack:Add(UI:Dropdown(content, { - label = "Text alignment (text style)", path = "bars.align", values = ALIGN_VALUES, + label = L["Text alignment (text style)"], path = "bars.align", values = ALIGN_VALUES, })) stack:Add(UI:EditBox(content, { - label = "Label/value separator (text style)", path = "bars.textSep", - tooltip = "Placed between the stat name and its value, e.g. ': '", + label = L["Label/value separator (text style)"], path = "bars.textSep", + tooltip = L["Placed between the stat name and its value, e.g. ': '"], })) stack:Add(UI:Slider(content, { - label = "Line height (text style)", path = "bars.textHeight", min = 6, max = 40, step = 1, + label = L["Line height (text style)"], path = "bars.textHeight", min = 6, max = 40, step = 1, })) stack:Gap(10) - stack:Add(UI:Header(content, "Bar appearance")) + stack:Add(UI:Header(content, L["Bar appearance"])) stack:Add(UI:Dropdown(content, { - label = "Bar texture", path = "bars.texture", + label = L["Bar texture"], path = "bars.texture", values = mediaValues("statusbar"), preview = "statusbar", })) - stack:Add(UI:Slider(content, { label = "Bar height", path = "bars.height", min = 4, max = 48, step = 1 })) - stack:Add(UI:Slider(content, { label = "Space between bars", path = "bars.spacing", min = 0, max = 30, step = 1 })) - stack:Add(UI:Slider(content, { label = "Horizontal inset", path = "bars.inset", min = 0, max = 40, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Bar height"], path = "bars.height", min = 4, max = 48, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Space between bars"], path = "bars.spacing", min = 0, max = 30, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Horizontal inset"], path = "bars.inset", min = 0, max = 40, step = 1 })) stack:Add(UI:Slider(content, { - label = "Bar opacity", path = "bars.alpha", min = 0, max = 1, step = 0.01, decimals = 2, + label = L["Bar opacity"], path = "bars.alpha", min = 0, max = 1, step = 0.01, decimals = 2, })) - stack:Add(UI:Check(content, { label = "Fill from the right", path = "bars.reverseFill" })) + stack:Add(UI:Check(content, { label = L["Fill from the right"], path = "bars.reverseFill" })) stack:Gap(10) - stack:Add(UI:Header(content, "Bar colors")) + stack:Add(UI:Header(content, L["Bar colors"])) stack:Add(UI:Dropdown(content, { - label = "Color mode", path = "bars.colorMode", values = COLOR_MODE_VALUES, - tooltip = "Per-stat colors are set on the Stats page.", + label = L["Color mode"], path = "bars.colorMode", values = COLOR_MODE_VALUES, + tooltip = L["Per-stat colors are set on the Stats page."], })) - stack:Add(UI:Color(content, { label = "Single color", path = "bars.singleColor" })) - stack:Add(UI:Color(content, { label = "Gradient: low value", path = "bars.gradientLow" })) - stack:Add(UI:Color(content, { label = "Gradient: high value", path = "bars.gradientHigh" })) + stack:Add(UI:Color(content, { label = L["Single color"], path = "bars.singleColor" })) + stack:Add(UI:Color(content, { label = L["Gradient: low value"], path = "bars.gradientLow" })) + stack:Add(UI:Color(content, { label = L["Gradient: high value"], path = "bars.gradientHigh" })) stack:Gap(10) - stack:Add(UI:Header(content, "Bar background")) + stack:Add(UI:Header(content, L["Bar background"])) stack:Add(UI:Dropdown(content, { - label = "Track texture", path = "bars.trackTexture", + label = L["Track texture"], path = "bars.trackTexture", values = mediaValues("background"), preview = "background", })) - stack:Add(UI:Color(content, { label = "Track color and transparency", path = "bars.trackColor" })) + stack:Add(UI:Color(content, { label = L["Track color and transparency"], path = "bars.trackColor" })) stack:Add(UI:Check(content, { - label = "Tint track with the stat color", path = "bars.trackUseStatColor", + label = L["Tint track with the stat color"], path = "bars.trackUseStatColor", })) stack:Add(UI:Slider(content, { - label = "Track tint opacity", path = "bars.trackStatAlpha", + label = L["Track tint opacity"], path = "bars.trackStatAlpha", min = 0, max = 1, step = 0.01, decimals = 2, })) stack:Gap(10) - stack:Add(UI:Header(content, "Bar border")) + stack:Add(UI:Header(content, L["Bar border"])) stack:Add(UI:Dropdown(content, { - label = "Border style", path = "bars.borderStyle", values = mediaValues("border"), + label = L["Border style"], path = "bars.borderStyle", values = mediaValues("border"), })) - stack:Add(UI:Color(content, { label = "Border color", path = "bars.borderColor" })) - stack:Add(UI:Slider(content, { label = "Border thickness", path = "bars.borderSize", min = 1, max = 16, step = 1 })) + stack:Add(UI:Color(content, { label = L["Border color"], path = "bars.borderColor" })) + stack:Add(UI:Slider(content, { label = L["Border thickness"], path = "bars.borderSize", min = 1, max = 16, step = 1 })) stack:Gap(10) - stack:Add(UI:Header(content, "Motion")) + stack:Add(UI:Header(content, L["Motion"])) stack:Add(UI:Check(content, { - label = "Animate value changes", path = "bars.smooth", - tooltip = "Eases bars toward new values instead of snapping.", + label = L["Animate value changes"], path = "bars.smooth", + tooltip = L["Eases bars toward new values instead of snapping."], })) stack:Add(UI:Slider(content, { - label = "Animation speed", path = "bars.smoothSpeed", min = 1, max = 30, step = 0.5, decimals = 1, + label = L["Animation speed"], path = "bars.smoothSpeed", min = 1, max = 30, step = 0.5, decimals = 1, })) - stack:Add(UI:Check(content, { label = "Show a spark at the fill edge", path = "bars.spark" })) - stack:Add(UI:Color(content, { label = "Spark color", path = "bars.sparkColor" })) + stack:Add(UI:Check(content, { label = L["Show a spark at the fill edge"], path = "bars.spark" })) + stack:Add(UI:Color(content, { label = L["Spark color"], path = "bars.sparkColor" })) stack:Gap(10) - stack:Add(UI:Header(content, "Row text")) + stack:Add(UI:Header(content, L["Row text"])) - stack:Add(UI:Check(content, { label = "Show stat names", path = "bars.showLabel" })) - stack:Add(UI:Check(content, { label = "Show values", path = "bars.showValue" })) - stack:Add(UI:Check(content, { label = "Color names with the stat color", path = "bars.labelUseStatColor" })) - stack:Add(UI:Check(content, { label = "Color values with the stat color", path = "bars.valueUseStatColor" })) + stack:Add(UI:Check(content, { label = L["Show stat names"], path = "bars.showLabel" })) + stack:Add(UI:Check(content, { label = L["Show values"], path = "bars.showValue" })) + stack:Add(UI:Check(content, { label = L["Color names with the stat color"], path = "bars.labelUseStatColor" })) + stack:Add(UI:Check(content, { label = L["Color values with the stat color"], path = "bars.valueUseStatColor" })) stack:Add(UI:Check(content, { - label = "Number prioritized stats", path = "bars.showRank", - tooltip = "Prefixes stats in a priority-ordered section with 1, 2, 3...", + label = L["Number prioritized stats"], path = "bars.showRank", + tooltip = L["Prefixes stats in a priority-ordered section with 1, 2, 3..."], })) - stack:Add(UI:EditBox(content, { label = "Numbering format", path = "bars.rankFormat" })) - stack:Add(UI:Slider(content, { label = "Name offset", path = "bars.labelX", min = -40, max = 40, step = 1 })) - stack:Add(UI:Slider(content, { label = "Value offset", path = "bars.valueX", min = -40, max = 40, step = 1 })) + stack:Add(UI:EditBox(content, { label = L["Numbering format"], path = "bars.rankFormat" })) + stack:Add(UI:Slider(content, { label = L["Name offset"], path = "bars.labelX", min = -40, max = 40, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Value offset"], path = "bars.valueX", min = -40, max = 40, step = 1 })) end -------------------------------------------------------------------------------- -- PAGE: FONTS -------------------------------------------------------------------------------- local FONT_ELEMENTS = { - { name = "Title", value = "title" }, - { name = "Section header", value = "header" }, - { name = "Stat name", value = "label" }, - { name = "Stat value", value = "value" }, - { name = "Priority line", value = "priority" }, - { name = "Footer", value = "footer" }, + { name = L["Title"], value = "title" }, + { name = L["Section header"], value = "header" }, + { name = L["Stat name"], value = "label" }, + { name = L["Stat value"], value = "value" }, + { name = L["Priority line"], value = "priority" }, + { name = L["Footer"], value = "footer" }, } local function buildFonts(content, stack) local selected = "title" - stack:Add(UI:Header(content, "Font")) + stack:Add(UI:Header(content, L["Font"])) stack:Add(UI:Dropdown(content, { - label = "Font face (all text)", path = "font.face", + label = L["Font face (all text)"], path = "font.face", values = mediaValues("font"), preview = "font", })) - stack:Add(UI:Check(content, { label = "Drop shadow", path = "font.shadow" })) - stack:Add(UI:Color(content, { label = "Shadow color", path = "font.shadowColor" })) - stack:Add(UI:Slider(content, { label = "Shadow X offset", path = "font.shadowX", min = -5, max = 5, step = 1 })) - stack:Add(UI:Slider(content, { label = "Shadow Y offset", path = "font.shadowY", min = -5, max = 5, step = 1 })) + stack:Add(UI:Check(content, { label = L["Drop shadow"], path = "font.shadow" })) + stack:Add(UI:Color(content, { label = L["Shadow color"], path = "font.shadowColor" })) + stack:Add(UI:Slider(content, { label = L["Shadow X offset"], path = "font.shadowX", min = -5, max = 5, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Shadow Y offset"], path = "font.shadowY", min = -5, max = 5, step = 1 })) stack:Gap(10) - stack:Add(UI:Header(content, "Per-element size and color")) + stack:Add(UI:Header(content, L["Per-element size and color"])) stack:Add(UI:Dropdown(content, { - label = "Editing", values = FONT_ELEMENTS, + label = L["Editing"], values = FONT_ELEMENTS, get = function() return selected end, set = function(value) selected = value end, refreshAll = true, @@ -438,17 +439,17 @@ local function buildFonts(content, stack) end stack:Add(UI:Slider(content, { - label = "Size", min = 6, max = 40, step = 1, + label = L["Size"], min = 6, max = 40, step = 1, get = function() return element().size end, set = function(value) element().size = value; SP:Refresh() end, })) stack:Add(UI:Dropdown(content, { - label = "Outline", values = fontFlagValues, + label = L["Outline"], values = fontFlagValues, get = function() return element().flags end, set = function(value) element().flags = value; SP:Refresh() end, })) stack:Add(UI:Color(content, { - label = "Color", + label = L["Color"], get = function() local c = element().color or { 1, 1, 1, 1 } return c[1], c[2], c[3], c[4] or 1 @@ -456,7 +457,7 @@ local function buildFonts(content, stack) set = function(r, g, b, a) element().color = { r, g, b, a } end, })) - stack:Add(UI:Note(content, "Stat name and value colors are overridden when 'Color with the stat color' is enabled on the Rows & Bars page.")) + stack:Add(UI:Note(content, L["Stat name and value colors are overridden when 'Color with the stat color' is enabled on the Rows & Bars page."])) end -------------------------------------------------------------------------------- @@ -469,22 +470,22 @@ local function buildStats(content, stack) return SP.db.stats[selected] or {} end - stack:Add(UI:Header(content, "Per-stat settings")) + stack:Add(UI:Header(content, L["Per-stat settings"])) stack:Add(UI:Dropdown(content, { - label = "Editing stat", values = statValues, + label = L["Editing stat"], values = statValues, get = function() return selected end, set = function(value) selected = value end, refreshAll = true, })) stack:Add(UI:Check(content, { - label = "Show this stat", + label = L["Show this stat"], get = function() return cfg().enabled end, set = function(value) cfg().enabled = value; SP:Refresh() end, })) stack:Add(UI:Color(content, { - label = "Stat color", + label = L["Stat color"], get = function() local c = cfg().color or { 1, 1, 1, 1 } return c[1], c[2], c[3], c[4] or 1 @@ -492,12 +493,12 @@ local function buildStats(content, stack) set = function(r, g, b, a) cfg().color = { r, g, b, a } end, })) stack:Add(UI:Check(content, { - label = "Use class color for this stat", + label = L["Use class color for this stat"], get = function() return cfg().useClassColor end, set = function(value) cfg().useClassColor = value; SP:Refresh() end, })) stack:Add(UI:EditBox(content, { - label = "Display name (blank for default)", + label = L["Display name (blank for default)"], get = function() return cfg().label or "" end, set = function(value) value = value:trim() @@ -506,33 +507,33 @@ local function buildStats(content, stack) end, })) stack:Add(UI:EditBox(content, { - label = "Value format", + label = L["Value format"], get = function() return cfg().format or "" end, set = function(value) cfg().format = value; SP:Refresh() end, })) - stack:Add(UI:Note(content, "Tokens: $value $rating $valuec $ratingc $max $label $peak $yards\nExample: '$rating - $value%' shows '285 - 10.65%'.")) + stack:Add(UI:Note(content, L["Tokens: $value $rating $valuec $ratingc $max $label $peak $yards\nExample: '$rating - $value%' shows '285 - 10.65%'."])) stack:Add(UI:Slider(content, { - label = "Decimal places", min = 0, max = 4, step = 1, + label = L["Decimal places"], min = 0, max = 4, step = 1, get = function() return cfg().decimals end, set = function(value) cfg().decimals = value; SP:Refresh() end, })) stack:Gap(10) - stack:Add(UI:Header(content, "Bar scale")) + stack:Add(UI:Header(content, L["Bar scale"])) stack:Add(UI:Dropdown(content, { - label = "Bar fill", values = FILL_VALUES, + label = L["Bar fill"], values = FILL_VALUES, get = function() return cfg().fill end, set = function(value) cfg().fill = value; SP:Refresh() end, })) stack:Add(UI:Slider(content, { - label = "Value at a full bar", min = 1, max = 20000, step = 1, + label = L["Value at a full bar"], min = 1, max = 20000, step = 1, get = function() return cfg().max end, set = function(value) cfg().max = value; SP:Refresh() end, })) stack:Add(UI:Check(content, { - label = "Grow the scale automatically", - tooltip = "Raises the full-bar value whenever the stat exceeds it. Useful for Speed, which has no ceiling while skyriding.", + label = L["Grow the scale automatically"], + tooltip = L["Raises the full-bar value whenever the stat exceeds it. Useful for Speed, which has no ceiling while skyriding."], get = function() return cfg().autoMax end, set = function(value) cfg().autoMax = value @@ -543,7 +544,7 @@ local function buildStats(content, stack) stack:Gap(10) stack:Add(UI:ButtonRow(content, { - { text = "Reset all stats", width = 140, onClick = function() + { text = L["Reset all stats"], width = 140, onClick = function() Config:Reset("stats") UI:RefreshAll() end }, @@ -568,43 +569,43 @@ local function buildSections(content, stack) return out end - stack:Add(UI:Header(content, "Sections")) - stack:Add(UI:Note(content, "Sections are drawn top to bottom in this order. Each one holds any set of stats you like.")) + stack:Add(UI:Header(content, L["Sections"])) + stack:Add(UI:Note(content, L["Sections are drawn top to bottom in this order. Each one holds any set of stats you like."])) stack:Add(UI:Dropdown(content, { - label = "Editing section", values = sectionValues, + label = L["Editing section"], values = sectionValues, get = function() return selectedIndex end, set = function(value) selectedIndex = value end, refreshAll = true, })) stack:Add(UI:EditBox(content, { - label = "Section title", + label = L["Section title"], get = function() return section().title or "" end, set = function(value) section().title = value; SP:Refresh() end, })) stack:Add(UI:Check(content, { - label = "Show this section", + label = L["Show this section"], get = function() return section().enabled end, set = function(value) section().enabled = value; SP:Refresh() end, })) stack:Add(UI:Check(content, { - label = "Show the section header", + label = L["Show the section header"], get = function() return section().showHeader ~= false end, set = function(value) section().showHeader = value; SP:Refresh() end, })) stack:Add(UI:Check(content, { - label = "Order by spec stat priority", - tooltip = "Re-sorts this section's stats to match your specialization's priority.", + label = L["Order by spec stat priority"], + tooltip = L["Re-sorts this section's stats to match your specialization's priority."], get = function() return section().prioritized end, set = function(value) section().prioritized = value; SP:Refresh() end, })) stack:Add(UI:Dropdown(content, { - label = "Header alignment", path = "panel.headerAlign", values = ALIGN_VALUES, + label = L["Header alignment"], path = "panel.headerAlign", values = ALIGN_VALUES, })) stack:Add(UI:ButtonRow(content, { - { text = "Move section up", width = 140, onClick = function() + { text = L["Move section up"], width = 140, onClick = function() if selectedIndex > 1 then local sections = SP.db.sections sections[selectedIndex], sections[selectedIndex - 1] = sections[selectedIndex - 1], sections[selectedIndex] @@ -613,7 +614,7 @@ local function buildSections(content, stack) UI:RefreshAll() end end }, - { text = "Move section down", width = 140, onClick = function() + { text = L["Move section down"], width = 140, onClick = function() local sections = SP.db.sections if selectedIndex < #sections then sections[selectedIndex], sections[selectedIndex + 1] = sections[selectedIndex + 1], sections[selectedIndex] @@ -625,7 +626,7 @@ local function buildSections(content, stack) })) stack:Gap(10) - stack:Add(UI:Header(content, "Stats in this section")) + stack:Add(UI:Header(content, L["Stats in this section"])) -- The membership list grows and shrinks as stats are added or removed, so -- it is placed LAST on the page - anything stacked below it would be @@ -659,9 +660,9 @@ local function buildSections(content, stack) row.label:SetFont([[Fonts\FRIZQT__.TTF]], 12, "") row.label:SetPoint("LEFT", row.check, "RIGHT", 4, 0) - row.up = UI:Button(row, { text = "Up", width = 44, height = 20 }) - row.down = UI:Button(row, { text = "Down", width = 54, height = 20 }) - row.remove = UI:Button(row, { text = "Remove", width = 66, height = 20 }) + row.up = UI:Button(row, { text = L["Up"], width = 44, height = 20 }) + row.down = UI:Button(row, { text = L["Down"], width = 54, height = 20 }) + row.remove = UI:Button(row, { text = L["Remove"], width = 66, height = 20 }) row.remove:SetPoint("RIGHT", row, "RIGHT", 0, 0) row.down:SetPoint("RIGHT", row.remove, "LEFT", -4, 0) @@ -717,7 +718,7 @@ local function buildSections(content, stack) -- Only offer stats that aren't already in this section. addDropdown = UI:Dropdown(content, { - label = "Add a stat to this section", + label = L["Add a stat to this section"], values = function() local present = {} for _, statName in ipairs(section().stats or {}) do present[statName] = true end @@ -729,7 +730,7 @@ local function buildSections(content, stack) out[#out + 1] = { name = def and def.name or statName, value = statName } end end - if #out == 0 then out[1] = { name = "(every stat is already here)", value = "" } end + if #out == 0 then out[1] = { name = L["(every stat is already here)"], value = "" } end return out end, get = function() return "" end, @@ -746,7 +747,7 @@ local function buildSections(content, stack) stack:Add(addDropdown) stack:Add(UI:ButtonRow(content, { - { text = "Reset sections", width = 140, onClick = function() + { text = L["Reset sections"], width = 140, onClick = function() Config:Reset("sections") selectedIndex = 1 refreshList() @@ -770,38 +771,38 @@ end -- PAGE: FOOTER -------------------------------------------------------------------------------- local function buildFooter(content, stack) - stack:Add(UI:Header(content, "Footer line")) + stack:Add(UI:Header(content, L["Footer line"])) - stack:Add(UI:Check(content, { label = "Show the footer", path = "footer.enabled" })) - stack:Add(UI:Check(content, { label = "Frames per second", path = "footer.showFPS" })) - stack:Add(UI:Check(content, { label = "Home latency", path = "footer.showHomeLatency" })) - stack:Add(UI:Check(content, { label = "World latency", path = "footer.showWorldLatency" })) - stack:Add(UI:Check(content, { label = "Addon memory use", path = "footer.showMemory" })) - stack:Add(UI:EditBox(content, { label = "Separator between entries", path = "footer.separator" })) + stack:Add(UI:Check(content, { label = L["Show the footer"], path = "footer.enabled" })) + stack:Add(UI:Check(content, { label = L["Frames per second"], path = "footer.showFPS" })) + stack:Add(UI:Check(content, { label = L["Home latency"], path = "footer.showHomeLatency" })) + stack:Add(UI:Check(content, { label = L["World latency"], path = "footer.showWorldLatency" })) + stack:Add(UI:Check(content, { label = L["Addon memory use"], path = "footer.showMemory" })) + stack:Add(UI:EditBox(content, { label = L["Separator between entries"], path = "footer.separator" })) stack:Gap(10) - stack:Add(UI:Header(content, "Formats")) + stack:Add(UI:Header(content, L["Formats"])) - stack:Add(UI:EditBox(content, { label = "FPS format", path = "footer.fpsFormat" })) - stack:Add(UI:EditBox(content, { label = "Home latency format", path = "footer.homeFormat" })) - stack:Add(UI:EditBox(content, { label = "World latency format", path = "footer.worldFormat" })) - stack:Add(UI:EditBox(content, { label = "Memory format", path = "footer.memoryFormat" })) - stack:Add(UI:Note(content, "These use standard number formats: %d for a whole number, %.1f for one decimal.")) + stack:Add(UI:EditBox(content, { label = L["FPS format"], path = "footer.fpsFormat" })) + stack:Add(UI:EditBox(content, { label = L["Home latency format"], path = "footer.homeFormat" })) + stack:Add(UI:EditBox(content, { label = L["World latency format"], path = "footer.worldFormat" })) + stack:Add(UI:EditBox(content, { label = L["Memory format"], path = "footer.memoryFormat" })) + stack:Add(UI:Note(content, L["These use standard number formats: %d for a whole number, %.1f for one decimal."])) stack:Gap(10) - stack:Add(UI:Header(content, "Performance coloring")) + stack:Add(UI:Header(content, L["Performance coloring"])) stack:Add(UI:Check(content, { - label = "Color by performance", path = "footer.colorize", - tooltip = "Turns FPS and latency green, yellow or red depending on the thresholds below.", - })) - stack:Add(UI:Color(content, { label = "Good", path = "footer.goodColor" })) - stack:Add(UI:Color(content, { label = "Fair", path = "footer.okColor" })) - stack:Add(UI:Color(content, { label = "Poor", path = "footer.badColor" })) - stack:Add(UI:Slider(content, { label = "FPS considered good", path = "footer.fpsGood", min = 20, max = 240, step = 1 })) - stack:Add(UI:Slider(content, { label = "FPS considered poor", path = "footer.fpsBad", min = 5, max = 120, step = 1 })) - stack:Add(UI:Slider(content, { label = "Latency considered good (ms)", path = "footer.msGood", min = 10, max = 400, step = 5 })) - stack:Add(UI:Slider(content, { label = "Latency considered poor (ms)", path = "footer.msBad", min = 50, max = 1000, step = 5 })) + label = L["Color by performance"], path = "footer.colorize", + tooltip = L["Turns FPS and latency green, yellow or red depending on the thresholds below."], + })) + stack:Add(UI:Color(content, { label = L["Good"], path = "footer.goodColor" })) + stack:Add(UI:Color(content, { label = L["Fair"], path = "footer.okColor" })) + stack:Add(UI:Color(content, { label = L["Poor"], path = "footer.badColor" })) + stack:Add(UI:Slider(content, { label = L["FPS considered good"], path = "footer.fpsGood", min = 20, max = 240, step = 1 })) + stack:Add(UI:Slider(content, { label = L["FPS considered poor"], path = "footer.fpsBad", min = 5, max = 120, step = 1 })) + stack:Add(UI:Slider(content, { label = L["Latency considered good (ms)"], path = "footer.msGood", min = 10, max = 400, step = 5 })) + stack:Add(UI:Slider(content, { label = L["Latency considered poor (ms)"], path = "footer.msBad", min = 50, max = 1000, step = 5 })) end -------------------------------------------------------------------------------- @@ -812,18 +813,18 @@ local SECONDARY = { "Crit", "Haste", "Mastery", "Versatility" } local function buildPriority(content, stack) local weightText = "" -- the pasted weight/order box - stack:Add(UI:Header(content, "Priority line")) + stack:Add(UI:Header(content, L["Priority line"])) - stack:Add(UI:Check(content, { label = "Show the priority chain", path = "priorityLine.enabled" })) - stack:Add(UI:EditBox(content, { label = "Separator", path = "priorityLine.separator" })) - stack:Add(UI:Check(content, { label = "Color each stat name", path = "priorityLine.colorize" })) - stack:Add(UI:Check(content, { label = "Prefix with the spec name", path = "priorityLine.showSpec" })) + stack:Add(UI:Check(content, { label = L["Show the priority chain"], path = "priorityLine.enabled" })) + stack:Add(UI:EditBox(content, { label = L["Separator"], path = "priorityLine.separator" })) + stack:Add(UI:Check(content, { label = L["Color each stat name"], path = "priorityLine.colorize" })) + stack:Add(UI:Check(content, { label = L["Prefix with the spec name"], path = "priorityLine.showSpec" })) stack:Gap(10) - stack:Add(UI:Header(content, "Priority for your current spec")) - stack:Add(UI:Note(content, "The built-in order is a general-purpose baseline. Sim your own character for the authoritative answer, then set it here.")) + stack:Add(UI:Header(content, L["Priority for your current spec"])) + stack:Add(UI:Note(content, L["The built-in order is a general-purpose baseline. Sim your own character for the authoritative answer, then set it here."])) - local specLabel = UI:Note(content, "") + local specLabel = UI:Note(content, L[""]) stack:Add(specLabel) specLabel.Refresh = function(self) local _, specName = SP:GetCurrentPriority() @@ -839,7 +840,7 @@ local function buildPriority(content, stack) for slot = 1, 4 do stack:Add(UI:Dropdown(content, { - label = "Priority " .. slot, + label = L["Priority "] .. slot, values = function() local out = {} for _, statName in ipairs(SECONDARY) do @@ -878,11 +879,11 @@ local function buildPriority(content, stack) end stack:Gap(10) - stack:Add(UI:Header(content, "Paste a stat weight string")) - stack:Add(UI:Note(content, "Paste a Pawn string (from Raidbots, a sim, or a stat site) or a plain order like 'Mastery > Haste > Crit > Versatility'. StatPanel reads the four secondaries and sets the order for your current spec.")) + stack:Add(UI:Header(content, L["Paste a stat weight string"])) + stack:Add(UI:Note(content, L["Paste a Pawn string (from Raidbots, a sim, or a stat site) or a plain order like 'Mastery > Haste > Crit > Versatility'. StatPanel reads the four secondaries and sets the order for your current spec."])) local weightBox = UI:EditBox(content, { - label = "Weights or order", + label = L["Weights or order"], multiline = true, manualCommit = true, get = function() return weightText end, @@ -891,10 +892,10 @@ local function buildPriority(content, stack) stack:Add(weightBox) stack:Add(UI:ButtonRow(content, { - { text = "Apply pasted weights", width = 180, onClick = function() + { text = L["Apply pasted weights"], width = 180, onClick = function() local _, specName, specID = SP:GetCurrentPriority() if not specID then - SP:Print("no active specialization to apply to.") + SP:Print(L["no active specialization to apply to."]) return end local order, err = SP:ParsePriorityString(weightBox.edit:GetText()) @@ -905,8 +906,8 @@ local function buildPriority(content, stack) SP.db.customPriority[specID] = order weightBox.edit:SetText("") weightText = "" - SP:Print(string.format("priority for %s set to %s.", - specName or "your spec", table.concat(order, " > "))) + SP:Print(string.format(L["priority for %s set to %s."], + specName or L["your spec"], table.concat(order, " > "))) SP:Refresh() UI:RefreshAll() end }, @@ -914,7 +915,7 @@ local function buildPriority(content, stack) stack:Gap(10) stack:Add(UI:ButtonRow(content, { - { text = "Use the built-in order", width = 170, onClick = function() + { text = L["Use the built-in order"], width = 170, onClick = function() local _, _, specID = SP:GetCurrentPriority() if specID then SP.db.customPriority[specID] = nil @@ -929,8 +930,8 @@ end -- PAGE: PRESETS -------------------------------------------------------------------------------- local function buildPresets(content, stack) - stack:Add(UI:Header(content, "Presets")) - stack:Add(UI:Note(content, "A preset overwrites appearance settings in the current profile. Your position, visibility rules and profiles are left alone.")) + stack:Add(UI:Header(content, L["Presets"])) + stack:Add(UI:Note(content, L["A preset overwrites appearance settings in the current profile. Your position, visibility rules and profiles are left alone."])) for _, name in ipairs(Presets.order) do local preset = Presets.list[name] @@ -947,9 +948,9 @@ local function buildPresets(content, stack) end stack:Gap(12) - stack:Add(UI:Header(content, "Start over")) + stack:Add(UI:Header(content, L["Start over"])) stack:Add(UI:ButtonRow(content, { - { text = "Reset this profile", width = 160, onClick = function() + { text = L["Reset this profile"], width = 160, onClick = function() Config:Reset() UI:RefreshAll() end }, @@ -964,11 +965,11 @@ local function buildProfiles(content, stack) local importText = "" -- the pasted import-string box local importTarget = "" -- "Import into profile" name box - stack:Add(UI:Header(content, "Profile")) - stack:Add(UI:Note(content, "Each character remembers which profile it uses, so you can share one look across alts or give each its own.")) + stack:Add(UI:Header(content, L["Profile"])) + stack:Add(UI:Note(content, L["Each character remembers which profile it uses, so you can share one look across alts or give each its own."])) stack:Add(UI:Dropdown(content, { - label = "Active profile", + label = L["Active profile"], values = function() return Config:ProfileList() end, get = function() return Config:CurrentProfile() end, set = function(value) @@ -979,13 +980,13 @@ local function buildProfiles(content, stack) })) stack:Add(UI:EditBox(content, { - label = "New profile name", + label = L["New profile name"], get = function() return newName end, set = function(value) newName = value end, })) stack:Add(UI:ButtonRow(content, { - { text = "Create", width = 100, onClick = function() + { text = L["Create"], width = 100, onClick = function() local ok, err = Config:NewProfile(newName) if ok then Config:SetProfile(newName) @@ -995,7 +996,7 @@ local function buildProfiles(content, stack) end UI:RefreshAll() end }, - { text = "Copy current", width = 110, onClick = function() + { text = L["Copy current"], width = 110, onClick = function() local ok, err = Config:NewProfile(newName, Config:CurrentProfile()) if ok then Config:SetProfile(newName) @@ -1005,20 +1006,20 @@ local function buildProfiles(content, stack) end UI:RefreshAll() end }, - { text = "Delete current", width = 120, onClick = function() + { text = L["Delete current"], width = 120, onClick = function() local name = Config:CurrentProfile() local ok, err = Config:DeleteProfile(name) - if not ok then SP:Print(err) else SP:Print("Deleted profile '" .. name .. "'.") end + if not ok then SP:Print(err) else SP:Print(L["Deleted profile '%s'."]:format(name)) end UI:RefreshAll() end }, })) stack:Gap(10) - stack:Add(UI:Header(content, "Share")) - stack:Add(UI:Note(content, "Export produces a string you can paste to someone else. Importing overwrites the profile you name below, or the active one if you leave it blank.")) + stack:Add(UI:Header(content, L["Share"])) + stack:Add(UI:Note(content, L["Export produces a string you can paste to someone else. Importing overwrites the profile you name below, or the active one if you leave it blank."])) local exportBox = UI:EditBox(content, { - label = "Export string", + label = L["Export string"], multiline = true, manualCommit = true, selectAllOnClick = true, @@ -1028,7 +1029,7 @@ local function buildProfiles(content, stack) stack:Add(exportBox) stack:Add(UI:ButtonRow(content, { - { text = "Generate export", width = 140, onClick = function() + { text = L["Generate export"], width = 140, onClick = function() exportBox.edit:SetText(Config:Export() or "") exportBox.edit:HighlightText() exportBox.edit:SetFocus() @@ -1036,7 +1037,7 @@ local function buildProfiles(content, stack) })) local importBox = UI:EditBox(content, { - label = "Import string", + label = L["Import string"], multiline = true, manualCommit = true, get = function() return importText end, @@ -1045,16 +1046,16 @@ local function buildProfiles(content, stack) stack:Add(importBox) stack:Add(UI:EditBox(content, { - label = "Import into profile (blank = active)", + label = L["Import into profile (blank = active)"], get = function() return importTarget end, set = function(value) importTarget = value end, })) stack:Add(UI:ButtonRow(content, { - { text = "Import", width = 120, onClick = function() + { text = L["Import"], width = 120, onClick = function() local name, err = Config:Import(importBox.edit:GetText(), importTarget) if name then - SP:Print("Imported into profile '" .. name .. "'.") + SP:Print(L["Imported into profile '%s'."]:format(name)) importBox.edit:SetText("") else SP:Print(err) @@ -1070,37 +1071,37 @@ end local function buildAnnounce(content, stack) local whisperTarget = "" - stack:Add(UI:Header(content, "Announce")) - stack:Add(UI:Note(content, "Sends a summary of your gear to chat. Nothing is ever sent automatically - only when you use the button, the slash command or the right-click menu.")) + stack:Add(UI:Header(content, L["Announce"])) + stack:Add(UI:Note(content, L["Sends a summary of your gear to chat. Nothing is ever sent automatically - only when you use the button, the slash command or the right-click menu."])) stack:Add(UI:Dropdown(content, { - label = "Send to", path = "announce.channel", values = SP.Announce.channels, + label = L["Send to"], path = "announce.channel", values = SP.Announce.channels, })) stack:Add(UI:EditBox(content, { - label = "Whisper to (for the Whisper channel)", + label = L["Whisper to (for the Whisper channel)"], get = function() return whisperTarget end, set = function(value) whisperTarget = value end, })) - stack:Add(UI:EditBox(content, { label = "Prefix", path = "announce.prefix" })) + stack:Add(UI:EditBox(content, { label = L["Prefix"], path = "announce.prefix" })) stack:Gap(10) - stack:Add(UI:Header(content, "Include")) + stack:Add(UI:Header(content, L["Include"])) - stack:Add(UI:Check(content, { label = "Item level", path = "announce.includeItemLevel" })) - stack:Add(UI:Check(content, { label = "Specialization", path = "announce.includeSpec" })) - stack:Add(UI:Check(content, { label = "Stats", path = "announce.includeStats" })) - stack:Add(UI:Check(content, { label = "Stat priority", path = "announce.includePriority" })) - stack:Add(UI:Check(content, { label = "Session peak speed", path = "announce.includeSpeed" })) - stack:Add(UI:Check(content, { label = "Missing enchants and sockets", path = "announce.includeGear" })) + stack:Add(UI:Check(content, { label = L["Item level"], path = "announce.includeItemLevel" })) + stack:Add(UI:Check(content, { label = L["Specialization"], path = "announce.includeSpec" })) + stack:Add(UI:Check(content, { label = L["Stats"], path = "announce.includeStats" })) + stack:Add(UI:Check(content, { label = L["Stat priority"], path = "announce.includePriority" })) + stack:Add(UI:Check(content, { label = L["Session peak speed"], path = "announce.includeSpeed" })) + stack:Add(UI:Check(content, { label = L["Missing enchants and sockets"], path = "announce.includeGear" })) - stack:Add(UI:Note(content, "The game protects most combat stats and will not let any addon send them to chat, so those are left out automatically. Item level, spec, speed and gear warnings all go through. If a future patch unprotects a stat it will start appearing with no change needed.")) + stack:Add(UI:Note(content, L["The game protects most combat stats and will not let any addon send them to chat, so those are left out automatically. Item level, spec, speed and gear warnings all go through. If a future patch unprotects a stat it will start appearing with no change needed."])) stack:Gap(10) stack:Add(UI:ButtonRow(content, { - { text = "Preview", width = 110, onClick = function() + { text = L["Preview"], width = 110, onClick = function() SP.Announce:Send("SELF") end }, - { text = "Announce now", width = 130, onClick = function() + { text = L["Announce now"], width = 130, onClick = function() SP.Announce:Send(SP.db.announce.channel, whisperTarget) end }, })) @@ -1110,15 +1111,15 @@ end -- PAGE: GEAR -------------------------------------------------------------------------------- local function buildGear(content, stack) - stack:Add(UI:Header(content, "Equipped gear")) - stack:Add(UI:Note(content, "Item data is not protected by the game, so unlike the combat stats this can be read in full.")) + stack:Add(UI:Header(content, L["Equipped gear"])) + stack:Add(UI:Note(content, L["Item data is not protected by the game, so unlike the combat stats this can be read in full."])) stack:Add(UI:ButtonRow(content, { - { text = "Refresh", width = 110, onClick = function() UI:RefreshAll() end }, - { text = "Print report", width = 130, onClick = function() SP.Gear:PrintReport() end }, + { text = L["Refresh"], width = 110, onClick = function() UI:RefreshAll() end }, + { text = L["Print report"], width = 130, onClick = function() SP.Gear:PrintReport() end }, })) - local summary = UI:Note(content, "") + local summary = UI:Note(content, L[""]) stack:Add(summary) -- Reusable rows, rebuilt on every refresh. @@ -1228,18 +1229,18 @@ local function buildAutomation(content, stack) local function settings() return SP.AutoProfile:Settings() end local function profileChoices() - local out = { { name = "(no rule)", value = "" } } + local out = { { name = L["(no rule)"], value = "" } } for _, name in ipairs(Config:ProfileList()) do out[#out + 1] = { name = name, value = name } end return out end - stack:Add(UI:Header(content, "Automatic profile switching")) - stack:Add(UI:Note(content, "Rules are saved per character. A content rule beats a specialization rule, so you can keep a spec profile generally and still force a different one inside a raid. Anything left as '(no rule)' is ignored.")) + stack:Add(UI:Header(content, L["Automatic profile switching"])) + stack:Add(UI:Note(content, L["Rules are saved per character. A content rule beats a specialization rule, so you can keep a spec profile generally and still force a different one inside a raid. Anything left as '(no rule)' is ignored."])) stack:Add(UI:Check(content, { - label = "Switch profiles automatically", + label = L["Switch profiles automatically"], get = function() return settings().enabled end, set = function(value) settings().enabled = value @@ -1248,7 +1249,7 @@ local function buildAutomation(content, stack) })) stack:Gap(10) - stack:Add(UI:Header(content, "By content")) + stack:Add(UI:Header(content, L["By content"])) for _, context in ipairs(SP.AutoProfile.contexts) do stack:Add(UI:Dropdown(content, { @@ -1263,10 +1264,10 @@ local function buildAutomation(content, stack) end stack:Gap(10) - stack:Add(UI:Header(content, "By specialization")) - stack:Add(UI:Note(content, "Only your current specialization is listed. Switch spec and come back to set a rule for another one.")) + stack:Add(UI:Header(content, L["By specialization"])) + stack:Add(UI:Note(content, L["Only your current specialization is listed. Switch spec and come back to set a rule for another one."])) - local specLabel = UI:Note(content, "") + local specLabel = UI:Note(content, L[""]) stack:Add(specLabel) specLabel.Refresh = function(self) local _, specName = SP:GetCurrentPriority() @@ -1275,7 +1276,7 @@ local function buildAutomation(content, stack) UI.widgets[#UI.widgets + 1] = specLabel stack:Add(UI:Dropdown(content, { - label = "Profile for this specialization", + label = L["Profile for this specialization"], values = profileChoices, get = function() local _, _, specID = SP:GetCurrentPriority() @@ -1292,25 +1293,25 @@ local function buildAutomation(content, stack) stack:Gap(10) stack:Add(UI:ButtonRow(content, { - { text = "Apply rules now", width = 150, onClick = function() + { text = L["Apply rules now"], width = 150, onClick = function() -- Apply() returns false both when nothing matches and when the match -- is the profile you are already on; resolve first so the two cases -- read differently instead of both claiming "no rule matches". local target = SP.AutoProfile:Resolve() if not target then - SP:Print("no rule matches your current spec or location.") + SP:Print(L["no rule matches your current spec or location."]) elseif target == SP.Config:CurrentProfile() then - SP:Print("already on '" .. target .. "', the profile your rules call for.") + SP:Print(L["already on '%s', the profile your rules call for."]:format(target)) else SP.AutoProfile:Apply() end end }, - { text = "Clear all rules", width = 140, onClick = function() + { text = L["Clear all rules"], width = 140, onClick = function() local s = settings() wipe(s.bySpec) wipe(s.byContext) UI:RefreshAll() - SP:Print("cleared this character's automatic rules.") + SP:Print(L["cleared this character's automatic rules."]) end }, })) end @@ -1319,19 +1320,19 @@ end -- PAGE REGISTRY -------------------------------------------------------------------------------- local PAGES = { - { id = "general", name = "General", build = buildGeneral }, - { id = "panel", name = "Panel", build = buildPanel }, - { id = "bars", name = "Rows & Bars", build = buildBars }, - { id = "fonts", name = "Fonts", build = buildFonts }, - { id = "stats", name = "Stats", build = buildStats }, - { id = "sections", name = "Sections", build = buildSections }, - { id = "footer", name = "Footer", build = buildFooter }, - { id = "priority", name = "Priority", build = buildPriority }, - { id = "gear", name = "Gear", build = buildGear }, - { id = "announce", name = "Announce", build = buildAnnounce }, - { id = "presets", name = "Presets", build = buildPresets }, - { id = "profiles", name = "Profiles", build = buildProfiles }, - { id = "auto", name = "Automation", build = buildAutomation }, + { id = "general", name = L["General"], build = buildGeneral }, + { id = "panel", name = L["Panel"], build = buildPanel }, + { id = "bars", name = L["Rows & Bars"], build = buildBars }, + { id = "fonts", name = L["Fonts"], build = buildFonts }, + { id = "stats", name = L["Stats"], build = buildStats }, + { id = "sections", name = L["Sections"], build = buildSections }, + { id = "footer", name = L["Footer"], build = buildFooter }, + { id = "priority", name = L["Priority"], build = buildPriority }, + { id = "gear", name = L["Gear"], build = buildGear }, + { id = "announce", name = L["Announce"], build = buildAnnounce }, + { id = "presets", name = L["Presets"], build = buildPresets }, + { id = "profiles", name = L["Profiles"], build = buildProfiles }, + { id = "auto", name = L["Automation"], build = buildAutomation }, } function Options:ShowPage(id) @@ -1381,10 +1382,10 @@ local previewWindow -- A dark panel on a dark backdrop is unreadable, and a translucent one can only -- be judged against something. Cycle the preview background to check both. local PREVIEW_BACKDROPS = { - { name = "Dark", color = { 0.03, 0.03, 0.04, 0.94 } }, - { name = "Grey", color = { 0.40, 0.40, 0.43, 0.95 } }, - { name = "Light", color = { 0.88, 0.88, 0.90, 0.96 } }, - { name = "Game", color = { 0, 0, 0, 0 } }, + { name = L["Dark"], color = { 0.03, 0.03, 0.04, 0.94 } }, + { name = L["Grey"], color = { 0.40, 0.40, 0.43, 0.95 } }, + { name = L["Light"], color = { 0.88, 0.88, 0.90, 0.96 } }, + { name = L["Game"], color = { 0, 0, 0, 0 } }, } local function applyPreviewBackdrop() diff --git a/Presets.lua b/Presets.lua index 7402b11..b4722ec 100644 --- a/Presets.lua +++ b/Presets.lua @@ -5,6 +5,7 @@ -- doesn't mention (position, visibility rules, profiles) survives untouched. local addonName, SP = ... +local L = SP.L local Presets = {} SP.Presets = Presets @@ -24,7 +25,7 @@ Presets.order = { Presets.list = { -------------------------------------------------------------------------- ["Modern Bars"] = { - desc = "The stock look: flat dark panel with colored stat bars.", + desc = L["The stock look: flat dark panel with colored stat bars."], settings = { panel = { width = 208, bgTexture = "Solid", bgColor = { 0.04, 0.04, 0.05, 0.90 }, @@ -76,7 +77,7 @@ Presets.list = { -- Text-only readout: one colored line per stat, rating alongside effect, -- and an item level header showing equipped vs. overall. ["Classic Text"] = { - desc = "No bars. One colored line per stat: 'Mastery: 285 - 10.65%'.", + desc = L["No bars. One colored line per stat: 'Mastery: 285 - 10.65%'."], settings = { panel = { width = 190, autoWidth = true, minWidth = 150, @@ -127,7 +128,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Compact Bars"] = { - desc = "Thin headerless bars for a small footprint.", + desc = L["Thin headerless bars for a small footprint."], settings = { panel = { width = 170, autoWidth = false, @@ -167,7 +168,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Blizzard"] = { - desc = "Blizzard textures and a tooltip border, to match the default UI.", + desc = L["Blizzard textures and a tooltip border, to match the default UI."], settings = { panel = { width = 220, autoWidth = false, @@ -188,7 +189,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Transparent"] = { - desc = "No background or border at all - just floating text and bars.", + desc = L["No background or border at all - just floating text and bars."], settings = { panel = { bgColor = { 0, 0, 0, 0 }, borderStyle = "None", @@ -204,7 +205,7 @@ Presets.list = { -------------------------------------------------------------------------- -- Thin uppercase text with no chrome at all. Reads like a HUD readout. ["Minimal Mono"] = { - desc = "Tiny monochrome text, no background. Sits quietly in a corner.", + desc = L["Tiny monochrome text, no background. Sits quietly in a corner."], settings = { panel = { width = 160, autoWidth = true, minWidth = 120, @@ -238,7 +239,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Neon"] = { - desc = "High-contrast glow bars on near-black, with a value gradient.", + desc = L["High-contrast glow bars on near-black, with a value gradient."], settings = { panel = { width = 220, autoWidth = false, @@ -281,7 +282,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Parchment"] = { - desc = "Warm parchment and gold, in keeping with the default UI art.", + desc = L["Warm parchment and gold, in keeping with the default UI art."], settings = { panel = { width = 230, autoWidth = false, @@ -316,7 +317,7 @@ Presets.list = { -------------------------------------------------------------------------- -- Role presets: these mainly change WHICH stats are on show. ["Tank"] = { - desc = "Defensive focus: armor, dodge, parry, block and avoidance up top.", + desc = L["Defensive focus: armor, dodge, parry, block and avoidance up top."], settings = { panel = { width = 215, autoWidth = false, showTitle = true }, bars = { style = "bar", height = 15, spacing = 5, smooth = true }, @@ -344,7 +345,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Speedrunner"] = { - desc = "Big live speed readout with your session record, and little else.", + desc = L["Big live speed readout with your session record, and little else."], settings = { panel = { width = 190, autoWidth = true, minWidth = 150, @@ -386,7 +387,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Raid Ready"] = { - desc = "Secondary stats, item level and both latencies - what you check before a pull.", + desc = L["Secondary stats, item level and both latencies - what you check before a pull."], settings = { panel = { width = 225, autoWidth = false, @@ -432,7 +433,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Frostbound"] = { - desc = "Cold blues and whites on deep navy.", + desc = L["Cold blues and whites on deep navy."], settings = { panel = { width = 210, autoWidth = false, @@ -469,7 +470,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Ember"] = { - desc = "Warm reds and ambers on charcoal.", + desc = L["Warm reds and ambers on charcoal."], settings = { panel = { width = 210, autoWidth = false, @@ -506,7 +507,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Class Colored"] = { - desc = "Every bar takes your class color. Clean and unfussy.", + desc = L["Every bar takes your class color. Clean and unfussy."], settings = { panel = { width = 200, autoWidth = false, @@ -538,7 +539,7 @@ Presets.list = { -- Deliberately oversized and high contrast, for readability at a distance -- or on a large display. ["Big & Bold"] = { - desc = "Large, heavy, high-contrast text. Easy to read at a glance.", + desc = L["Large, heavy, high-contrast text. Easy to read at a glance."], settings = { panel = { width = 300, autoWidth = true, minWidth = 260, @@ -571,7 +572,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Ultra Compact"] = { - desc = "The smallest useful readout: four secondaries, nothing else.", + desc = L["The smallest useful readout: four secondaries, nothing else."], settings = { panel = { width = 120, autoWidth = true, minWidth = 90, @@ -614,7 +615,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Terminal"] = { - desc = "Green-on-black monospace, like a console readout.", + desc = L["Green-on-black monospace, like a console readout."], settings = { panel = { width = 200, autoWidth = true, minWidth = 170, @@ -653,7 +654,7 @@ Presets.list = { -------------------------------------------------------------------------- ["Healer"] = { - desc = "Throughput stats plus leech, with your primary attribute on top.", + desc = L["Throughput stats plus leech, with your primary attribute on top."], settings = { panel = { width = 215, autoWidth = false, showTitle = true, showDivider = true }, bars = { style = "bar", height = 15, spacing = 5, smooth = true }, @@ -681,7 +682,7 @@ Presets.list = { -------------------------------------------------------------------------- ["PvP"] = { - desc = "Versatility first, with avoidance, dodge and speed alongside.", + desc = L["Versatility first, with avoidance, dodge and speed alongside."], settings = { panel = { width = 210, autoWidth = false, @@ -717,7 +718,7 @@ Presets.list = { -- silently falls back to our built-ins when it isn't. The `dynamic` hook -- below then matches your actual configured ElvUI colors. ["ElvUI"] = { - desc = "Matches ElvUI: flat dark panel, 1px black border, narrow font.", + desc = L["Matches ElvUI: flat dark panel, 1px black border, narrow font."], settings = { panel = { width = 200, autoWidth = false, @@ -754,7 +755,7 @@ Presets.list = { -------------------------------------------------------------------------- ["ElvUI Transparent"] = { - desc = "The popular transparent ElvUI style: near-black glass, hairline border.", + desc = L["The popular transparent ElvUI style: near-black glass, hairline border."], settings = { panel = { width = 200, autoWidth = false, @@ -939,7 +940,7 @@ function Presets:Apply(name) -- broken hook must not leave the profile half-applied. if preset.dynamic then local ok, err = pcall(preset.dynamic) - if not ok then SP:Print("preset hook failed: " .. tostring(err)) end + if not ok then SP:Print(L["preset hook failed: %s"]:format(tostring(err))) end end SP:Refresh() From fb1f268cda97a64fe4ad2c607ac19aa033cfd38b Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 18:37:59 -0400 Subject: [PATCH 5/8] Route AutoProfile, Broker and Announce strings through SP.L Content-context rule names, the broker tooltip lines, the announce channel names and every announce status/error message now look up SP.L. The "N values left out" plural was simplified to "N value(s)" so it needs no English-specific plural fragment. Co-Authored-By: Claude Opus 4.8 (1M context) --- Announce.lua | 39 ++++++++++++++++++++------------------- AutoProfile.lua | 19 ++++++++++--------- Broker.lua | 13 +++++++------ 3 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Announce.lua b/Announce.lua index 53d07aa..87679c3 100644 --- a/Announce.lua +++ b/Announce.lua @@ -15,6 +15,7 @@ -- itself, because nobody wants an addon spamming their raid chat. local addonName, SP = ... +local L = SP.L local Announce = {} SP.Announce = Announce @@ -25,15 +26,15 @@ local warnedAboutSecrets = false -- "SELF" isn't a real chat type; it prints locally so you can preview. Announce.channels = { - { name = "Print to my chat only", value = "SELF" }, - { name = "Say", value = "SAY" }, - { name = "Party", value = "PARTY" }, - { name = "Raid", value = "RAID" }, - { name = "Instance", value = "INSTANCE_CHAT" }, - { name = "Guild", value = "GUILD" }, - { name = "Officer", value = "OFFICER" }, - { name = "Yell", value = "YELL" }, - { name = "Whisper", value = "WHISPER" }, + { name = L["Print to my chat only"], value = "SELF" }, + { name = L["Say"], value = "SAY" }, + { name = L["Party"], value = "PARTY" }, + { name = L["Raid"], value = "RAID" }, + { name = L["Instance"], value = "INSTANCE_CHAT" }, + { name = L["Guild"], value = "GUILD" }, + { name = L["Officer"], value = "OFFICER" }, + { name = L["Yell"], value = "YELL" }, + { name = L["Whisper"], value = "WHISPER" }, } -------------------------------------------------------------------------------- @@ -138,11 +139,11 @@ end -------------------------------------------------------------------------------- local function channelAvailable(channel) if channel == "PARTY" or channel == "INSTANCE_CHAT" then - return IsInGroup(), "You aren't in a group." + return IsInGroup(), L["You aren't in a group."] elseif channel == "RAID" then - return IsInRaid(), "You aren't in a raid." + return IsInRaid(), L["You aren't in a raid."] elseif channel == "GUILD" or channel == "OFFICER" then - return IsInGuild(), "You aren't in a guild." + return IsInGuild(), L["You aren't in a guild."] end return true end @@ -152,22 +153,22 @@ function Announce:Send(channel, target) local now = GetTime() if channel ~= "SELF" and (now - lastSent) < THROTTLE_SECONDS then - SP:Print(string.format("hold on - you can announce again in %.0f seconds.", + SP:Print(string.format(L["hold on - you can announce again in %.0f seconds."], THROTTLE_SECONDS - (now - lastSent))) return false end local message, dropped = self:Build() if not message then - SP:Print("nothing to announce - enable some fields on the Announce page.") + SP:Print(L["nothing to announce - enable some fields on the Announce page."]) return false end -- Explain the omission once per session rather than on every announce. if dropped > 0 and not warnedAboutSecrets then warnedAboutSecrets = true - SP:Print(("%d value%s left out: the game protects those stats, so they cannot be sent to chat.") - :format(dropped, dropped == 1 and " was" or "s were")) + SP:Print(L["%d value(s) left out: the game protects those stats, so they cannot be sent to chat."] + :format(dropped)) end if channel == "SELF" then @@ -177,7 +178,7 @@ function Announce:Send(channel, target) local ok, err = channelAvailable(channel) if not ok then - SP:Print(err .. " Showing it here instead:") + SP:Print(err .. " " .. L["Showing it here instead:"]) SP:Print(message) return false end @@ -185,7 +186,7 @@ function Announce:Send(channel, target) if channel == "WHISPER" then target = target and target:trim() or "" if target == "" then - SP:Print("whisper needs a name: /sp announce whisper ") + SP:Print(L["whisper needs a name: /sp announce whisper "]) return false end end @@ -194,7 +195,7 @@ function Announce:Send(channel, target) -- call is guarded and falls back to a local print. local sent = pcall(SendChatMessage, message, channel, nil, target) if not sent then - SP:Print("the game refused to send that message. Showing it here instead:") + SP:Print(L["the game refused to send that message. Showing it here instead:"]) SP:Print(message) return false end diff --git a/AutoProfile.lua b/AutoProfile.lua index 02d87f8..adfbd9f 100644 --- a/AutoProfile.lua +++ b/AutoProfile.lua @@ -9,6 +9,7 @@ -- context leaves whatever profile you are on alone. local addonName, SP = ... +local L = SP.L local AutoProfile = {} SP.AutoProfile = AutoProfile @@ -38,14 +39,14 @@ local CONTEXT_FALLBACK = { } AutoProfile.contexts = { - { name = "Open world", value = "world" }, - { name = "Delve", value = "delve" }, - { name = "Dungeon", value = "dungeon" }, - { name = "Mythic+ dungeon", value = "mythicplus" }, - { name = "Raid", value = "raid" }, - { name = "Arena", value = "arena" }, - { name = "Battleground", value = "battleground" }, - { name = "Scenario", value = "scenario" }, + { name = L["Open world"], value = "world" }, + { name = L["Delve"], value = "delve" }, + { name = L["Dungeon"], value = "dungeon" }, + { name = L["Mythic+ dungeon"], value = "mythicplus" }, + { name = L["Raid"], value = "raid" }, + { name = L["Arena"], value = "arena" }, + { name = L["Battleground"], value = "battleground" }, + { name = L["Scenario"], value = "scenario" }, } -------------------------------------------------------------------------------- @@ -121,7 +122,7 @@ function AutoProfile:Apply(announce) if SP.UI then SP.UI:RefreshAll() end if announce ~= false then - SP:Print(string.format("switched to profile '%s' (%s rule).", target, reason)) + SP:Print(string.format(L["switched to profile '%s' (%s rule)."], target, reason)) end return true end diff --git a/Broker.lua b/Broker.lua index 28d125e..1dea24b 100644 --- a/Broker.lua +++ b/Broker.lua @@ -12,6 +12,7 @@ -- a profile) so the button doesn't jump around when you switch looks. local addonName, SP = ... +local L = SP.L local Broker = {} SP.Broker = Broker @@ -43,23 +44,23 @@ local function onTooltip(tooltip) local isSecret = SP.IsSecret local _, equipped = GetAverageItemLevel() if not isSecret(equipped) then - tooltip:AddDoubleLine("Item level", string.format("%.1f", equipped or 0), 0.8, 0.8, 0.8, 1, 1, 1) + tooltip:AddDoubleLine(L["Item level"], string.format("%.1f", equipped or 0), 0.8, 0.8, 0.8, 1, 1, 1) end local fps = GetFramerate() or 0 if not isSecret(fps) then - tooltip:AddDoubleLine("FPS", string.format("%.0f", fps), 0.8, 0.8, 0.8, 1, 1, 1) + tooltip:AddDoubleLine(L["FPS"], string.format("%.0f", fps), 0.8, 0.8, 0.8, 1, 1, 1) end local _, specName = SP:GetCurrentPriority() if specName then - tooltip:AddDoubleLine("Spec", specName, 0.8, 0.8, 0.8, 1, 1, 1) + tooltip:AddDoubleLine(L["Spec"], specName, 0.8, 0.8, 0.8, 1, 1, 1) end - tooltip:AddDoubleLine("Profile", SP.Config:CurrentProfile(), 0.8, 0.8, 0.8, 1, 1, 1) + tooltip:AddDoubleLine(L["Profile"], SP.Config:CurrentProfile(), 0.8, 0.8, 0.8, 1, 1, 1) tooltip:AddLine(" ") - tooltip:AddLine("|cffffff00Left-click|r open options", 0.6, 0.6, 0.6) - tooltip:AddLine("|cffffff00Right-click|r quick menu", 0.6, 0.6, 0.6) + tooltip:AddLine(L["|cffffff00Left-click|r open options"], 0.6, 0.6, 0.6) + tooltip:AddLine(L["|cffffff00Right-click|r quick menu"], 0.6, 0.6, 0.6) end -------------------------------------------------------------------------------- From 510a7c5a7383b2d8559594f657aa794f6e3b11d7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Thu, 23 Jul 2026 20:27:23 -0400 Subject: [PATCH 6/8] Route the panel, gear report and remaining announce strings through SP.L Completes the sweep: stat display names (STAT_DEFS, PRIMARY_NAME, SHORT_NAME), the panel row tooltip and the Rebuild error in StatPanel; the whole /sp gear report and warning summary in Gear; and the item-level / priority / gear lines the announce assembles. Plural counts became "%d thing(s)" so no language needs English plural rules. Every user-facing string in the addon now looks up SP.L. Document the feature and bump to 2.3.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- Announce.lua | 16 +++++++--------- CHANGELOG.md | 15 +++++++++++++++ Gear.lua | 33 ++++++++++++++++---------------- README.md | 2 ++ StatPanel.lua | 53 ++++++++++++++++++++++++++------------------------- 5 files changed, 68 insertions(+), 51 deletions(-) diff --git a/Announce.lua b/Announce.lua index 87679c3..2dbb50b 100644 --- a/Announce.lua +++ b/Announce.lua @@ -53,9 +53,9 @@ function Announce:Build() overall, equipped = plain(overall), plain(equipped) if equipped then if overall and math.abs(overall - equipped) >= 0.05 then - parts[#parts + 1] = string.format("iLvl %.2f (%.2f overall)", equipped, overall) + parts[#parts + 1] = string.format(L["iLvl %.2f (%.2f overall)"], equipped, overall) else - parts[#parts + 1] = string.format("iLvl %.2f", equipped) + parts[#parts + 1] = string.format(L["iLvl %.2f"], equipped) end else dropped = dropped + 1 @@ -96,13 +96,13 @@ function Announce:Build() if cfg.includePriority then local priority = SP:GetCurrentPriority() - parts[#parts + 1] = "priority " .. table.concat(priority, " > ") + parts[#parts + 1] = L["priority %s"]:format(table.concat(priority, " > ")) end if cfg.includeSpeed then local peak = plain(SP.GetPeakSpeed and SP.GetPeakSpeed()) if peak and peak > 0 then - parts[#parts + 1] = string.format("peak speed %.0f%%", peak) + parts[#parts + 1] = string.format(L["peak speed %.0f%%"], peak) end end @@ -111,15 +111,13 @@ function Announce:Build() if audit then local issues = {} if audit.missingEnchants > 0 then - issues[#issues + 1] = audit.missingEnchants .. " missing enchant" - .. (audit.missingEnchants == 1 and "" or "s") + issues[#issues + 1] = L["%d missing enchant(s)"]:format(audit.missingEnchants) end if audit.emptySockets > 0 then - issues[#issues + 1] = audit.emptySockets .. " empty socket" - .. (audit.emptySockets == 1 and "" or "s") + issues[#issues + 1] = L["%d empty socket(s)"]:format(audit.emptySockets) end if audit.tierCount then - parts[#parts + 1] = string.format("%d/%d tier set", audit.tierCount, audit.tierTotal) + parts[#parts + 1] = string.format(L["%d/%d tier set"], audit.tierCount, audit.tierTotal) end if #issues > 0 then parts[#parts + 1] = table.concat(issues, ", ") diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c3c23..a73c01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to StatPanel are recorded here. This project follows [Semantic Versioning](https://semver.org/) and the format of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.3.0] - 2026-07-23 + +### Added + +- **Localization support.** The addon had none -- every user-facing string was + an inline English literal, so it could never be translated. There is now a + tiny locale table (`SP.L`, no embedded library): the English string is the + key, and a translation file for the client's language overlays it, with any + untranslated string falling back to English. Every user-facing string -- the + options window, the panel tooltip and stat names, the slash commands, the + right-click menu, the gear report and the chat announce -- is routed through + it. Shipping English-complete and translation-ready; `Locales/README.md` + documents how to add a language. Identifiers (config keys, value-format + tokens, preset names) are deliberately left untranslated. + ## [2.2.0] - 2026-07-23 ### Added diff --git a/Gear.lua b/Gear.lua index a3c60b2..91c5707 100644 --- a/Gear.lua +++ b/Gear.lua @@ -8,6 +8,7 @@ -- an enchant or left a socket empty. local addonName, SP = ... +local L = SP.L local Gear = {} SP.Gear = Gear @@ -261,21 +262,21 @@ end function Gear:PrintReport() local audit = self:Audit() - SP:Print("gear audit") + SP:Print(L["gear audit"]) for _, slot in ipairs(audit.slots) do if slot.empty then if slot.id ~= 17 then - print(string.format(" %-12s %s", slot.name, colorize("empty", 0.9, 0.3, 0.3))) + print(string.format(" %-12s %s", slot.name, colorize(L["empty"], 0.9, 0.3, 0.3))) end else local notes = {} - if slot.needsEnchant then notes[#notes + 1] = colorize("no enchant", 0.95, 0.6, 0.25) end + if slot.needsEnchant then notes[#notes + 1] = colorize(L["no enchant"], 0.95, 0.6, 0.25) end if slot.emptySockets > 0 then - notes[#notes + 1] = colorize(slot.emptySockets .. " empty socket", 0.95, 0.6, 0.25) + notes[#notes + 1] = colorize(L["%d empty socket(s)"]:format(slot.emptySockets), 0.95, 0.6, 0.25) end if slot.lowGems > 0 then - notes[#notes + 1] = colorize(slot.lowGems .. " rare gem", 0.95, 0.6, 0.25) + notes[#notes + 1] = colorize(L["%d rare gem(s)"]:format(slot.lowGems), 0.95, 0.6, 0.25) end -- The upgrade track sits after the item level, dimmed when maxed and @@ -290,7 +291,7 @@ function Gear:PrintReport() local marker = "" if audit.lowest and slot.id == audit.lowest.id then - marker = colorize(" <- lowest", 0.6, 0.6, 0.65) + marker = colorize(" <- " .. L["lowest"], 0.6, 0.6, 0.65) end print(string.format(" %-12s %d%s%s%s", slot.name, slot.itemLevel, track, @@ -301,22 +302,22 @@ function Gear:PrintReport() local overall, equipped = GetAverageItemLevel() equipped = SP.PlainNumber(equipped) if equipped then - print(string.format(" %-12s %.2f", "Equipped", equipped)) + print(string.format(" %-12s %.2f", L["Equipped"], equipped)) end if audit.tierCount then local full = audit.tierCount >= audit.tierTotal - print(" " .. colorize(string.format("Tier set %d/%d", audit.tierCount, audit.tierTotal), + print(" " .. colorize(string.format(L["Tier set %d/%d"], audit.tierCount, audit.tierTotal), full and 0.35 or 0.6, full and 0.85 or 0.6, full and 0.4 or 0.65)) end if audit.notMaxUpgrade > 0 then - print(" " .. colorize(audit.notMaxUpgrade .. " item" - .. (audit.notMaxUpgrade == 1 and "" or "s") .. " not fully upgraded", 0.6, 0.6, 0.65)) + print(" " .. colorize(L["%d item(s) not fully upgraded"]:format(audit.notMaxUpgrade), + 0.6, 0.6, 0.65)) end if audit.missingEnchants == 0 and audit.emptySockets == 0 and audit.emptySlots == 0 and audit.lowGems == 0 then - print(" " .. colorize("Everything is enchanted and socketed.", 0.35, 0.85, 0.4)) + print(" " .. colorize(L["Everything is enchanted and socketed."], 0.35, 0.85, 0.4)) end end @@ -329,18 +330,18 @@ function Gear:WarningText() local issues = {} if audit.missingEnchants > 0 then - issues[#issues + 1] = audit.missingEnchants .. " enchant" .. (audit.missingEnchants == 1 and "" or "s") + issues[#issues + 1] = L["%d enchant(s)"]:format(audit.missingEnchants) end if audit.emptySockets > 0 then - issues[#issues + 1] = audit.emptySockets .. " socket" .. (audit.emptySockets == 1 and "" or "s") + issues[#issues + 1] = L["%d socket(s)"]:format(audit.emptySockets) end if audit.lowGems > 0 then - issues[#issues + 1] = audit.lowGems .. " rare gem" .. (audit.lowGems == 1 and "" or "s") + issues[#issues + 1] = L["%d rare gem(s)"]:format(audit.lowGems) end if audit.emptySlots > 0 then - issues[#issues + 1] = audit.emptySlots .. " empty slot" .. (audit.emptySlots == 1 and "" or "s") + issues[#issues + 1] = L["%d empty slot(s)"]:format(audit.emptySlots) end if #issues == 0 then return nil end - return "Missing: " .. table.concat(issues, ", ") + return L["Missing: %s"]:format(table.concat(issues, ", ")) end diff --git a/README.md b/README.md index 3a896fa..9fa15c4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ item level in the title, session speed peak, and framerate with both latencies.* - Minimap button and a LibDataBroker feed - Gear audit: per-slot item level and upgrade track, tier set count, missing enchants, empty sockets and below-Epic gems +- Fully localizable — English-complete and translation-ready (see + [Locales/README.md](Locales/README.md)); nothing embedded - Chat announce for your gear summary --- diff --git a/StatPanel.lua b/StatPanel.lua index 054c6d1..ea9e1c1 100644 --- a/StatPanel.lua +++ b/StatPanel.lua @@ -6,6 +6,7 @@ local addonName, SP = ... local NS = SP +local L = SP.L local Media = SP.Media @@ -182,11 +183,11 @@ local function resolvePrimary() return best, bestIndex end -local PRIMARY_NAME = { [1] = "Strength", [2] = "Agility", [4] = "Intellect" } +local PRIMARY_NAME = { [1] = L["Strength"], [2] = L["Agility"], [4] = L["Intellect"] } local STAT_DEFS = { Primary = { - name = "Primary", + name = L["Primary"], get = function() local value, index = resolvePrimary() -- No index means the attribute couldn't be identified; the row @@ -194,13 +195,13 @@ local STAT_DEFS = { return value, value, index and PRIMARY_NAME[index] end, }, - Strength = { name = "Strength", get = function() return primaryStat(1) end }, - Agility = { name = "Agility", get = function() return primaryStat(2) end }, - Stamina = { name = "Stamina", get = function() return primaryStat(3) end }, - Intellect = { name = "Intellect", get = function() return primaryStat(4) end }, + Strength = { name = L["Strength"], get = function() return primaryStat(1) end }, + Agility = { name = L["Agility"], get = function() return primaryStat(2) end }, + Stamina = { name = L["Stamina"], get = function() return primaryStat(3) end }, + Intellect = { name = L["Intellect"], get = function() return primaryStat(4) end }, Crit = { - name = "Crit", + name = L["Crit"], get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Crit) @@ -209,7 +210,7 @@ local STAT_DEFS = { end, }, Haste = { - name = "Haste", + name = L["Haste"], get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Haste) @@ -218,7 +219,7 @@ local STAT_DEFS = { end, }, Mastery = { - name = "Mastery", + name = L["Mastery"], get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Mastery) @@ -227,21 +228,21 @@ local STAT_DEFS = { end, }, Versatility = { - name = "Versatility", + name = L["Versatility"], get = function() return num(GetCombatRatingBonus, CR_ID.Versatility), ratingOf("Versatility") end, }, - Armor = { name = "Armor DR", get = function() return GetArmorReduction() end }, - Dodge = { name = "Dodge", get = function() return num(GetDodgeChance), ratingOf("Dodge") end }, - Parry = { name = "Parry", get = function() return num(GetParryChance), ratingOf("Parry") end }, - Block = { name = "Block", get = function() return num(GetBlockChance), ratingOf("Block") end }, + Armor = { name = L["Armor DR"], get = function() return GetArmorReduction() end }, + Dodge = { name = L["Dodge"], get = function() return num(GetDodgeChance), ratingOf("Dodge") end }, + Parry = { name = L["Parry"], get = function() return num(GetParryChance), ratingOf("Parry") end }, + Block = { name = L["Block"], get = function() return num(GetBlockChance), ratingOf("Block") end }, - Leech = { name = "Leech", get = function() return num(GetLifesteal), ratingOf("Leech") end }, - Avoidance = { name = "Avoidance", get = function() return num(GetAvoidance), ratingOf("Avoidance") end }, + Leech = { name = L["Leech"], get = function() return num(GetLifesteal), ratingOf("Leech") end }, + Avoidance = { name = L["Avoidance"], get = function() return num(GetAvoidance), ratingOf("Avoidance") end }, Speed = { - name = "Speed", + name = L["Speed"], get = function() local percent, yards = GetSpeed() return percent, ratingOf("Speed"), nil, yards @@ -324,7 +325,7 @@ NS.StatPriority = { local DEFAULT_PRIORITY = { "Crit", "Haste", "Mastery", "Versatility" } -- Short labels for the compact priority chain line. -local SHORT_NAME = { Crit = "Crit", Haste = "Haste", Mastery = "Mast", Versatility = "Vers" } +local SHORT_NAME = { Crit = L["Crit"], Haste = L["Haste"], Mastery = L["Mast"], Versatility = L["Vers"] } -- Returns the priority list for the player's current spec, its name, and its ID. -- A user override in SP.db.customPriority always wins. @@ -627,7 +628,7 @@ function Panel:Rebuild() local ok, err = pcall(self.RebuildInner, self) self.rebuilding = false if not ok then - SP:Print("a display setting could not be applied (" .. tostring(err) .. ").") + SP:Print(L["a display setting could not be applied (%s)."]:format(tostring(err))) end self:Update(0, true) @@ -1155,24 +1156,24 @@ function Panel:ShowRowTooltip(row) -- Tooltip lines are built by us rather than by SetFormattedText, so secret -- numbers can't go in them. Say so plainly instead of showing nothing. if isSecret(value) or isSecret(rating) then - GameTooltip:AddLine("The game protects this value; see the panel itself.", 0.6, 0.6, 0.6, true) + GameTooltip:AddLine(L["The game protects this value; see the panel itself."], 0.6, 0.6, 0.6, true) else - GameTooltip:AddDoubleLine("Value", string.format("%.2f", value), 0.7, 0.7, 0.7, 1, 1, 1) + GameTooltip:AddDoubleLine(L["Value"], string.format("%.2f", value), 0.7, 0.7, 0.7, 1, 1, 1) if rating and rating > 0 then - GameTooltip:AddDoubleLine("Rating", commafy(rating), 0.7, 0.7, 0.7, 1, 1, 1) + GameTooltip:AddDoubleLine(L["Rating"], commafy(rating), 0.7, 0.7, 0.7, 1, 1, 1) end if row.statName == "Speed" and not isSecret(extra) then - GameTooltip:AddDoubleLine("Yards/sec", string.format("%.1f", extra or 0), 0.7, 0.7, 0.7, 1, 1, 1) - GameTooltip:AddDoubleLine("Session peak", string.format("%.0f%%", sessionPeakSpeed), 0.7, 0.7, 0.7, 1, 1, 1) + GameTooltip:AddDoubleLine(L["Yards/sec"], string.format("%.1f", extra or 0), 0.7, 0.7, 0.7, 1, 1, 1) + GameTooltip:AddDoubleLine(L["Session peak"], string.format("%.0f%%", sessionPeakSpeed), 0.7, 0.7, 0.7, 1, 1, 1) end end if dynamicName then - GameTooltip:AddDoubleLine("Attribute", dynamicName, 0.7, 0.7, 0.7, 1, 1, 1) + GameTooltip:AddDoubleLine(L["Attribute"], dynamicName, 0.7, 0.7, 0.7, 1, 1, 1) end if not SP.db.panel.locked then GameTooltip:AddLine(" ") - GameTooltip:AddLine("Drag to move | /sp for options", 0.5, 0.5, 0.5) + GameTooltip:AddLine(L["Drag to move | /sp for options"], 0.5, 0.5, 0.5) end GameTooltip:Show() end From 5d1863c0487aa1bd8bbee9441ab64c2c0666a873 Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Mon, 27 Jul 2026 12:43:59 -0400 Subject: [PATCH 7/8] Serve stat and slot names from the client, add deDE and a locale linter The first localization pass routed strings through SP.L but left several gaps that only show up once someone actually translates. Stat and gear-slot names now come from Blizzard's own GlobalStrings via a new SP.Global helper, which falls back to SP.L when a global is missing. Those read correctly in all twelve locales with no translator effort. The abbreviated priority-chain labels (Crit, Mast, Vers) stay in SP.L, since the Blizzard names are the full ones and would not fit. Strings the first pass missed are routed too: the font-outline dropdown, the live preview window, the Gear page's slot list and summary, and the LDB broker text. The last three were reaching the UI through string.format and assignment rather than SetText, which is why a grep for SetText did not find them. Sentences built with .. become format strings so translations can reorder them, and Locales/deDE.lua is a complete German translation (452 keys). SP.L never fails at runtime, so the two mistakes translators actually make are silent: a mistyped key renders English forever, and a dropped format specifier only errors at the far-away :format() call. tools/locale-lint.ps1 reports both, plus duplicates, empty translations, a mismatched SP.Locale argument and a file missing from the TOC. -Export emits a stub. CI runs it on every pull request. Also fixes the CI TOC check, which anchored on [A-Za-z0-9_/-]+ and so stopped at the backslash in a Windows-style path. Every subdirectory entry was skipped silently, meaning a broken Locales\ path would have passed. Separators are normalized before matching and the check now walks subdirectories. --- .github/workflows/lint.yml | 25 +- Announce.lua | 4 +- Broker.lua | 4 +- CHANGELOG.md | 37 +++ Gear.lua | 37 ++- Locale.lua | 16 + Locales/README.md | 91 +++++- Locales/deDE.lua | 580 +++++++++++++++++++++++++++++++++++++ Media.lua | 17 +- Options.lua | 36 +-- StatPanel.lua | 62 ++-- StatPanel.toc | 15 + tools/locale-lint.ps1 | 209 +++++++++++++ 13 files changed, 1061 insertions(+), 72 deletions(-) create mode 100644 Locales/deDE.lua create mode 100644 tools/locale-lint.ps1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7b1c823..c1c5796 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,11 +32,19 @@ jobs: # A .lua file that isn't listed in the TOC never loads, which is easy to # miss in review and impossible to spot in-game without hunting. + # + # The TOC uses Windows separators (Locales\deDE.lua), so entries are + # normalized to forward slashes before comparing -- an earlier version of + # this check anchored on [A-Za-z0-9_/-]+ and silently skipped every + # subdirectory entry, which is exactly the case worth catching. - name: Every Lua file is in the TOC, and every TOC entry exists shell: bash run: | status=0 - listed=$(grep -oiE '^[A-Za-z0-9_/-]+\.lua' StatPanel.toc | tr -d '\r') + # Normalize separators before matching, not inside the pattern: '\134' + # is octal for backslash and avoids an escaping level that would + # otherwise differ between a shell and a YAML block scalar. + listed=$(tr '\134' '/' < StatPanel.toc | tr -d '\r' | grep -oiE '^[A-Za-z0-9_./-]+\.lua') for file in $listed; do if [ ! -f "$file" ]; then @@ -45,7 +53,7 @@ jobs: fi done - for file in *.lua; do + for file in $(find . -name '*.lua' -not -path './.git/*' | sed 's|^\./||' | sort); do if ! echo "$listed" | grep -qxF "$file"; then echo "::error file=$file::'$file' is not listed in StatPanel.toc and will never load" status=1 @@ -54,3 +62,16 @@ jobs: [ $status -eq 0 ] && echo "All Lua files are accounted for." exit $status + + locales: + name: Locales + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # SP.L falls back silently, so a mistyped key or a dropped format + # specifier is invisible at runtime until the moment it breaks. pwsh ships + # on the ubuntu-latest image. + - name: Lint locale files + shell: pwsh + run: pwsh -File tools/locale-lint.ps1 diff --git a/Announce.lua b/Announce.lua index 2dbb50b..3416ff2 100644 --- a/Announce.lua +++ b/Announce.lua @@ -176,7 +176,9 @@ function Announce:Send(channel, target) local ok, err = channelAvailable(channel) if not ok then - SP:Print(err .. " " .. L["Showing it here instead:"]) + -- `err` is already a localized sentence; the format string lets a + -- translator choose how the two join rather than forcing a space. + SP:Print(L["%s Showing it here instead:"]:format(err)) SP:Print(message) return false end diff --git a/Broker.lua b/Broker.lua index 1dea24b..711c4ff 100644 --- a/Broker.lua +++ b/Broker.lua @@ -206,9 +206,9 @@ function Broker:StartTicker() -- secret; fall back to the parts we can read. if SP.IsSecret(equipped) then dataObject.text = SP.IsSecret(fps) and "StatPanel" - or string.format("%.0f fps", fps) + or L["%.0f fps"]:format(fps) else - dataObject.text = string.format("%.0f fps | iLvl %.0f", fps, equipped or 0) + dataObject.text = L["%.0f fps | iLvl %.0f"]:format(fps, equipped or 0) end end) end diff --git a/CHANGELOG.md b/CHANGELOG.md index a73c01d..2ba7472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,43 @@ of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). documents how to add a language. Identifiers (config keys, value-format tokens, preset names) are deliberately left untranslated. +- **Stat and gear-slot names come from the client.** `SP.Global` prefers + Blizzard's own `GlobalStrings` (`STAT_HASTE`, `HEADSLOT`, ...) and falls back + to `SP.L` only if a global is missing, so those names are already correct in + all twelve locales with no translator effort. The deliberately abbreviated + forms used by the compact priority chain (`Crit`, `Mast`, `Vers`) stay in + `SP.L` — the Blizzard names are the full ones and wouldn't fit. + +- **German translation** (`Locales/deDE.lua`), complete against the current + string set. + +- **Localized addon-list metadata.** `## Notes-` headers for the ten + translated client locales, read by the in-game addon list and the CurseForge + and Wago listings. + +- **`tools/locale-lint.ps1`.** `SP.L` never fails at runtime, which means the + two mistakes translators actually make are silent: a mistyped key renders the + English forever, and a translation that drops or reorders a format specifier + only errors at the far-away `:format()` call. The linter reports both, plus + duplicate keys, empty translations, a locale file whose `SP.Locale` argument + doesn't match its filename, and one missing from the TOC. `-Export ` + emits a ready-to-fill stub. CI runs it on every pull request. + +### Fixed + +- Strings the first localization pass missed: the font-outline dropdown names, + the live-preview window, and the gear list on the Gear options page, which + duplicated the chat report's wording as English literals. + +- Sentences assembled with `..` now use format strings, so translations can + reorder them: "Current specialization: %s", "Background: %s", "Priority %d" + and the announce channel fallback. + +- The CI TOC check anchored its pattern on `[A-Za-z0-9_/-]+`, which stops at the + backslash in a Windows-style TOC path. Every subdirectory entry was skipped + silently, so a broken `Locales\` path would have passed. Separators are now + normalized before matching, and the check walks subdirectories too. + ## [2.2.0] - 2026-07-23 ### Added diff --git a/Gear.lua b/Gear.lua index 91c5707..7ade140 100644 --- a/Gear.lua +++ b/Gear.lua @@ -9,29 +9,34 @@ local addonName, SP = ... local L = SP.L +local G = SP.Global local Gear = {} SP.Gear = Gear -- Slot list in the order the character sheet shows them. Shirt (4) and tabard -- (19) are cosmetic and deliberately excluded. +-- +-- Names come from Blizzard's own paper-doll globals so the report reads in the +-- client's language for free; the English string after each is the fallback key +-- if a global ever goes away. local SLOTS = { - { id = 1, name = "Head" }, - { id = 2, name = "Neck" }, - { id = 3, name = "Shoulder" }, - { id = 15, name = "Back" }, - { id = 5, name = "Chest" }, - { id = 9, name = "Wrist" }, - { id = 10, name = "Hands" }, - { id = 6, name = "Waist" }, - { id = 7, name = "Legs" }, - { id = 8, name = "Feet" }, - { id = 11, name = "Ring 1" }, - { id = 12, name = "Ring 2" }, - { id = 13, name = "Trinket 1" }, - { id = 14, name = "Trinket 2" }, - { id = 16, name = "Main Hand" }, - { id = 17, name = "Off Hand" }, + { id = 1, name = G("HEADSLOT", "Head") }, + { id = 2, name = G("NECKSLOT", "Neck") }, + { id = 3, name = G("SHOULDERSLOT", "Shoulder") }, + { id = 15, name = G("BACKSLOT", "Back") }, + { id = 5, name = G("CHESTSLOT", "Chest") }, + { id = 9, name = G("WRISTSLOT", "Wrist") }, + { id = 10, name = G("HANDSSLOT", "Hands") }, + { id = 6, name = G("WAISTSLOT", "Waist") }, + { id = 7, name = G("LEGSSLOT", "Legs") }, + { id = 8, name = G("FEETSLOT", "Feet") }, + { id = 11, name = G("FINGER0SLOT", "Ring 1") }, + { id = 12, name = G("FINGER1SLOT", "Ring 2") }, + { id = 13, name = G("TRINKET0SLOT", "Trinket 1") }, + { id = 14, name = G("TRINKET1SLOT", "Trinket 2") }, + { id = 16, name = G("MAINHANDSLOT", "Main Hand") }, + { id = 17, name = G("SECONDARYHANDSLOT", "Off Hand") }, } -- Slots that normally take an enchant at max level. This shifts between diff --git a/Locale.lua b/Locale.lua index e47e0bb..bac5888 100644 --- a/Locale.lua +++ b/Locale.lua @@ -29,3 +29,19 @@ function SP.Locale(locale) end return {} end + +-- The client already ships localized text for much of what the panel displays: +-- stat names and gear slots are in Blizzard's GlobalStrings, correct in all +-- twelve locales. Preferring those to our own table means a translator never +-- has to retype "Haste" in twelve languages, and the SP.L entry stays behind as +-- the fallback for a client (or a future patch) where the global is missing. +-- +-- Callers pass the English text as the fallback key, so the string is still +-- greppable in the source and a translator can still override it. +function SP.Global(name, fallback) + local value = _G[name] + if type(value) == "string" and value ~= "" then + return value + end + return SP.L[fallback] +end diff --git a/Locales/README.md b/Locales/README.md index a7547ac..3627e05 100644 --- a/Locales/README.md +++ b/Locales/README.md @@ -5,21 +5,30 @@ translated without touching the code. The **English string is the key** — ther is no separate English value file to maintain, and any string a translation doesn't cover falls back to English automatically. +`Locales/deDE.lua` is a complete worked example; copy its shape. + ## Adding a language -1. Create `Locales/.lua`, where `` is the client value returned - by `GetLocale()` — `deDE`, `frFR`, `ruRU`, `koKR`, `zhCN`, `zhTW`, `esES`, - `esMX`, `ptBR`, `itIT`. +1. Generate a stub with every key the addon currently uses: + + ``` + pwsh -File tools\locale-lint.ps1 -Export frFR > Locales\frFR.lua + ``` + + Where the locale is the client value returned by `GetLocale()` — `deDE`, + `frFR`, `ruRU`, `koKR`, `zhCN`, `zhTW`, `esES`, `esMX`, `ptBR`, `itIT`. -2. Start it like this and translate the right-hand side only: + Every line comes out commented, so the stub is valid Lua as it stands. + Uncomment and translate the lines you finish; the rest keep falling back to + English. Partial translations are supported on purpose. + +2. The file's header must name its own locale: ```lua local _, SP = ... - local L = SP.Locale("deDE") + local L = SP.Locale("frFR") - L["Show panel"] = "Panel anzeigen" - L["Lock position"] = "Position sperren" - -- ...only the strings you translate; the rest stay English. + L["Show panel"] = "Afficher le panneau" ``` `SP.Locale` returns the live table when the file's locale matches the @@ -30,10 +39,68 @@ doesn't cover falls back to English automatically. ``` Locale.lua - Locales\deDE.lua + Locales\frFR.lua Media.lua ``` -The keys are the exact English strings as they appear in the UI. You do not need -to translate all of them at once — partial translations are fine and fall back -to English key by key. +4. Add a `## Notes-frFR:` line to `StatPanel.toc` alongside the others. That's + what the in-game addon list and the CurseForge / Wago pages show. The title + is a proper noun and stays untranslated. + +5. Run the linter before opening a PR: + + ``` + pwsh -File tools\locale-lint.ps1 + ``` + +## What not to translate + +The fallback is forgiving, which makes a handful of things easy to break +quietly. Leave these exactly as they appear in the key: + +- **`$tokens`** — `$value`, `$rating`, `$equipped`, `$spec` and friends are + parsed out of the format string by the panel. Translate the words around + them, not the tokens. +- **Format specifiers** — `%s`, `%d`, `%.2f`. A translation must consume the + same arguments as its key or `:format()` errors. To reorder them, use Lua's + positional form: `%1$s`, `%2$d`. +- **Color codes** — `|cffffd100 … |r` must survive intact. +- **Slash subcommands** — `/sp toggle`, `/sp preset`. These are typed, not read. + The `` placeholder after them is prose and can be translated. +- **Media names** — "Flat", "Pixel", "Friz Quadrata" and the other texture, + border and font names are absent from the key list on purpose. They are + lookup keys and LibSharedMedia registration names that get written into saved + profiles, so translating them would make a profile written on one client + unreadable on another. + +## Strings you don't have to translate at all + +Stat names and gear slot names go through `SP.Global`, which prefers Blizzard's +own `GlobalStrings` — `STAT_HASTE`, `HEADSLOT` and so on — and only falls back +to `SP.L` if the global is missing. Those already read correctly in all twelve +client locales with no work from you. The English text is still passed as the +fallback key, so you *may* override it, and `deDE.lua` does, but it only ever +shows if a future patch removes the global. + +The exceptions are the deliberately abbreviated forms — `Crit`, `Mast`, `Vers` — +which label the compact priority chain where the full Blizzard names wouldn't +fit. Those are ours and do need translating. + +## What the linter checks + +`SP.L` is designed to never fail: an untranslated key renders English, and a key +with no entry renders itself. That's right at runtime, but it also means the two +mistakes translators actually make are completely silent. `tools/locale-lint.ps1` +catches them, and CI runs it on every pull request: + +| Check | Why it matters | +| --- | --- | +| Orphaned key | A typo'd key just renders the English forever. Nothing looks wrong. | +| Format specifier mismatch | A dropped or retyped `%s` makes `:format()` error at the call site, far from the locale file. | +| Duplicate key | The later entry silently wins. | +| Empty translation | Renders as blank instead of falling back. Delete the line instead. | +| Wrong `SP.Locale` argument | Writes onto the wrong table, or onto `SP.L` for every client. | +| Not listed in the TOC | The file never loads at all. | + +It also reports coverage per locale. Untranslated keys are counted, not treated +as errors. diff --git a/Locales/deDE.lua b/Locales/deDE.lua new file mode 100644 index 0000000..286539d --- /dev/null +++ b/Locales/deDE.lua @@ -0,0 +1,580 @@ +-- Locales/deDE.lua (German) +-- +-- Generated as a stub with `pwsh -File tools\locale-lint.ps1 -Export deDE`, then +-- translated by hand. Run the linter after editing: it catches the two mistakes +-- the SP.L fallback hides -- a mistyped key (renders English forever, nobody +-- notices) and a translation that drops or reorders a format specifier. +-- +-- Rules followed here: +-- * $tokens, |cff...|r color codes, /sp subcommands and %-specifiers are +-- copied verbatim -- the code parses them. +-- * Media names ("Flat", "Pixel", "Friz Quadrata") are absent on purpose: +-- they are lookup keys and LibSharedMedia registration names, not display +-- text, and translating them would break saved profiles. +-- * Stat and gear-slot names are translated even though SP.Global normally +-- serves them from Blizzard's own GlobalStrings, so the fallback path is +-- German too if a global ever disappears. +-- +-- Reviewed for terminology against the German client's own wording; a native +-- speaker pass on tone would still be welcome. + +local _, SP = ... +local L = SP.Locale("deDE") + +-------------------------------------------------------------------------------- +-- Announce.lua +-------------------------------------------------------------------------------- +L["Print to my chat only"] = "Nur in meinem Chat ausgeben" +L["Say"] = "Sagen" +L["Party"] = "Gruppe" +L["Raid"] = "Schlachtzug" +L["Instance"] = "Instanz" +L["Guild"] = "Gilde" +L["Officer"] = "Offizier" +L["Yell"] = "Schreien" +L["Whisper"] = "Flüstern" +L["iLvl %.2f (%.2f overall)"] = "GS %.2f (%.2f insgesamt)" +L["iLvl %.2f"] = "GS %.2f" +L["priority %s"] = "Priorität %s" +L["peak speed %.0f%%"] = "Höchsttempo %.0f%%" +L["%d missing enchant(s)"] = "%d fehlende Verzauberung(en)" +L["%d empty socket(s)"] = "%d leere(r) Sockel" +L["%d/%d tier set"] = "%d/%d Klassenset" +L["You aren't in a group."] = "Du bist in keiner Gruppe." +L["You aren't in a raid."] = "Du bist in keinem Schlachtzug." +L["You aren't in a guild."] = "Du bist in keiner Gilde." +L["hold on - you can announce again in %.0f seconds."] = "Moment - du kannst in %.0f Sekunden erneut ankündigen." +L["nothing to announce - enable some fields on the Announce page."] = "Nichts anzukündigen - aktiviere Felder auf der Seite 'Ankündigen'." +L["%d value(s) left out: the game protects those stats, so they cannot be sent to chat."] = "%d Wert(e) ausgelassen: Das Spiel schützt diese Werte, sie können nicht in den Chat gesendet werden." +L["%s Showing it here instead:"] = "%s Stattdessen hier:" +L["whisper needs a name: /sp announce whisper "] = "Flüstern braucht einen Namen: /sp announce whisper " +L["the game refused to send that message. Showing it here instead:"] = "Das Spiel hat das Senden der Nachricht abgelehnt. Stattdessen hier:" + +-------------------------------------------------------------------------------- +-- AutoProfile.lua +-------------------------------------------------------------------------------- +L["Open world"] = "Offene Welt" +L["Delve"] = "Tiefen" +L["Dungeon"] = "Dungeon" +L["Mythic+ dungeon"] = "Mythisch+-Dungeon" +L["Arena"] = "Arena" +L["Battleground"] = "Schlachtfeld" +L["Scenario"] = "Szenario" +L["switched to profile '%s' (%s rule)."] = "Zu Profil '%s' gewechselt (Regel: %s)." + +-------------------------------------------------------------------------------- +-- Broker.lua +-------------------------------------------------------------------------------- +L["Item level"] = "Gegenstandsstufe" +L["FPS"] = "FPS" +L["Spec"] = "Spezialisierung" +L["Profile"] = "Profil" +L["|cffffff00Left-click|r open options"] = "|cffffff00Linksklick|r Optionen öffnen" +L["|cffffff00Right-click|r quick menu"] = "|cffffff00Rechtsklick|r Schnellmenü" +L["%.0f fps"] = "%.0f fps" +L["%.0f fps | iLvl %.0f"] = "%.0f fps | GS %.0f" + +-------------------------------------------------------------------------------- +-- Config.lua +-------------------------------------------------------------------------------- +L["Profile name cannot be empty."] = "Der Profilname darf nicht leer sein." +L["A profile named '%s' already exists."] = "Ein Profil namens '%s' existiert bereits." +L["The Default profile cannot be deleted."] = "Das Standardprofil kann nicht gelöscht werden." +L["No such profile."] = "Kein solches Profil." +L["Nothing to import."] = "Nichts zu importieren." +L["That import string is too large to be a profile."] = "Diese Importzeichenfolge ist zu groß für ein Profil." +L["That doesn't look like a StatPanel export string."] = "Das sieht nicht nach einer StatPanel-Exportzeichenfolge aus." +L["The import string is corrupt."] = "Die Importzeichenfolge ist beschädigt." +L["Could not read the import string: %s"] = "Die Importzeichenfolge konnte nicht gelesen werden: %s" +L["The import string did not contain a profile."] = "Die Importzeichenfolge enthielt kein Profil." + +-------------------------------------------------------------------------------- +-- Gear.lua +-------------------------------------------------------------------------------- +L["gear audit"] = "Ausrüstungsprüfung" +L["empty"] = "leer" +L["no enchant"] = "keine Verzauberung" +L["%d rare gem(s)"] = "%d seltene(r) Edelstein(e)" +L["lowest"] = "niedrigste" +L["Equipped"] = "Angelegt" +L["Tier set %d/%d"] = "Klassenset %d/%d" +L["%d item(s) not fully upgraded"] = "%d Gegenstand/Gegenstände nicht voll aufgewertet" +L["Everything is enchanted and socketed."] = "Alles ist verzaubert und gesockelt." +L["%d enchant(s)"] = "%d Verzauberung(en)" +L["%d socket(s)"] = "%d Sockel" +L["%d empty slot(s)"] = "%d leere(r) Platz/Plätze" +L["Missing: %s"] = "Fehlt: %s" + +-- Gear slots. Normally supplied by Blizzard's paper-doll globals; these are the +-- fallback if one of those globals ever goes away. +L["Head"] = "Kopf" +L["Neck"] = "Hals" +L["Shoulder"] = "Schulter" +L["Back"] = "Rücken" +L["Chest"] = "Brust" +L["Wrist"] = "Handgelenke" +L["Hands"] = "Hände" +L["Waist"] = "Taille" +L["Legs"] = "Beine" +L["Feet"] = "Füße" +L["Ring 1"] = "Ring 1" +L["Ring 2"] = "Ring 2" +L["Trinket 1"] = "Schmuck 1" +L["Trinket 2"] = "Schmuck 2" +L["Main Hand"] = "Waffenhand" +L["Off Hand"] = "Schildhand" + +-------------------------------------------------------------------------------- +-- Media.lua (display names only -- the `value` side stays English) +-------------------------------------------------------------------------------- +L["None"] = "Keine" +L["Outline"] = "Umrandung" +L["Thick Outline"] = "Dicke Umrandung" +L["Monochrome"] = "Monochrom" +L["Monochrome Outline"] = "Monochrome Umrandung" + +-------------------------------------------------------------------------------- +-- Menu.lua +-------------------------------------------------------------------------------- +L["Show panel"] = "Panel anzeigen" +L["Lock position"] = "Position sperren" +L["Show FPS"] = "FPS anzeigen" +L["Apply preset"] = "Vorlage anwenden" +L["Announce to"] = "Ankündigen an" +L["Audit my gear"] = "Ausrüstung prüfen" +L["Open options"] = "Optionen öffnen" +L["Reset position"] = "Position zurücksetzen" + +-------------------------------------------------------------------------------- +-- Options.lua: dropdown values +-------------------------------------------------------------------------------- +L["Left"] = "Links" +L["Center"] = "Zentriert" +L["Right"] = "Rechts" +L["Proportional to value"] = "Proportional zum Wert" +L["Always full"] = "Immer voll" +L["No fill (text only)"] = "Keine Füllung (nur Text)" +L["Per-stat colors"] = "Farben pro Wert" +L["Class color"] = "Klassenfarbe" +L["Single color"] = "Einzelne Farbe" +L["Value gradient"] = "Wertverlauf" +L["Bars"] = "Balken" +L["Text only"] = "Nur Text" +L["Player name"] = "Spielername" +L["Specialization"] = "Spezialisierung" +L["Custom text"] = "Eigener Text" +L["Hidden"] = "Ausgeblendet" +L["Total effect (character sheet)"] = "Gesamteffekt (Charakterfenster)" +L["Bonus from rating only"] = "Nur Bonus aus Wertung" + +-------------------------------------------------------------------------------- +-- Options.lua: General page +-------------------------------------------------------------------------------- +L["Panel"] = "Panel" +L["Enable StatPanel"] = "StatPanel aktivieren" +L["Master switch. Turning this off hides the panel entirely."] = "Hauptschalter. Deaktivieren blendet das Panel vollständig aus." +L["Stops the panel from being dragged."] = "Verhindert das Verschieben des Panels." +L["Keep on screen"] = "Auf dem Bildschirm halten" +L["Prevents dragging the panel off the edge of the screen."] = "Verhindert, dass das Panel über den Bildschirmrand hinaus gezogen wird." +L["Scale"] = "Skalierung" +L["Opacity"] = "Deckkraft" +L["Frame layer"] = "Fensterebene" +L["Which layer the panel draws on. Raise it if another addon covers the panel."] = "Auf welcher Ebene das Panel gezeichnet wird. Erhöhe sie, wenn ein anderes Addon das Panel verdeckt." +L["Update interval (seconds)"] = "Aktualisierungsintervall (Sekunden)" +L["How often values refresh. Higher values use less CPU."] = "Wie oft die Werte aktualisiert werden. Höhere Werte brauchen weniger CPU." +L["Stat values show"] = "Werte zeigen" +L["Total effect matches the character sheet. Bonus from rating shows only what your gear's rating contributes."] = "Gesamteffekt entspricht dem Charakterfenster. Bonus aus Wertung zeigt nur, was die Wertung deiner Ausrüstung beiträgt." +L["Visibility"] = "Sichtbarkeit" +L["Hide during combat"] = "Im Kampf ausblenden" +L["Turning this on clears 'Show only during combat'."] = "Aktivieren deaktiviert 'Nur im Kampf anzeigen'." +L["Show only during combat"] = "Nur im Kampf anzeigen" +L["Turning this on clears 'Hide during combat'."] = "Aktivieren deaktiviert 'Im Kampf ausblenden'." +L["Hide while dead"] = "Im Tod ausblenden" +L["Hide in vehicles"] = "In Fahrzeugen ausblenden" +L["Hide in pet battles"] = "In Haustierkämpfen ausblenden" +L["Hide inside instances"] = "In Instanzen ausblenden" +L["Turning this on clears 'Hide outside instances'."] = "Aktivieren deaktiviert 'Außerhalb von Instanzen ausblenden'." +L["Hide outside instances"] = "Außerhalb von Instanzen ausblenden" +L["Turning this on clears 'Hide inside instances'."] = "Aktivieren deaktiviert 'In Instanzen ausblenden'." +L["Mouseover fade"] = "Ausblenden ohne Mauszeiger" +L["Only show on mouseover"] = "Nur bei Mauszeiger anzeigen" +L["Fades the panel out until you hover over it."] = "Blendet das Panel aus, bis du mit der Maus darüberfährst." +L["Faded opacity"] = "Deckkraft im ausgeblendeten Zustand" +L["Fade duration (seconds)"] = "Überblenddauer (Sekunden)" +L["Show tooltips on hover"] = "Tooltips bei Mauszeiger anzeigen" +L["Minimap and options"] = "Minikarte und Optionen" +L["Show the minimap button"] = "Minikartensymbol anzeigen" +L["Left-click opens these options, right-click opens the quick menu. Drag it around the minimap edge."] = "Linksklick öffnet diese Optionen, Rechtsklick das Schnellmenü. Ziehe es am Rand der Minikarte entlang." +L["Show a live preview while configuring"] = "Live-Vorschau beim Konfigurieren anzeigen" +L["Docks the real panel beside this window so you can see changes as you make them."] = "Dockt das echte Panel neben diesem Fenster an, damit du Änderungen sofort siehst." +L["Reset peak speed"] = "Höchsttempo zurücksetzen" + +-------------------------------------------------------------------------------- +-- Options.lua: Appearance +-------------------------------------------------------------------------------- +L["Size"] = "Größe" +L["Auto-size width to content"] = "Breite automatisch an Inhalt anpassen" +L["Grows and shrinks the panel to fit the widest row."] = "Vergrößert und verkleinert das Panel passend zur breitesten Zeile." +L["Width"] = "Breite" +L["Minimum width (auto-size)"] = "Mindestbreite (automatisch)" +L["Side padding"] = "Seitlicher Abstand" +L["Top padding"] = "Abstand oben" +L["Bottom padding"] = "Abstand unten" +L["Gap between sections"] = "Abstand zwischen Abschnitten" +L["Section header spacing"] = "Abstand der Abschnittsüberschriften" +L["Set to 0 to remove section headers entirely."] = "Auf 0 setzen, um Abschnittsüberschriften ganz zu entfernen." +L["Background"] = "Hintergrund" +L["Background texture"] = "Hintergrundtextur" +L["Background color and transparency"] = "Hintergrundfarbe und Transparenz" +L["Tile the background"] = "Hintergrund kacheln" +L["Tile size"] = "Kachelgröße" +L["Border"] = "Rahmen" +L["Border style"] = "Rahmenstil" +L["Border color and transparency"] = "Rahmenfarbe und Transparenz" +L["Border thickness"] = "Rahmenstärke" +L["Only affects pixel-style borders; textured borders use their own size."] = "Wirkt nur auf Pixelrahmen; texturierte Rahmen nutzen ihre eigene Größe." +L["Border inset"] = "Rahmeneinzug" +L["Title"] = "Titel" +L["Show title"] = "Titel anzeigen" +L["Title shows"] = "Titel zeigt" +L["Title alignment"] = "Titelausrichtung" +L["Item level format"] = "Format der Gegenstandsstufe" +L["Tokens: $equipped, $overall, $name, $spec, $class, $level"] = "Platzhalter: $equipped, $overall, $name, $spec, $class, $level" +L["Tokens: $equipped $overall $name $spec $class $level"] = "Platzhalter: $equipped $overall $name $spec $class $level" +L["Item level decimals"] = "Nachkommastellen der Gegenstandsstufe" +L["Custom title text"] = "Eigener Titeltext" +L["Divider"] = "Trennlinie" +L["Show divider under title"] = "Trennlinie unter dem Titel anzeigen" +L["Divider color"] = "Farbe der Trennlinie" +L["Divider thickness"] = "Stärke der Trennlinie" + +-------------------------------------------------------------------------------- +-- Options.lua: Rows & Bars page +-------------------------------------------------------------------------------- +L["Row style"] = "Zeilenstil" +L["Draw rows as"] = "Zeilen zeichnen als" +L["Bars draw a status bar per stat. Text only draws a single colored line per stat."] = "Balken zeichnen einen Statusbalken pro Wert. Nur Text zeichnet eine einzelne farbige Zeile pro Wert." +L["Text alignment (text style)"] = "Textausrichtung (Textstil)" +L["Label/value separator (text style)"] = "Trennzeichen zwischen Name und Wert (Textstil)" +L["Placed between the stat name and its value, e.g. ': '"] = "Wird zwischen Wertname und Wert gesetzt, z. B. ': '" +L["Line height (text style)"] = "Zeilenhöhe (Textstil)" +L["Bar appearance"] = "Balkendarstellung" +L["Bar texture"] = "Balkentextur" +L["Bar height"] = "Balkenhöhe" +L["Space between bars"] = "Abstand zwischen Balken" +L["Horizontal inset"] = "Horizontaler Einzug" +L["Bar opacity"] = "Deckkraft der Balken" +L["Fill from the right"] = "Von rechts füllen" +L["Bar colors"] = "Balkenfarben" +L["Color mode"] = "Farbmodus" +L["Per-stat colors are set on the Stats page."] = "Farben pro Wert werden auf der Seite 'Werte' festgelegt." +L["Gradient: low value"] = "Verlauf: niedriger Wert" +L["Gradient: high value"] = "Verlauf: hoher Wert" +L["Bar background"] = "Balkenhintergrund" +L["Track texture"] = "Textur der Leiste" +L["Track color and transparency"] = "Farbe und Transparenz der Leiste" +L["Tint track with the stat color"] = "Leiste in der Wertfarbe einfärben" +L["Track tint opacity"] = "Deckkraft der Leistenfärbung" +L["Bar border"] = "Balkenrahmen" +L["Border color"] = "Rahmenfarbe" +L["Motion"] = "Bewegung" +L["Animate value changes"] = "Wertänderungen animieren" +L["Eases bars toward new values instead of snapping."] = "Lässt Balken weich zum neuen Wert laufen, statt zu springen." +L["Animation speed"] = "Animationsgeschwindigkeit" +L["Show a spark at the fill edge"] = "Funken am Füllrand anzeigen" +L["Spark color"] = "Funkenfarbe" +L["Row text"] = "Zeilentext" +L["Show stat names"] = "Wertnamen anzeigen" +L["Show values"] = "Werte anzeigen" +L["Color names with the stat color"] = "Namen in der Wertfarbe einfärben" +L["Color values with the stat color"] = "Werte in der Wertfarbe einfärben" +L["Number prioritized stats"] = "Priorisierte Werte nummerieren" +L["Prefixes stats in a priority-ordered section with 1, 2, 3..."] = "Stellt Werten in einem nach Priorität sortierten Abschnitt 1, 2, 3... voran." +L["Numbering format"] = "Nummerierungsformat" +L["Name offset"] = "Versatz des Namens" +L["Value offset"] = "Versatz des Werts" + +-------------------------------------------------------------------------------- +-- Options.lua: Fonts page +-------------------------------------------------------------------------------- +L["Section header"] = "Abschnittsüberschrift" +L["Stat name"] = "Wertname" +L["Stat value"] = "Wert" +L["Priority line"] = "Prioritätszeile" +L["Footer"] = "Fußzeile" +L["Font"] = "Schriftart" +L["Font face (all text)"] = "Schriftart (gesamter Text)" +L["Drop shadow"] = "Schlagschatten" +L["Shadow color"] = "Schattenfarbe" +L["Shadow X offset"] = "Schattenversatz X" +L["Shadow Y offset"] = "Schattenversatz Y" +L["Per-element size and color"] = "Größe und Farbe pro Element" +L["Editing"] = "Bearbeitung" +L["Color"] = "Farbe" +L["Stat name and value colors are overridden when 'Color with the stat color' is enabled on the Rows & Bars page."] = "Die Farben von Wertname und Wert werden überschrieben, wenn auf der Seite 'Zeilen & Balken' das Einfärben in der Wertfarbe aktiv ist." + +-------------------------------------------------------------------------------- +-- Options.lua: Stats page +-------------------------------------------------------------------------------- +L["Per-stat settings"] = "Einstellungen pro Wert" +L["Editing stat"] = "Bearbeiteter Wert" +L["Show this stat"] = "Diesen Wert anzeigen" +L["Stat color"] = "Wertfarbe" +L["Use class color for this stat"] = "Klassenfarbe für diesen Wert verwenden" +L["Display name (blank for default)"] = "Anzeigename (leer für Standard)" +L["Value format"] = "Wertformat" +L["Tokens: $value $rating $valuec $ratingc $max $label $peak $yards\nExample: '$rating - $value%' shows '285 - 10.65%'."] = "Platzhalter: $value $rating $valuec $ratingc $max $label $peak $yards\nBeispiel: '$rating - $value%' zeigt '285 - 10,65%'." +L["Decimal places"] = "Nachkommastellen" +L["Bar scale"] = "Balkenskala" +L["Bar fill"] = "Balkenfüllung" +L["Value at a full bar"] = "Wert bei vollem Balken" +L["Grow the scale automatically"] = "Skala automatisch erhöhen" +L["Raises the full-bar value whenever the stat exceeds it. Useful for Speed, which has no ceiling while skyriding."] = "Erhöht den Wert für den vollen Balken, sobald der Wert ihn übersteigt. Nützlich für Geschwindigkeit, die beim Drachenreiten keine Obergrenze hat." +L["Reset all stats"] = "Alle Werte zurücksetzen" + +-------------------------------------------------------------------------------- +-- Options.lua: Sections page +-------------------------------------------------------------------------------- +L["Sections"] = "Abschnitte" +L["Sections are drawn top to bottom in this order. Each one holds any set of stats you like."] = "Abschnitte werden in dieser Reihenfolge von oben nach unten gezeichnet. Jeder kann beliebige Werte enthalten." +L["Editing section"] = "Bearbeiteter Abschnitt" +L["Section title"] = "Abschnittstitel" +L["Show this section"] = "Diesen Abschnitt anzeigen" +L["Show the section header"] = "Abschnittsüberschrift anzeigen" +L["Order by spec stat priority"] = "Nach Wertepriorität der Spezialisierung sortieren" +L["Re-sorts this section's stats to match your specialization's priority."] = "Sortiert die Werte dieses Abschnitts nach der Priorität deiner Spezialisierung." +L["Header alignment"] = "Ausrichtung der Überschrift" +L["Move section up"] = "Abschnitt nach oben" +L["Move section down"] = "Abschnitt nach unten" +L["Stats in this section"] = "Werte in diesem Abschnitt" +L["Up"] = "Hoch" +L["Down"] = "Runter" +L["Remove"] = "Entfernen" +L["Add a stat to this section"] = "Wert zu diesem Abschnitt hinzufügen" +L["(every stat is already here)"] = "(alle Werte sind bereits vorhanden)" +L["Reset sections"] = "Abschnitte zurücksetzen" + +-------------------------------------------------------------------------------- +-- Options.lua: Footer page +-------------------------------------------------------------------------------- +L["Footer line"] = "Fußzeile" +L["Show the footer"] = "Fußzeile anzeigen" +L["Frames per second"] = "Bilder pro Sekunde" +L["Home latency"] = "Latenz (Heimat)" +L["World latency"] = "Latenz (Welt)" +L["Addon memory use"] = "Speicherverbrauch des Addons" +L["Separator between entries"] = "Trennzeichen zwischen Einträgen" +L["Formats"] = "Formate" +L["FPS format"] = "FPS-Format" +L["Home latency format"] = "Format der Heimat-Latenz" +L["World latency format"] = "Format der Welt-Latenz" +L["Memory format"] = "Speicherformat" +L["These use standard number formats: %d for a whole number, %.1f for one decimal."] = "Hier gelten die üblichen Zahlenformate: %d für eine ganze Zahl, %.1f für eine Nachkommastelle." +L["Performance coloring"] = "Farbgebung nach Leistung" +L["Color by performance"] = "Nach Leistung einfärben" +L["Turns FPS and latency green, yellow or red depending on the thresholds below."] = "Färbt FPS und Latenz je nach den Schwellenwerten unten grün, gelb oder rot." +L["Good"] = "Gut" +L["Fair"] = "Mittel" +L["Poor"] = "Schlecht" +L["FPS considered good"] = "FPS gelten als gut ab" +L["FPS considered poor"] = "FPS gelten als schlecht unter" +L["Latency considered good (ms)"] = "Latenz gilt als gut (ms)" +L["Latency considered poor (ms)"] = "Latenz gilt als schlecht (ms)" + +-------------------------------------------------------------------------------- +-- Options.lua: Priority page +-------------------------------------------------------------------------------- +L["Show the priority chain"] = "Prioritätskette anzeigen" +L["Separator"] = "Trennzeichen" +L["Color each stat name"] = "Jeden Wertnamen einfärben" +L["Prefix with the spec name"] = "Namen der Spezialisierung voranstellen" +L["Priority for your current spec"] = "Priorität für deine aktuelle Spezialisierung" +L["The built-in order is a general-purpose baseline. Sim your own character for the authoritative answer, then set it here."] = "Die eingebaute Reihenfolge ist ein allgemeiner Richtwert. Simuliere deinen eigenen Charakter für die verbindliche Antwort und trage sie hier ein." +L["Current specialization: %s"] = "Aktuelle Spezialisierung: %s" +L["unknown"] = "unbekannt" +L["Priority %d"] = "Priorität %d" +L["Paste a stat weight string"] = "Zeichenfolge mit Wertungsgewichten einfügen" +L["Paste a Pawn string (from Raidbots, a sim, or a stat site) or a plain order like 'Mastery > Haste > Crit > Versatility'. StatPanel reads the four secondaries and sets the order for your current spec."] = "Füge eine Pawn-Zeichenfolge (von Raidbots, einer Sim oder einer Statistikseite) oder eine einfache Reihenfolge wie 'Mastery > Haste > Crit > Versatility' ein. StatPanel liest die vier Sekundärwerte und setzt die Reihenfolge für deine aktuelle Spezialisierung." +L["Weights or order"] = "Gewichte oder Reihenfolge" +L["Apply pasted weights"] = "Eingefügte Gewichte anwenden" +L["no active specialization to apply to."] = "Keine aktive Spezialisierung, auf die das angewendet werden könnte." +L["priority for %s set to %s."] = "Priorität für %s auf %s gesetzt." +L["your spec"] = "deine Spezialisierung" +L["Use the built-in order"] = "Eingebaute Reihenfolge verwenden" + +-------------------------------------------------------------------------------- +-- Options.lua: Presets and Profiles pages +-------------------------------------------------------------------------------- +L["Presets"] = "Vorlagen" +L["A preset overwrites appearance settings in the current profile. Your position, visibility rules and profiles are left alone."] = "Eine Vorlage überschreibt die Darstellungseinstellungen im aktuellen Profil. Position, Sichtbarkeitsregeln und Profile bleiben unangetastet." +L["Start over"] = "Neu beginnen" +L["Reset this profile"] = "Dieses Profil zurücksetzen" +L["Each character remembers which profile it uses, so you can share one look across alts or give each its own."] = "Jeder Charakter merkt sich sein Profil, sodass du ein Aussehen über alle Twinks teilen oder jedem ein eigenes geben kannst." +L["Active profile"] = "Aktives Profil" +L["New profile name"] = "Name des neuen Profils" +L["Create"] = "Erstellen" +L["Copy current"] = "Aktuelles kopieren" +L["Delete current"] = "Aktuelles löschen" +L["Deleted profile '%s'."] = "Profil '%s' gelöscht." +L["Share"] = "Teilen" +L["Export produces a string you can paste to someone else. Importing overwrites the profile you name below, or the active one if you leave it blank."] = "Der Export erzeugt eine Zeichenfolge, die du weitergeben kannst. Der Import überschreibt das unten genannte Profil oder das aktive, wenn du das Feld leer lässt." +L["Export string"] = "Exportzeichenfolge" +L["Generate export"] = "Export erzeugen" +L["Import string"] = "Importzeichenfolge" +L["Import into profile (blank = active)"] = "In Profil importieren (leer = aktives)" +L["Import"] = "Importieren" +L["Imported into profile '%s'."] = "In Profil '%s' importiert." + +-------------------------------------------------------------------------------- +-- Options.lua: Announce page +-------------------------------------------------------------------------------- +L["Announce"] = "Ankündigen" +L["Sends a summary of your gear to chat. Nothing is ever sent automatically - only when you use the button, the slash command or the right-click menu."] = "Sendet eine Zusammenfassung deiner Ausrüstung in den Chat. Es wird nie automatisch etwas gesendet - nur über die Schaltfläche, den Slash-Befehl oder das Rechtsklickmenü." +L["Send to"] = "Senden an" +L["Whisper to (for the Whisper channel)"] = "Flüstern an (für den Kanal 'Flüstern')" +L["Prefix"] = "Präfix" +L["Include"] = "Einschließen" +L["Stats"] = "Werte" +L["Stat priority"] = "Wertepriorität" +L["Session peak speed"] = "Höchsttempo der Sitzung" +L["Missing enchants and sockets"] = "Fehlende Verzauberungen und Sockel" +L["The game protects most combat stats and will not let any addon send them to chat, so those are left out automatically. Item level, spec, speed and gear warnings all go through. If a future patch unprotects a stat it will start appearing with no change needed."] = "Das Spiel schützt die meisten Kampfwerte und lässt kein Addon sie in den Chat senden, daher werden sie automatisch weggelassen. Gegenstandsstufe, Spezialisierung, Tempo und Ausrüstungswarnungen kommen durch. Hebt ein späterer Patch den Schutz eines Werts auf, erscheint er ohne weitere Änderung." +L["Preview"] = "Vorschau" +L["Announce now"] = "Jetzt ankündigen" + +-------------------------------------------------------------------------------- +-- Options.lua: Gear page +-------------------------------------------------------------------------------- +L["Equipped gear"] = "Angelegte Ausrüstung" +L["Item data is not protected by the game, so unlike the combat stats this can be read in full."] = "Gegenstandsdaten werden vom Spiel nicht geschützt, daher lassen sie sich anders als die Kampfwerte vollständig auslesen." +L["Refresh"] = "Aktualisieren" +L["Print report"] = "Bericht ausgeben" +L["Average equipped item level %.2f.%s %s"] = "Durchschnittliche angelegte Gegenstandsstufe %.2f.%s %s" +L[" Tier set %d/%d."] = " Klassenset %d/%d." +L["Nothing missing."] = "Es fehlt nichts." + +-------------------------------------------------------------------------------- +-- Options.lua: Automation page +-------------------------------------------------------------------------------- +L["(no rule)"] = "(keine Regel)" +L["Automatic profile switching"] = "Automatischer Profilwechsel" +L["Rules are saved per character. A content rule beats a specialization rule, so you can keep a spec profile generally and still force a different one inside a raid. Anything left as '(no rule)' is ignored."] = "Regeln werden pro Charakter gespeichert. Eine Inhaltsregel schlägt eine Spezialisierungsregel, sodass du allgemein ein Spezialisierungsprofil behalten und im Schlachtzug trotzdem ein anderes erzwingen kannst. Alles auf '(keine Regel)' wird ignoriert." +L["Switch profiles automatically"] = "Profile automatisch wechseln" +L["By content"] = "Nach Inhalt" +L["By specialization"] = "Nach Spezialisierung" +L["Only your current specialization is listed. Switch spec and come back to set a rule for another one."] = "Nur deine aktuelle Spezialisierung wird aufgeführt. Wechsle die Spezialisierung und komm zurück, um eine Regel für eine andere zu setzen." +L["Profile for this specialization"] = "Profil für diese Spezialisierung" +L["Apply rules now"] = "Regeln jetzt anwenden" +L["no rule matches your current spec or location."] = "Keine Regel passt zu deiner aktuellen Spezialisierung oder deinem Aufenthaltsort." +L["already on '%s', the profile your rules call for."] = "Bereits auf '%s', dem von deinen Regeln geforderten Profil." +L["Clear all rules"] = "Alle Regeln löschen" +L["cleared this character's automatic rules."] = "Automatische Regeln dieses Charakters gelöscht." + +-------------------------------------------------------------------------------- +-- Options.lua: page names and the preview window +-------------------------------------------------------------------------------- +L["General"] = "Allgemein" +L["Rows & Bars"] = "Zeilen & Balken" +L["Fonts"] = "Schriftarten" +L["Priority"] = "Priorität" +L["Gear"] = "Ausrüstung" +L["Profiles"] = "Profile" +L["Automation"] = "Automatisierung" +L["Dark"] = "Dunkel" +L["Grey"] = "Grau" +L["Light"] = "Hell" +L["Game"] = "Spiel" +L["Background: %s"] = "Hintergrund: %s" +L["Live Preview"] = "Live-Vorschau" +L["The real panel, docked here. Drag this window to move it; the panel returns home when you close the options."] = "Das echte Panel, hier angedockt. Ziehe dieses Fenster, um es zu verschieben; das Panel kehrt zurück, sobald du die Optionen schließt." +L["Type /sp for slash commands. Drag the panel itself to move it."] = "Gib /sp für Slash-Befehle ein. Ziehe das Panel selbst, um es zu verschieben." + +-------------------------------------------------------------------------------- +-- Presets.lua +-------------------------------------------------------------------------------- +L["The stock look: flat dark panel with colored stat bars."] = "Das Standardaussehen: flaches dunkles Panel mit farbigen Wertebalken." +L["No bars. One colored line per stat: 'Mastery: 285 - 10.65%'."] = "Keine Balken. Eine farbige Zeile pro Wert: 'Meisterschaft: 285 - 10,65%'." +L["Thin headerless bars for a small footprint."] = "Dünne Balken ohne Überschriften für wenig Platzbedarf." +L["Blizzard textures and a tooltip border, to match the default UI."] = "Blizzard-Texturen und ein Tooltip-Rahmen, passend zur Standardoberfläche." +L["No background or border at all - just floating text and bars."] = "Ganz ohne Hintergrund und Rahmen - nur schwebender Text und Balken." +L["Tiny monochrome text, no background. Sits quietly in a corner."] = "Winziger monochromer Text ohne Hintergrund. Sitzt unauffällig in einer Ecke." +L["High-contrast glow bars on near-black, with a value gradient."] = "Kontrastreiche Leuchtbalken auf fast Schwarz, mit Wertverlauf." +L["Warm parchment and gold, in keeping with the default UI art."] = "Warmes Pergament und Gold, im Stil der Standardoberfläche." +L["Defensive focus: armor, dodge, parry, block and avoidance up top."] = "Defensiver Fokus: Rüstung, Ausweichen, Parieren, Blocken und Vermeidung ganz oben." +L["Big live speed readout with your session record, and little else."] = "Große Live-Tempoanzeige mit deinem Sitzungsrekord, und sonst wenig." +L["Secondary stats, item level and both latencies - what you check before a pull."] = "Sekundärwerte, Gegenstandsstufe und beide Latenzen - was man vor einem Pull prüft." +L["Cold blues and whites on deep navy."] = "Kühles Blau und Weiß auf tiefem Marineblau." +L["Warm reds and ambers on charcoal."] = "Warmes Rot und Bernstein auf Anthrazit." +L["Every bar takes your class color. Clean and unfussy."] = "Jeder Balken nimmt deine Klassenfarbe an. Klar und schnörkellos." +L["Large, heavy, high-contrast text. Easy to read at a glance."] = "Großer, fetter, kontrastreicher Text. Auf einen Blick lesbar." +L["The smallest useful readout: four secondaries, nothing else."] = "Die kleinste sinnvolle Anzeige: vier Sekundärwerte, sonst nichts." +L["Green-on-black monospace, like a console readout."] = "Grün auf Schwarz in Festbreitenschrift, wie eine Konsolenausgabe." +L["Throughput stats plus leech, with your primary attribute on top."] = "Schadenswerte plus Lebensraub, mit deinem Primärattribut ganz oben." +L["Versatility first, with avoidance, dodge and speed alongside."] = "Vielseitigkeit zuerst, daneben Vermeidung, Ausweichen und Geschwindigkeit." +L["Matches ElvUI: flat dark panel, 1px black border, narrow font."] = "Passend zu ElvUI: flaches dunkles Panel, 1px schwarzer Rahmen, schmale Schrift." +L["The popular transparent ElvUI style: near-black glass, hairline border."] = "Der beliebte transparente ElvUI-Stil: fast schwarzes Glas, haarfeiner Rahmen." +L["preset hook failed: %s"] = "Vorlagen-Hook fehlgeschlagen: %s" + +-------------------------------------------------------------------------------- +-- SPMain.lua (slash commands -- the /sp subcommands stay English) +-------------------------------------------------------------------------------- +L["commands:"] = "Befehle:" +L[" |cffffd100/sp|r - open the options"] = " |cffffd100/sp|r - Optionen öffnen" +L[" |cffffd100/sp toggle|r - show or hide the panel"] = " |cffffd100/sp toggle|r - Panel ein- oder ausblenden" +L[" |cffffd100/sp lock|r - lock or unlock dragging"] = " |cffffd100/sp lock|r - Verschieben sperren oder freigeben" +L[" |cffffd100/sp reset|r - move the panel back to the center"] = " |cffffd100/sp reset|r - Panel zurück in die Mitte setzen" +L[" |cffffd100/sp preset |r - apply a preset (%s)"] = " |cffffd100/sp preset |r - Vorlage anwenden (%s)" +L[" |cffffd100/sp profile |r - switch profiles"] = " |cffffd100/sp profile |r - Profil wechseln" +L[" |cffffd100/sp peak|r - report and clear the session speed record"] = " |cffffd100/sp peak|r - Temporekord der Sitzung ausgeben und zurücksetzen" +L[" |cffffd100/sp minimap|r - show or hide the minimap button"] = " |cffffd100/sp minimap|r - Minikartensymbol ein- oder ausblenden" +L[" |cffffd100/sp gear|r - audit enchants, sockets and item level"] = " |cffffd100/sp gear|r - Verzauberungen, Sockel und Gegenstandsstufe prüfen" +L[" |cffffd100/sp announce [channel]|r - report your gear to chat"] = " |cffffd100/sp announce [Kanal]|r - Ausrüstung in den Chat melden" +L["panel shown."] = "Panel eingeblendet." +L["panel hidden."] = "Panel ausgeblendet." +L["panel locked."] = "Panel gesperrt." +L["panel unlocked."] = "Panel entsperrt." +L["position reset."] = "Position zurückgesetzt." +L["applied the '%s' preset."] = "Vorlage '%s' angewendet." +L["unknown preset. Available: %s"] = "Unbekannte Vorlage. Verfügbar: %s" +L["switched to profile '%s'."] = "Zu Profil '%s' gewechselt." +L["profiles: %s"] = "Profile: %s" +L["session speed record cleared."] = "Temporekord der Sitzung zurückgesetzt." +L["minimap button hidden."] = "Minikartensymbol ausgeblendet." +L["minimap button shown."] = "Minikartensymbol eingeblendet." + +-------------------------------------------------------------------------------- +-- StatPanel.lua +-------------------------------------------------------------------------------- +L["Primary"] = "Primärattribut" +L["Armor DR"] = "Rüstung SR" + +-- Deliberately abbreviated: these label the compact priority chain, where the +-- full names would not fit. +L["Crit"] = "Krit" +L["Haste"] = "Tempo" +L["Mast"] = "Meist." +L["Vers"] = "Viels." + +L["a display setting could not be applied (%s)."] = "Eine Darstellungseinstellung konnte nicht angewendet werden (%s)." +L["The game protects this value; see the panel itself."] = "Das Spiel schützt diesen Wert; sieh im Panel selbst nach." +L["Value"] = "Wert" +L["Rating"] = "Wertung" +L["Yards/sec"] = "Meter/Sek." +L["Session peak"] = "Höchstwert der Sitzung" +L["Attribute"] = "Attribut" +L["Drag to move | /sp for options"] = "Ziehen zum Verschieben | /sp für Optionen" + +-- Stat names. Normally supplied by Blizzard's GlobalStrings; these are the +-- fallback if one of those globals ever goes away. +L["Strength"] = "Stärke" +L["Agility"] = "Beweglichkeit" +L["Stamina"] = "Ausdauer" +L["Intellect"] = "Intelligenz" +L["Mastery"] = "Meisterschaft" +L["Versatility"] = "Vielseitigkeit" +L["Dodge"] = "Ausweichen" +L["Parry"] = "Parieren" +L["Block"] = "Blocken" +L["Leech"] = "Lebensraub" +L["Avoidance"] = "Vermeidung" +L["Speed"] = "Geschwindigkeit" diff --git a/Media.lua b/Media.lua index d3d1259..ad3695b 100644 --- a/Media.lua +++ b/Media.lua @@ -11,6 +11,7 @@ -- still ship a curated list of Blizzard-shipped assets. local addonName, SP = ... +local L = SP.L local Media = {} SP.Media = Media @@ -76,12 +77,18 @@ Media.borders = { } -- Font outline flag combinations, in dropdown order. +-- +-- Only `name` is translated. `value` is what SetFont expects and what lands in +-- the saved profile, so it stays English on every client -- otherwise a profile +-- written on a German client would be unreadable on an English one. The same +-- rule governs the texture and border tables above: their keys are lookup keys +-- and LibSharedMedia registration names, not display text. Media.fontFlags = { - { value = "", name = "None" }, - { value = "OUTLINE", name = "Outline" }, - { value = "THICKOUTLINE", name = "Thick Outline" }, - { value = "MONOCHROME", name = "Monochrome" }, - { value = "MONOCHROME,OUTLINE", name = "Monochrome Outline" }, + { value = "", name = L["None"] }, + { value = "OUTLINE", name = L["Outline"] }, + { value = "THICKOUTLINE", name = L["Thick Outline"] }, + { value = "MONOCHROME", name = L["Monochrome"] }, + { value = "MONOCHROME,OUTLINE", name = L["Monochrome Outline"] }, } -- Frame strata values, ordered from back to front. diff --git a/Options.lua b/Options.lua index 70d3fa3..1a687d4 100644 --- a/Options.lua +++ b/Options.lua @@ -824,11 +824,11 @@ local function buildPriority(content, stack) stack:Add(UI:Header(content, L["Priority for your current spec"])) stack:Add(UI:Note(content, L["The built-in order is a general-purpose baseline. Sim your own character for the authoritative answer, then set it here."])) - local specLabel = UI:Note(content, L[""]) + local specLabel = UI:Note(content, "") stack:Add(specLabel) specLabel.Refresh = function(self) local _, specName = SP:GetCurrentPriority() - self.text:SetText("Current specialization: " .. (specName or "unknown")) + self.text:SetText(L["Current specialization: %s"]:format(specName or L["unknown"])) end UI.widgets[#UI.widgets + 1] = specLabel @@ -840,7 +840,7 @@ local function buildPriority(content, stack) for slot = 1, 4 do stack:Add(UI:Dropdown(content, { - label = L["Priority "] .. slot, + label = L["Priority %d"]:format(slot), values = function() local out = {} for _, statName in ipairs(SECONDARY) do @@ -1132,9 +1132,9 @@ local function buildGear(content, stack) local warning = SP.Gear:WarningText() local tier = audit.tierCount - and string.format(" Tier set %d/%d.", audit.tierCount, audit.tierTotal) or "" - summary.text:SetText(string.format("Average equipped item level %.2f.%s %s", - audit.average, tier, warning or "Nothing missing.")) + and L[" Tier set %d/%d."]:format(audit.tierCount, audit.tierTotal) or "" + summary.text:SetText(L["Average equipped item level %.2f.%s %s"] + :format(audit.average, tier, warning or L["Nothing missing."])) for _, row in ipairs(list.rows) do row:Hide() end @@ -1170,17 +1170,19 @@ local function buildGear(content, stack) row.info:SetText("-") row.info:SetTextColor(0.5, 0.5, 0.55) else - row.info:SetText("empty") + row.info:SetText(L["empty"]) row.info:SetTextColor(0.9, 0.35, 0.35) end else + -- Same wording and the same keys as the /sp gear chat report in + -- Gear.lua, so a translator writes each of these exactly once. local notes = {} - if slot.needsEnchant then notes[#notes + 1] = "no enchant" end + if slot.needsEnchant then notes[#notes + 1] = L["no enchant"] end if slot.emptySockets > 0 then - notes[#notes + 1] = slot.emptySockets .. " empty socket" + notes[#notes + 1] = L["%d empty socket(s)"]:format(slot.emptySockets) end if slot.lowGems > 0 then - notes[#notes + 1] = slot.lowGems .. " rare gem" + notes[#notes + 1] = L["%d rare gem(s)"]:format(slot.lowGems) end local track = "" @@ -1192,7 +1194,7 @@ local function buildGear(content, stack) local isLowest = audit.lowest and slot.id == audit.lowest.id row.info:SetText(string.format("%d%s%s%s", slot.itemLevel, track, #notes > 0 and (" " .. table.concat(notes, ", ")) or "", - isLowest and " (lowest)" or "")) + isLowest and (" (" .. L["lowest"] .. ")") or "")) if #notes > 0 then row.info:SetTextColor(0.95, 0.6, 0.25) @@ -1267,11 +1269,11 @@ local function buildAutomation(content, stack) stack:Add(UI:Header(content, L["By specialization"])) stack:Add(UI:Note(content, L["Only your current specialization is listed. Switch spec and come back to set a rule for another one."])) - local specLabel = UI:Note(content, L[""]) + local specLabel = UI:Note(content, "") stack:Add(specLabel) specLabel.Refresh = function(self) local _, specName = SP:GetCurrentPriority() - self.text:SetText("Current specialization: " .. (specName or "unknown")) + self.text:SetText(L["Current specialization: %s"]:format(specName or L["unknown"])) end UI.widgets[#UI.widgets + 1] = specLabel @@ -1394,7 +1396,7 @@ local function applyPreviewBackdrop() local mode = PREVIEW_BACKDROPS[index] previewWindow:SetBackdropColor(unpack(mode.color)) - previewWindow.bgButton:SetText("Background: " .. mode.name) + previewWindow.bgButton:SetText(L["Background: %s"]:format(mode.name)) end local function ensurePreviewWindow() @@ -1426,14 +1428,14 @@ local function ensurePreviewWindow() heading:SetFont([[Fonts\FRIZQT__.TTF]], 13, "") heading:SetTextColor(1, 0.82, 0.32) heading:SetPoint("TOP", previewWindow, "TOP", 0, -9) - heading:SetText("Live Preview") + heading:SetText(L["Live Preview"]) local note = previewWindow:CreateFontString(nil, "OVERLAY") note:SetFont([[Fonts\FRIZQT__.TTF]], 10, "") note:SetTextColor(0.60, 0.60, 0.65) note:SetPoint("BOTTOM", previewWindow, "BOTTOM", 0, 8) note:SetWidth(330) - note:SetText("The real panel, docked here. Drag this window to move it; the panel returns home when you close the options.") + note:SetText(L["The real panel, docked here. Drag this window to move it; the panel returns home when you close the options."]) previewWindow.bgButton = CreateFrame("Button", nil, previewWindow, "UIPanelButtonTemplate") previewWindow.bgButton:SetSize(150, 20) @@ -1533,7 +1535,7 @@ function SP:CreateOptionsPanel() local subtitle = optionsFrame:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) subtitle:SetJustifyH("LEFT") - subtitle:SetText("Type /sp for slash commands. Drag the panel itself to move it.") + subtitle:SetText(L["Type /sp for slash commands. Drag the panel itself to move it."]) -- Sidebar local sidebar = CreateFrame("Frame", nil, optionsFrame) diff --git a/StatPanel.lua b/StatPanel.lua index ea9e1c1..9ffc2ac 100644 --- a/StatPanel.lua +++ b/StatPanel.lua @@ -7,6 +7,7 @@ local addonName, SP = ... local NS = SP local L = SP.L +local G = SP.Global local Media = SP.Media @@ -183,7 +184,32 @@ local function resolvePrimary() return best, bestIndex end -local PRIMARY_NAME = { [1] = L["Strength"], [2] = L["Agility"], [4] = L["Intellect"] } +-- Display names for the attributes and ratings. Blizzard's GlobalStrings already +-- carry these in every client locale, so SP.Global prefers the client's own text +-- and keeps the English string as the fallback key. "Primary" and "Armor DR" are +-- ours -- neither is a Blizzard concept -- so those stay in SP.L. +local STAT_NAME = { + Strength = G("SPELL_STAT1_NAME", "Strength"), + Agility = G("SPELL_STAT2_NAME", "Agility"), + Stamina = G("SPELL_STAT3_NAME", "Stamina"), + Intellect = G("SPELL_STAT4_NAME", "Intellect"), + Crit = G("STAT_CRITICAL_STRIKE", "Crit"), + Haste = G("STAT_HASTE", "Haste"), + Mastery = G("STAT_MASTERY", "Mastery"), + Versatility = G("STAT_VERSATILITY", "Versatility"), + Dodge = G("DODGE", "Dodge"), + Parry = G("PARRY", "Parry"), + Block = G("BLOCK", "Block"), + Leech = G("STAT_LIFESTEAL", "Leech"), + Avoidance = G("STAT_AVOIDANCE", "Avoidance"), + Speed = G("STAT_SPEED", "Speed"), +} + +local PRIMARY_NAME = { + [1] = STAT_NAME.Strength, + [2] = STAT_NAME.Agility, + [4] = STAT_NAME.Intellect, +} local STAT_DEFS = { Primary = { @@ -195,13 +221,13 @@ local STAT_DEFS = { return value, value, index and PRIMARY_NAME[index] end, }, - Strength = { name = L["Strength"], get = function() return primaryStat(1) end }, - Agility = { name = L["Agility"], get = function() return primaryStat(2) end }, - Stamina = { name = L["Stamina"], get = function() return primaryStat(3) end }, - Intellect = { name = L["Intellect"], get = function() return primaryStat(4) end }, + Strength = { name = STAT_NAME.Strength, get = function() return primaryStat(1) end }, + Agility = { name = STAT_NAME.Agility, get = function() return primaryStat(2) end }, + Stamina = { name = STAT_NAME.Stamina, get = function() return primaryStat(3) end }, + Intellect = { name = STAT_NAME.Intellect, get = function() return primaryStat(4) end }, Crit = { - name = L["Crit"], + name = STAT_NAME.Crit, get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Crit) @@ -210,7 +236,7 @@ local STAT_DEFS = { end, }, Haste = { - name = L["Haste"], + name = STAT_NAME.Haste, get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Haste) @@ -219,7 +245,7 @@ local STAT_DEFS = { end, }, Mastery = { - name = L["Mastery"], + name = STAT_NAME.Mastery, get = function(source) local value = (source == "bonus") and num(GetCombatRatingBonus, CR_ID.Mastery) @@ -228,21 +254,21 @@ local STAT_DEFS = { end, }, Versatility = { - name = L["Versatility"], + name = STAT_NAME.Versatility, get = function() return num(GetCombatRatingBonus, CR_ID.Versatility), ratingOf("Versatility") end, }, - Armor = { name = L["Armor DR"], get = function() return GetArmorReduction() end }, - Dodge = { name = L["Dodge"], get = function() return num(GetDodgeChance), ratingOf("Dodge") end }, - Parry = { name = L["Parry"], get = function() return num(GetParryChance), ratingOf("Parry") end }, - Block = { name = L["Block"], get = function() return num(GetBlockChance), ratingOf("Block") end }, + Armor = { name = L["Armor DR"], get = function() return GetArmorReduction() end }, + Dodge = { name = STAT_NAME.Dodge, get = function() return num(GetDodgeChance), ratingOf("Dodge") end }, + Parry = { name = STAT_NAME.Parry, get = function() return num(GetParryChance), ratingOf("Parry") end }, + Block = { name = STAT_NAME.Block, get = function() return num(GetBlockChance), ratingOf("Block") end }, - Leech = { name = L["Leech"], get = function() return num(GetLifesteal), ratingOf("Leech") end }, - Avoidance = { name = L["Avoidance"], get = function() return num(GetAvoidance), ratingOf("Avoidance") end }, + Leech = { name = STAT_NAME.Leech, get = function() return num(GetLifesteal), ratingOf("Leech") end }, + Avoidance = { name = STAT_NAME.Avoidance, get = function() return num(GetAvoidance), ratingOf("Avoidance") end }, Speed = { - name = L["Speed"], + name = STAT_NAME.Speed, get = function() local percent, yards = GetSpeed() return percent, ratingOf("Speed"), nil, yards @@ -324,7 +350,9 @@ NS.StatPriority = { local DEFAULT_PRIORITY = { "Crit", "Haste", "Mastery", "Versatility" } --- Short labels for the compact priority chain line. +-- Short labels for the compact priority chain line. These stay in SP.L rather +-- than using the Blizzard globals STAT_DEFS does: the globals are the full +-- names ("Critical Strike"), and the whole point of this line is that it fits. local SHORT_NAME = { Crit = L["Crit"], Haste = L["Haste"], Mastery = L["Mast"], Versatility = L["Vers"] } -- Returns the priority list for the player's current spec, its name, and its ID. diff --git a/StatPanel.toc b/StatPanel.toc index d264ca9..c9a32ee 100644 --- a/StatPanel.toc +++ b/StatPanel.toc @@ -12,7 +12,22 @@ ## X-Curse-Project-ID: 1122269 ## X-Wago-ID: 96d2OlGO +# The addon list and the CurseForge / Wago listings read these per-locale +# headers, falling back to the plain ## Notes above when one is absent. The +# title is a proper noun and is deliberately left untranslated. +## Notes-deDE: Vollständig anpassbare Anzeige für Sekundärwerte, Tempo und FPS +## Notes-esES: Panel totalmente personalizable de estadísticas secundarias, velocidad y FPS +## Notes-esMX: Panel totalmente personalizable de estadísticas secundarias, velocidad y FPS +## Notes-frFR: Affichage entièrement personnalisable des stats secondaires, de la vitesse et des FPS +## Notes-itIT: Pannello completamente personalizzabile per statistiche secondarie, velocità e FPS +## Notes-koKR: 이차 능력치, 이동 속도, FPS를 표시하는 완전 맞춤형 패널 +## Notes-ptBR: Painel totalmente personalizável de atributos secundários, velocidade e FPS +## Notes-ruRU: Полностью настраиваемая панель вторичных характеристик, скорости и FPS +## Notes-zhCN: 完全可自定义的副属性、速度和 FPS 显示面板 +## Notes-zhTW: 完全可自訂的副屬性、速度與 FPS 顯示面板 + Locale.lua +Locales\deDE.lua Media.lua Config.lua Presets.lua diff --git a/tools/locale-lint.ps1 b/tools/locale-lint.ps1 new file mode 100644 index 0000000..8afe5d6 --- /dev/null +++ b/tools/locale-lint.ps1 @@ -0,0 +1,209 @@ +<# + StatPanel locale linter. + + SP.L is deliberately forgiving: a key with no translation falls back to + English, and a key with no entry at all falls back to itself. That is the + right behaviour at runtime, but it also means the two mistakes a translator + actually makes are completely silent -- a mistyped key looks fine (it just + renders the typo'd English), and a translation that drops or reorders a + format specifier only blows up on the line of code that calls :format(). + + This script is where those get caught. + + Usage (from anywhere): + pwsh -File tools\locale-lint.ps1 + pwsh -File tools\locale-lint.ps1 -Locale deDE # one locale only + pwsh -File tools\locale-lint.ps1 -Export frFR # print a stub to fill in + + Exit code is 1 if any error was found, 0 otherwise, so CI can gate on it. + Untranslated keys are reported as coverage, not as errors -- partial + translations are supported on purpose. +#> +param( + [string]$Source = (Split-Path $PSScriptRoot -Parent), + [string]$Locale, + [string]$Export +) + +$ErrorActionPreference = 'Stop' + +$localesDir = Join-Path $Source 'Locales' +$tocPath = Join-Path $Source 'StatPanel.toc' +$errors = 0 + +function Write-Err ($msg) { $script:errors++; Write-Host " ERROR $msg" -ForegroundColor Red } +function Write-Warn ($msg) { Write-Host " WARN $msg" -ForegroundColor Yellow } +function Write-Ok ($msg) { Write-Host " ok $msg" -ForegroundColor DarkGray } + +#------------------------------------------------------------------------------ +# Format specifiers +#------------------------------------------------------------------------------ +# Lua's string.format accepts C-style specifiers plus %q. "%%" is a literal +# percent and carries no argument, so it is skipped. A translation must consume +# exactly the same arguments as its key or :format() errors at runtime. +function Get-FormatSpecs ($text) { + $out = @() + foreach ($m in [regex]::Matches($text, '%(%|\d+\$?[-+ #0]*[\d.]*[diouxXeEfgGqscaA]|[-+ #0]*[\d.]*[diouxXeEfgGqscaA])')) { + if ($m.Value -ne '%%') { $out += $m.Value } + } + return , $out +} + +#------------------------------------------------------------------------------ +# Keys used by the addon +#------------------------------------------------------------------------------ +# Two shapes reach SP.L: +# L["some string"] -- the ordinary lookup +# SP.Global("HEADSLOT", "Head") -- client string first, key as fallback +# The second argument of SP.Global is a real SP.L key, so a translator may +# override it and the linter must not call that override orphaned. +$usedKeys = [ordered]@{} + +$sourceFiles = Get-ChildItem -Path $Source -Filter '*.lua' -File | + Where-Object { $_.DirectoryName -ne $localesDir } + +foreach ($file in $sourceFiles) { + $text = Get-Content -Path $file.FullName -Raw -Encoding UTF8 + $rel = $file.Name + + foreach ($m in [regex]::Matches($text, 'L\[\s*"((?:[^"\\]|\\.)*)"\s*\]')) { + $key = $m.Groups[1].Value + if ($key -eq '') { continue } + if (-not $usedKeys.Contains($key)) { $usedKeys[$key] = @() } + $usedKeys[$key] += $rel + } + + foreach ($m in [regex]::Matches($text, '(?:SP\.Global|\bG)\(\s*"[^"]+"\s*,\s*"((?:[^"\\]|\\.)*)"\s*\)')) { + $key = $m.Groups[1].Value + if (-not $usedKeys.Contains($key)) { $usedKeys[$key] = @() } + $usedKeys[$key] += $rel + } +} + +Write-Host "`nStatPanel locale lint" -ForegroundColor Cyan +Write-Host " $($usedKeys.Count) keys across $($sourceFiles.Count) source files`n" + +#------------------------------------------------------------------------------ +# -Export: print a ready-to-translate stub +#------------------------------------------------------------------------------ +if ($Export) { + $existing = @{} + $exportFile = Join-Path $localesDir "$Export.lua" + if (Test-Path $exportFile) { + $text = Get-Content -Path $exportFile -Raw -Encoding UTF8 + foreach ($m in [regex]::Matches($text, 'L\[\s*"((?:[^"\\]|\\.)*)"\s*\]\s*=')) { + $existing[$m.Groups[1].Value] = $true + } + } + + Write-Host "local _, SP = ..." + Write-Host "local L = SP.Locale(`"$Export`")" + Write-Host "" + foreach ($key in $usedKeys.Keys) { + if ($existing.ContainsKey($key)) { continue } + # Commented out so the stub is valid Lua as-is: a translator uncomments + # the lines they finish, and the rest keep falling back to English. + Write-Host "-- L[`"$key`"] = `"$key`"" + } + exit 0 +} + +#------------------------------------------------------------------------------ +# Each locale file +#------------------------------------------------------------------------------ +if (-not (Test-Path $localesDir)) { + Write-Warn "no Locales directory at $localesDir" + exit 0 +} + +$localeFiles = Get-ChildItem -Path $localesDir -Filter '*.lua' -File +if ($Locale) { $localeFiles = $localeFiles | Where-Object { $_.BaseName -eq $Locale } } + +if (-not $localeFiles) { + Write-Warn 'no locale files found -- the framework is unexercised' + exit 0 +} + +$toc = if (Test-Path $tocPath) { Get-Content -Path $tocPath -Raw -Encoding UTF8 } else { '' } + +foreach ($file in $localeFiles) { + $name = $file.BaseName + Write-Host "Locales\$($file.Name)" -ForegroundColor White + + $text = Get-Content -Path $file.FullName -Raw -Encoding UTF8 + + # A file that doesn't call SP.Locale with its own name would either write + # onto the wrong table or, worse, onto SP.L on every client. + if ($text -notmatch "SP\.Locale\(\s*[`"']$([regex]::Escape($name))[`"']\s*\)") { + Write-Err "does not call SP.Locale(`"$name`") -- check the locale argument matches the filename" + } + + # The .toc drives load order; a file that isn't listed simply never loads. + if ($toc -notmatch "(?im)^\s*Locales[\\/]$([regex]::Escape($file.Name))\s*$") { + Write-Err "not listed in StatPanel.toc -- it will never load" + } + + $translated = 0 + $seen = @{} + + foreach ($m in [regex]::Matches($text, 'L\[\s*"((?:[^"\\]|\\.)*)"\s*\]\s*=\s*"((?:[^"\\]|\\.)*)"')) { + $key = $m.Groups[1].Value + $value = $m.Groups[2].Value + + if ($seen.ContainsKey($key)) { + Write-Err "duplicate entry for `"$key`" -- the later one silently wins" + continue + } + $seen[$key] = $true + + if (-not $usedKeys.Contains($key)) { + # The silent one: a typo here reads as a missing translation, and + # the English text keeps rendering, so nobody ever notices. + Write-Err "orphaned key `"$key`" -- no such string in the source (typo?)" + continue + } + + if ($value -eq '') { + Write-Err "empty translation for `"$key`" -- delete the line instead so it falls back" + continue + } + + $keySpecs = Get-FormatSpecs $key + $valueSpecs = Get-FormatSpecs $value + + if ($keySpecs.Count -ne $valueSpecs.Count) { + Write-Err ("`"$key`" takes $($keySpecs.Count) format argument(s), " + + "translation takes $($valueSpecs.Count) -- :format() will error") + } + elseif ($keySpecs.Count -gt 0) { + $positional = $valueSpecs | Where-Object { $_ -match '^%\d+\$' } + if ($positional) { + # %1$s style: the translator has explicitly reordered, which is + # the supported way to do it. Only the count matters. + } + elseif ((Compare-Object $keySpecs $valueSpecs -SyncWindow 0)) { + Write-Err ("`"$key`" specifiers [$($keySpecs -join ' ')] do not match " + + "translation [$($valueSpecs -join ' ')] -- use %1`$s style to reorder") + } + } + + $translated++ + } + + $missing = @($usedKeys.Keys | Where-Object { -not $seen.ContainsKey($_) }) + $pct = if ($usedKeys.Count -gt 0) { [math]::Round(100 * $translated / $usedKeys.Count) } else { 100 } + + Write-Ok "$translated/$($usedKeys.Count) keys translated ($pct%)" + if ($missing.Count -gt 0) { + Write-Host " $($missing.Count) untranslated, falling back to English" -ForegroundColor DarkGray + } + Write-Host '' +} + +if ($errors -gt 0) { + Write-Host "$errors error(s)`n" -ForegroundColor Red + exit 1 +} + +Write-Host "No errors.`n" -ForegroundColor Green +exit 0 From 2845ff18d7512b558695829dc5c0102a0ddbce3e Mon Sep 17 00:00:00 2001 From: "Christopher D. Fennell" Date: Mon, 27 Jul 2026 12:54:35 -0400 Subject: [PATCH 8/8] Date the 2.3.0 changelog entry to its actual release day 2.2.0 keeps its 2026-07-23 date: that is when its work landed, and it never had a release of its own -- it ships as part of this one. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba7472..db6324f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to StatPanel are recorded here. This project follows [Semantic Versioning](https://semver.org/) and the format of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [2.3.0] - 2026-07-23 +## [2.3.0] - 2026-07-27 ### Added