Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions .luacheckrc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -60,12 +61,12 @@ 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",

-- Items
"GetInventoryItemLink", "GetItemStats", "GetDetailedItemLevelInfo",
"GetInventoryItemLink", "GetInventoryItemID", "GetItemStats", "GetDetailedItemLevelInfo",

-- Chat and performance
"SendChatMessage", "GetFramerate", "GetNetStats",
Expand Down
58 changes: 31 additions & 27 deletions Announce.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" },
}

--------------------------------------------------------------------------------
Expand All @@ -52,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
Expand Down Expand Up @@ -95,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, " > "))
Comment on lines 98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Localize priority entries before joining them

On a German client with includePriority enabled, this produces a mixed-language announcement such as Priorität Haste > Crit > Mastery > Versatility: GetCurrentPriority() returns the canonical English identifiers, while only the surrounding format string is translated. Convert each entry through the localized stat display names before concatenating it; the same raw-list formatting also occurs in the pasted-priority confirmation in Options.lua.

Useful? React with 👍 / 👎.

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

Expand All @@ -110,12 +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(L["%d/%d tier set"], audit.tierCount, audit.tierTotal)
end
if #issues > 0 then
parts[#parts + 1] = table.concat(issues, ", ")
Expand All @@ -135,11 +137,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
Expand All @@ -149,22 +151,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
Expand All @@ -174,15 +176,17 @@ function Announce:Send(channel, target)

local ok, err = channelAvailable(channel)
if not ok then
SP:Print(err .. " 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

if channel == "WHISPER" then
target = target and target:trim() or ""
if target == "" then
SP:Print("whisper needs a name: /sp announce whisper <name>")
SP:Print(L["whisper needs a name: /sp announce whisper <name>"])
return false
end
end
Expand All @@ -191,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
Expand Down
19 changes: 10 additions & 9 deletions AutoProfile.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
-- context leaves whatever profile you are on alone.

local addonName, SP = ...
local L = SP.L

local AutoProfile = {}
SP.AutoProfile = AutoProfile
Expand Down Expand Up @@ -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" },
}

--------------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions Broker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

--------------------------------------------------------------------------------
Expand Down Expand Up @@ -205,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
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,78 @@ 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-27

### 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.

- **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-<locale>` 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 <locale>`
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

- **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.1] - 2026-07-23

### Fixed
Expand Down
Loading
Loading