diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000..1e1eba0 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,53 @@ +# AGENTS + +## Project Scope + +PersonalPlayerNotes is a World of Warcraft addon written in Lua 5.1 with Ace3. + +- Read [README.md](../README.md) for the user-facing addon summary. +- Treat [PersonalPlayerNotes.toc](../PersonalPlayerNotes.toc) as the authoritative load order and metadata manifest. +- Main behavior lives in [PersonalPlayerNotes.lua](../PersonalPlayerNotes.lua), option definitions in [PersonalPlayerNotesConfig.lua](../PersonalPlayerNotesConfig.lua), and metadata/UI helpers in [PersonalPlayerNotesUtils.lua](../PersonalPlayerNotesUtils.lua). + +## Working Rules + +- Keep edits compatible with both Retail and Classic code paths unless the task is explicitly flavor-specific. +- Prefer feature detection over hardcoded flavor checks (`WOW_PROJECT_ID`/`IsRetail`) for anything that depends on client API surface, e.g. `PersonalPlayerNotes:HasModernMenuAPI()`, `HasModernTooltipAPI()`, `HasModernSettingsAPI()`, `HasModernAddOnAPI()`, `HasModernUIReloadAPI()` in [PersonalPlayerNotesUtils.lua](../PersonalPlayerNotesUtils.lua). Classic clients have progressively picked up modern Retail APIs, so a flavor check can silently go stale. +- When changing menu, tooltip, or player-interaction behavior, check both branches in [PersonalPlayerNotes.lua](../PersonalPlayerNotes.lua): the modern path uses `Menu.ModifyMenu` and `TooltipDataProcessor`; the legacy fallback path uses `UnitPopup_ShowMenu` hooks and `GameTooltip:HookScript`. +- Some clients don't expose the `C_AddOns`/`C_UI` namespaces yet (confirmed live on Wrath Classic) — addon load/disable/reload for the legacy Shitlist migration must keep the `LoadAddOn`/`DisableAddOn`/`ReloadUI` fallback path guarded by `HasModernAddOnAPI()`/`HasModernUIReloadAPI()`. +- Preserve the file order in [PersonalPlayerNotes.toc](../PersonalPlayerNotes.toc): embedded libs, locales, then Lua files. If a new file is added, place it deliberately. +- Do not manually edit bundled libraries under `Libs/`. Dependency sourcing is controlled by [.pkgmeta](../.pkgmeta). +- Do not replace the `.toc` version token `@project-version@`. Versioning is injected by the packager and read at runtime via `C_AddOns.GetAddOnMetadata()`. +- Keep SavedVariables schema changes minimal and deliberate. Defaults are defined in [PersonalPlayerNotesConfig.lua](../PersonalPlayerNotesConfig.lua); profile or listed-player structure changes may require migration handling via `PersonalPlayerNotes:MigrateSavedVariablesSchema()` in [PersonalPlayerNotesUtils.lua](../PersonalPlayerNotesUtils.lua) and bumping `PersonalPlayerNotes.SCHEMA_VERSION`. +- Preserve the legacy `Shitlist` migration path unless the task explicitly updates migration behavior. + +## Localization Rules + +- Localization strings use AceLocale with `L["PPN_..."]` keys. +- When adding or renaming a localization key, update both [Locales/enUS.lua](../Locales/enUS.lua) and [Locales/zhCN.lua](../Locales/zhCN.lua) in the same change. +- Reuse existing naming patterns for locale keys and option labels before adding new variants. + +## Validation + +- Format with [StyLua](https://github.com/JohnnyMorganz/StyLua) using [.stylua.toml](../.stylua.toml) (`make format` / `make format-check`). CI fails the `format` job if a file isn't formatted — don't hand-format against the grain of what StyLua would produce. +- Preferred lint baseline is the existing luacheck configuration in [.luacheckrc](../.luacheckrc). Globals are declared explicitly (`globals`/`read_globals`), not blanket-ignored — if you introduce a genuinely new WoW API global, add it there rather than suppressing warning codes 111/112/113. +- Match the repository editor assumptions in [.vscode/settings.json](../.vscode/settings.json): Lua 5.1, WoW API stubs, and a number of intentionally disabled diagnostics. +- Pure Lua logic (no WoW API/Ace3 dependency) is unit tested with plain `lua5.1`, no WoW client required: + - [Tests/test_utils.lua](../Tests/test_utils.lua) - legacy Shitlist key parsing, client feature detection, addon metadata getters, SavedVariables schema migration, `OpenBlizzardOptions()` routing. + - [Tests/test_config.lua](../Tests/test_config.lua) - `PersonalPlayerNotesConfig.lua`'s AceDB defaults shape and all reasons/listed-players/alert CRUD accessors. + - [Tests/test_main.lua](../Tests/test_main.lua) - `PersonalPlayerNotes:GetOldConfigData()`'s legacy Shitlist data migration logic. + - [Tests/support/bootstrap.lua](../Tests/support/bootstrap.lua) - shared WoW/Ace3 stub loader used by the files above (not a test itself). + - Run all of them with `make test`, or individually with `lua5.1 Tests/test_X.lua`. Only add tests here for genuinely isolable logic (e.g. legacy data parsing, feature detection, CRUD accessors) - don't add artificial tests for WoW-API-coupled code that can't run outside a client (menu/tooltip rendering, AceGUI widgets, minimap icon). +- [Tools/validate-toc.lua](../Tools/validate-toc.lua) (`make validate`) checks every `*.toc` file: required metadata fields, that `## Version` is still the `@project-version@` placeholder, plausible `## Interface*` values, that every referenced file exists, and no duplicate file entries. +- CI behavior is defined in [.github/workflows/ci.yml](workflows/ci.yml) (format/lint/tests/validate/build, never publishes), [.github/workflows/release.yml](workflows/release.yml) (tag + publish, only on a merged PR to `master`), and [.github/workflows/interface-version.yml](workflows/interface-version.yml). + +## Packaging Notes + +- Packaging metadata lives in [.pkgmeta](../.pkgmeta); `.github/` and `.vscode/` are intentionally excluded from packaged releases. +- The addon is packaged with BigWigsMods packager and publishes against CurseForge project `344967`. +- Keep release-related documentation concise and prefer linking to existing files instead of duplicating workflow details here. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full release/testing process. + +## Useful Checks Before Editing + +- Search for an existing Ace3 option, chat command, or helper before adding a parallel implementation. +- If a change touches metadata shown in the UI, confirm whether it should come from `.toc` via `GetAddOnMetadata()` rather than from a duplicated string. +- If a change adds a new source file, update [PersonalPlayerNotes.toc](../PersonalPlayerNotes.toc) and verify any required locale or config wiring in the same edit. \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7cc9032 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing + +Notes for contributors and coding agents working on this repository. For the user-facing addon description, see [README.md](../README.md). For repository conventions (feature detection, TOC load order, localization/migration rules), see [AGENTS.md](AGENTS.md). + +## Development + +- Requires Lua 5.1 syntax/semantics (no runtime beyond the WoW client itself). +- Embedded libraries (AceAddon-3.0, AceConfig-3.0, LibDataBroker-1.1, etc.) are **not** vendored in this repository — see [embeds.xml](../embeds.xml) and [.pkgmeta](../.pkgmeta). They're fetched automatically from their upstream repositories when the addon is packaged (see [Releases](#releases)). For a runnable local copy for manual in-game testing, run [Tools/Install-LocalAddon.ps1](../Tools/Install-LocalAddon.ps1) (PowerShell) — it links a WoW AddOns folder to this checkout (so changes are picked up on `/reload`) and fetches the libraries listed in `.pkgmeta` into `Libs/`. It only needs `git` (no separate SVN client is installed or required; the SVN-hosted externals are fetched with `git svn`, which ships with Git for Windows). Run `.\Tools\Install-LocalAddon.ps1 -Uninstall` to remove the link again. Alternatively, run the [BigWigsMods packager](https://github.com/BigWigsMods/packager) locally to build an actual zip instead of a live link. + +## Running checks and tests + +[![CI](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/ci.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/ci.yml) + +All checks below run in [workflows/ci.yml](workflows/ci.yml) on every push and pull request, and never publish anything. A `Makefile` wraps the same commands locally: + +| Command | What it does | +|----------------------|--------------------------------------------------------------------------| +| `make format` | Formats all Lua files in-place with [StyLua](https://github.com/JohnnyMorganz/StyLua) (config: [.stylua.toml](../.stylua.toml)) | +| `make format-check` | Checks formatting without writing changes (same check CI runs) | +| `make lint` | Runs [luacheck](https://github.com/mpeterv/luacheck) using [.luacheckrc](../.luacheckrc) | +| `make test` | Runs the pure-Lua unit tests (no WoW client required) with Lua 5.1 | +| `make validate` | Runs [Tools/validate-toc.lua](../Tools/validate-toc.lua): checks required TOC metadata, that every referenced file exists, no duplicate file entries, and that `## Version` is still the packager placeholder | +| `make build` | Builds a local, unsigned addon zip with the BigWigsMods packager (no upload) | +| `make check` | Runs `format-check`, `lint`, `test`, and `validate` together | + +Equivalent raw commands (if you don't have `make`, e.g. on plain Windows without Git Bash/WSL): +```sh +stylua --check . # or without --check to write +luacheck . +lua5.1 Tests/test_utils.lua +lua5.1 Tests/test_config.lua +lua5.1 Tests/test_main.lua +lua5.1 Tools/validate-toc.lua +``` + +## Releases + +[![Interface versions](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/interface-version.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/interface-version.yml) +[![Release](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/release.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/release.yml) + +Releases are fully automated via [workflows/release.yml](workflows/release.yml), and only happen on pull requests merged into `master` (or manual dispatch) — never on every push: + +1. The `tag` job runs [K-Phoen/semver-release-action](https://github.com/K-Phoen/semver-release-action) (pinned to `v1.3.2`), which decides whether the merged PR warrants a new semver tag (based on the PR's labels — see that action's docs) and creates the git tag + GitHub release if so. If no new tag is warranted, nothing else in this workflow runs. +2. The `package` job (gated on step 1 actually producing a tag) runs the [BigWigsMods packager](https://github.com/BigWigsMods/packager) (pinned to the exact release `v2.5.1`, not a floating tag) to build a clean release zip (excluding dev-only files per [.pkgmeta](../.pkgmeta)), using the git tag as the addon version (the `.toc`'s `@project-version@` placeholder — see [.luacheckrc](../.luacheckrc)/AGENTS.md — is substituted in at this point), and publishes it to GitHub Releases and CurseForge (project `344967`). +3. Credentials (`CF_API_KEY`, `GITHUB_OAUTH`) are supplied via repository secrets, never hardcoded. These names are what packager `v2.5.1` actually reads (verified from its source) — a future packager upgrade past its unreleased "Normalize api token env vars" change would need `CF_API_TOKEN`/`GITHUB_API_TOKEN` instead. +4. A merge only reaches the `release` workflow's intended effect (an actual publish) if [workflows/ci.yml](workflows/ci.yml) passed first — this repository's branch protection rules on `master` should require the `ci.yml` jobs (`format`, `luacheck`, `tests`, `validate`, `build`) as required status checks, since a workflow file alone cannot enforce that. + +[workflows/interface-version.yml](workflows/interface-version.yml) separately keeps the declared `## Interface` versions in [PersonalPlayerNotes.toc](../PersonalPlayerNotes.toc) up to date on a daily schedule. + +## Project structure + +- [PersonalPlayerNotes.toc](../PersonalPlayerNotes.toc) — addon manifest and load order. +- [PersonalPlayerNotes.lua](../PersonalPlayerNotes.lua) — initialization, menu/tooltip hookups, legacy Shitlist migration. +- [PersonalPlayerNotesConfig.lua](../PersonalPlayerNotesConfig.lua) — AceDB defaults and AceConfig options tables. +- [PersonalPlayerNotesUtils.lua](../PersonalPlayerNotesUtils.lua) — metadata getters, feature detection, SavedVariables migration, GUI helpers. +- [Locales/](../Locales/) — AceLocale string tables (`enUS.lua`, `zhCN.lua`). +- [Tests/](../Tests/) — pure-Lua unit tests runnable outside of WoW. +- [Tools/](../Tools/) — local dev tooling, e.g. [Install-LocalAddon.ps1](../Tools/Install-LocalAddon.ps1) for setting up a runnable local checkout. +- [Tools/validate-toc.lua](../Tools/validate-toc.lua) — pure-Lua TOC/addon-structure validator, used by both CI and `make validate`. +- [embeds.xml](../embeds.xml) / [.pkgmeta](../.pkgmeta) — embedded Ace3 library declarations and packaging config. +- [.stylua.toml](../.stylua.toml) — StyLua formatting config. +- [Makefile](../Makefile) — local developer commands (`format`, `format-check`, `lint`, `test`, `validate`, `build`, `check`). +- [changelog.txt](../changelog.txt) — release changelog consumed by the packager. +- [AGENTS.md](AGENTS.md) — repo-specific conventions for contributors and coding agents. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index e77d287..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Package and release - -on: - workflow_dispatch: - workflow_run: - workflows: ['Auto Release'] - -jobs: - run: - name: Package and release - runs-on: ubuntu-latest - - env: - CF_API_KEY: ${{ secrets.CF_API_KEY }} - GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }} - - steps: - - name: Clone project - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Package and release - uses: BigWigsMods/packager@v2 - with: - args: -p 344967 -w 0 -a 0 -m .pkgmeta diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59d42a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +# Runs on every push and pull request. Never publishes or uploads anywhere - +# see release.yml for the publish pipeline. This workflow is what branch +# protection should require to pass before merging to master. + +on: + workflow_dispatch: + push: + branches: + - master + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Format check (StyLua) + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v4 + + - name: Check formatting + uses: JohnnyMorganz/stylua-action@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: "2.5.2" + args: --check . + + luacheck: + name: Luacheck + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v4 + + - name: Install and run Luacheck + uses: nebularg/actions-luacheck@v1 + with: + args: "--no-color -q" + + tests: + name: Lua unit tests + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v4 + + - name: Install Lua 5.1 + run: sudo apt-get update && sudo apt-get install -y lua5.1 + + - name: Run unit tests + run: | + set -e + for f in Tests/test_utils.lua Tests/test_config.lua Tests/test_main.lua; do + echo "-- $f --" + lua5.1 "$f" + done + + validate: + name: Validate addon (TOC, file structure) + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v4 + + - name: Install Lua 5.1 + run: sudo apt-get update && sudo apt-get install -y lua5.1 + + - name: Validate .toc files + run: lua5.1 Tools/validate-toc.lua + + build: + name: Build package (no upload) + needs: [format, luacheck, tests, validate] + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # -d skips all uploading (CurseForge/WoWInterface/Wago/GitHub) - this + # only proves the package builds. -p 0 unsets the CurseForge project id + # for this dry run so it doesn't attempt any CurseForge API calls + # (which would need CF_API_KEY, not available to PRs from forks). + - name: Build package artifact + uses: BigWigsMods/packager@v2.5.1 + with: + args: -d -p 0 -w 0 -a 0 -m .pkgmeta + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: PersonalPlayerNotes-build-${{ github.sha }} + path: .release/*.zip + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/interface-version.yml b/.github/workflows/interface-version.yml index 8d35dd2..8b9af4e 100644 --- a/.github/workflows/interface-version.yml +++ b/.github/workflows/interface-version.yml @@ -2,15 +2,8 @@ name: Update TOC Interface version(s) on: workflow_dispatch: - # push: - # branches: - # - master - # workflow_run: - # workflows: ['Luacheck'] - # types: - # - completed - # schedule: - # - cron: 0 12 * * * + schedule: + - cron: 0 12 * * * jobs: run: @@ -24,25 +17,21 @@ jobs: uses: p3lim/toc-interface-updater@v4 with: flavor: vanilla - future: true - name: Checking Wrath uses: p3lim/toc-interface-updater@v4 with: flavor: wrath - future: true - - name: Checking Cata + - name: Checking Mists uses: p3lim/toc-interface-updater@v4 with: - flavor: cata - future: true + flavor: mists - name: Checking Retail uses: p3lim/toc-interface-updater@v4 with: flavor: retail - future: true - name: Commit changes uses: EndBug/add-and-commit@v10 diff --git a/.github/workflows/luacheck.yml b/.github/workflows/luacheck.yml deleted file mode 100644 index e3af511..0000000 --- a/.github/workflows/luacheck.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Luacheck - -on: - workflow_dispatch: - # pull_request: - # push: - # branches: - # - master - -jobs: - run: - name: Luacheck - runs-on: ubuntu-latest - steps: - - name: Clone project - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Install and run Luacheck - uses: nebularg/actions-luacheck@v1 - with: - args: "--no-color -q" - - - name: wow-lint - uses: davidcraig/action-wow-lint@v1.0.1 - with: - path-to-files: '*.lua' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..58d9df7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +# Only runs on merged PRs into master (or manual dispatch). Creates a new git +# tag + GitHub release ONLY when K-Phoen/semver-release-action determines a +# new version is warranted (see its PR-label docs); if no new tag is created, +# the package/publish job is skipped entirely - this workflow does not +# publish on every push. +# +# `needs: tag` + `if: needs.tag.outputs.tag != ''` replaces the old +# cross-workflow `workflow_run` trigger (which had no conclusion check and +# could fire even if packaging should have been skipped). +# +# The `concurrency` group (without cancel-in-progress) makes sure two +# merges landing close together are released one at a time instead of racing. + +on: + workflow_dispatch: + pull_request: + types: [closed] + +concurrency: + group: release + cancel-in-progress: false + +jobs: + tag: + name: Tag new version + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged && github.event.pull_request.base.ref == 'master') + outputs: + tag: ${{ steps.tag_and_prepare_release.outputs.tag }} + steps: + - name: Clone project + uses: actions/checkout@v4 + + - name: Tag and prepare release + id: tag_and_prepare_release + uses: K-Phoen/semver-release-action@v1.3.2 + with: + release_branch: master + release_strategy: tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub release + if: steps.tag_and_prepare_release.outputs.tag + uses: Roang-zero1/github-create-release-action@v3 + with: + created_tag: ${{ steps.tag_and_prepare_release.outputs.tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + package: + name: Package and publish + needs: tag + if: needs.tag.outputs.tag != '' + runs-on: ubuntu-latest + + env: + # Verified against BigWigsMods/packager v2.5.1's release.sh: it reads + # CF_API_KEY and GITHUB_OAUTH (not the newer CF_API_TOKEN/ + # GITHUB_API_TOKEN names used by packager's unreleased main branch). + CF_API_KEY: ${{ secrets.CF_API_KEY }} + GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Clone project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Package and release + uses: BigWigsMods/packager@v2.5.1 + with: + args: -p 344967 -w 0 -a 0 -m .pkgmeta diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml deleted file mode 100644 index 24b4f35..0000000 --- a/.github/workflows/tag_and_release.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Auto Release - -on: - workflow_dispatch: - pull_request: - types: [closed] - -jobs: - build: - runs-on: ubuntu-latest - if: github.event.pull_request.merged && github.event.pull_request.base.ref == 'master' - steps: - - uses: actions/checkout@v6 - - - name: Tag and prepare release - id: tag_and_prepare_release - uses: K-Phoen/semver-release-action@master - with: - release_branch: ${{ github.event.pull_request.base.ref }} - release_strategy: tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload release notes - if: steps.tag_and_prepare_release.outputs.tag - uses: Roang-zero1/github-create-release-action@v3 - with: - created_tag: ${{ steps.tag_and_prepare_release.outputs.tag }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 0b4ea5b..f1373da 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,18 @@ luac.out # Addon dependencies Libs/* -!Libs/libs.txt +!Libs/.gitkeep + +# BigWigsMods packager local build output +.release/ + +# OS cruft +.DS_Store +Thumbs.db +desktop.ini + +# Editor/backup cruft +*.tmp +*.bak +*.orig +*.log diff --git a/.luacheckrc b/.luacheckrc index 78af5e6..c4190d4 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -7,14 +7,11 @@ max_string_line_length = false max_comment_line_length = false exclude_files = { - "libs/", + "Libs/", ".luacheckrc" } ignore = { - "111", -- Setting an undefined global variable. - "112", -- Mutating an undefined global variable. - "113", -- Accessing an undefined global variable. "211", -- Unused local variable "212", -- Unused argument "213", -- Unused loop variable @@ -24,6 +21,98 @@ ignore = { "542", -- empty if branch } -globals = {} +-- Globals this addon itself defines/mutates fields on (not just reads). +globals = { + "PersonalPlayerNotes", + + -- Legacy Shitlist addon SavedVariables: migration clears old fields. + "ShitlistDB", + + -- Blizzard's static popup registry: this addon registers its own dialog. + "StaticPopupDialogs", +} + +-- WoW client API / Ace3 environment globals this addon reads. Kept explicit +-- (rather than ignoring 111/112/113 project-wide) so Luacheck can still catch +-- real typos in global names elsewhere in the code. +read_globals = { + -- Lua/WoW runtime extras (not part of vanilla Lua 5.1 std) + "time", + "tinsert", + "tremove", + + -- Client/build identification + "WOW_PROJECT_ID", + "WOW_PROJECT_MAINLINE", + + -- Modern namespaced APIs + "C_AddOns", + "C_UI", + + -- Legacy equivalents of the namespaced APIs above (older clients) + "LoadAddOn", + "DisableAddOn", + "ReloadUI", + + -- Ace3 library loader (provided by Libs/LibStub, embedded via embeds.xml) + "LibStub", + + -- Menu / dropdown / unit popup APIs (modern and legacy) + "Menu", + "UnitPopup_ShowMenu", + "UIDROPDOWNMENU_MENU_LEVEL", + "UIDROPDOWNMENU_MENU_VALUE", + "UIDropDownMenu_AddButton", + "UIDropDownMenu_CreateInfo", + + -- Tooltip APIs (modern and legacy) + "TooltipDataProcessor", + "Enum", + "GameTooltip", + + -- Unit/realm info + "UnitIsPlayer", + "UnitName", + "UnitFullName", + "GetRealmName", + + -- Static popups + "StaticPopup_Show", + + -- Settings/options panel + "Settings", + "SettingsPanel", + "GameMenuFrame", + "HideUIPanel", + + -- Input state + "IsShiftKeyDown", + "IsControlKeyDown", + + -- Sound + "PlaySoundFile", +} + +-- The Tests/ suite intentionally pokes _G to stub out WoW/Ace3 globals so +-- the addon's pure-logic helpers can be exercised with plain lua5.1, outside +-- of the WoW client. Scope those mock globals to the test files only, +-- instead of loosening the rules for the whole project. +files["Tests/"] = { + globals = { + "LibStub", + "PersonalPlayerNotes", + "Menu", + "TooltipDataProcessor", + "Enum", + "Settings", + "C_AddOns", + "C_UI", + "ShitlistDB", + "StaticPopupDialogs", + "StaticPopup_Show", + "PlaySoundFile", + "tinsert", + "tremove", + }, +} -read_globals = {} diff --git a/.pkgmeta b/.pkgmeta index f1f025e..03fca9a 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -12,29 +12,31 @@ ignore: - .gitignore - .gitkeep - .luacheckrc + - .stylua.toml - .vscode - .github - .pkgmeta + - Makefile required-dependencies: externals: - Libs/AceAddon-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceAddon-3.0 - Libs/AceComm-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceComm-3.0 - Libs/AceConfig-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceConfig-3.0 - Libs/AceConsole-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceConsole-3.0 - Libs/AceDB-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceDB-3.0 - Libs/AceDBOptions-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceDBOptions-3.0 - Libs/AceEvent-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceEvent-3.0 - Libs/AceGUI-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceGUI-3.0 - Libs/AceHook-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceHook-3.0 - Libs/AceLocale-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceLocale-3.0 - Libs/AceSerializer-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceSerializer-3.0 - Libs/AceTab-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceTab-3.0 - Libs/AceTimer-3.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/AceTimer-3.0 - Libs/CallbackHandler-1.0: svn://svn.wowace.com/wow/ace3/mainline/trunk/CallbackHandler-1.0 - Libs/LibDBIcon-1.0: svn://svn.wowace.com/wow/libdbicon-1-0/mainline/trunk/LibDBIcon-1.0 - Libs/LibStub: svn://svn.wowace.com/wow/libstub/mainline/tags/1.0 + Libs/AceAddon-3.0: https://repos.wowace.com/wow/ace3/trunk/AceAddon-3.0 + Libs/AceComm-3.0: https://repos.wowace.com/wow/ace3/trunk/AceComm-3.0 + Libs/AceConfig-3.0: https://repos.wowace.com/wow/ace3/trunk/AceConfig-3.0 + Libs/AceConsole-3.0: https://repos.wowace.com/wow/ace3/trunk/AceConsole-3.0 + Libs/AceDB-3.0: https://repos.wowace.com/wow/ace3/trunk/AceDB-3.0 + Libs/AceDBOptions-3.0: https://repos.wowace.com/wow/ace3/trunk/AceDBOptions-3.0 + Libs/AceEvent-3.0: https://repos.wowace.com/wow/ace3/trunk/AceEvent-3.0 + Libs/AceGUI-3.0: https://repos.wowace.com/wow/ace3/trunk/AceGUI-3.0 + Libs/AceHook-3.0: https://repos.wowace.com/wow/ace3/trunk/AceHook-3.0 + Libs/AceLocale-3.0: https://repos.wowace.com/wow/ace3/trunk/AceLocale-3.0 + Libs/AceSerializer-3.0: https://repos.wowace.com/wow/ace3/trunk/AceSerializer-3.0 + Libs/AceTab-3.0: https://repos.wowace.com/wow/ace3/trunk/AceTab-3.0 + Libs/AceTimer-3.0: https://repos.wowace.com/wow/ace3/trunk/AceTimer-3.0 + Libs/CallbackHandler-1.0: https://repos.wowace.com/wow/ace3/trunk/CallbackHandler-1.0 + Libs/LibDBIcon-1.0: https://repos.wowace.com/wow/libdbicon-1-0/trunk/LibDBIcon-1.0 + Libs/LibStub: https://repos.wowace.com/wow/libstub/trunk Libs/LibDataBroker-1.1: url: http://github.com/tekkub/libdatabroker-1-1.git tag: latest diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..dcb52ec --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,16 @@ +### StyLua configuration for PersonalPlayerNotes. +### https://github.com/JohnnyMorganz/StyLua +### +### Matches the repo's existing style (4-space indent, double-quoted strings) +### as closely as possible instead of imposing a new one. Run `make format` to +### apply, or `make format-check` to verify without writing (same as CI). + +column_width = 120 +line_endings = "Windows" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" + +[sort_requires] +enabled = false diff --git a/.vscode/settings.json b/.vscode/settings.json index c52a2b0..99907ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,7 +35,9 @@ "SettingsPanel", "GameMenuFrame", "UIDROPDOWNMENU_MENU_VALUE", - "ShitlistDB" + "ShitlistDB", + "ReloadUI", + "UnitPopup_ShowMenu" ], "Lua.runtime.builtin": { "basic": "disable", @@ -49,6 +51,6 @@ "utf8": "disable" }, "Lua.workspace.library": [ - "~\\.vscode\\extensions\\ketho.wow-api-0.17.4\\Annotations" + "~\\.vscode\\extensions\\ketho.wow-api-0.22.3\\Annotations\\Core" ], } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..493aad4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,53 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Install Local Addon", + "detail": "Link this checkout into your WoW AddOns folder(s) and fetch the Libs/ dependencies.", + "type": "shell", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/Tools/Install-LocalAddon.ps1" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "Update Local Addon Libs", + "detail": "Re-fetch every library in Libs/ from .pkgmeta, even if already present.", + "type": "shell", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/Tools/Install-LocalAddon.ps1", + "-SkipLink", + "-UpdateLibs" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "Uninstall Local Addon", + "detail": "Remove the AddOns folder junction(s) created by 'Install Local Addon'. Leaves Libs/ alone.", + "type": "shell", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/Tools/Install-LocalAddon.ps1", + "-Uninstall" + ], + "group": "build", + "problemMatcher": [] + } + ] +} diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 4ae82e9..24d83e9 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -9,98 +9,98 @@ local L = LibStub("AceLocale-3.0"):NewLocale("PersonalPlayerNotes", "enUS", true -- GOLDENROD |cFFDAA520 -- TAN |cFFD2B48C -L["PPN"] = "Personal Player Notes"; -L["PPN_DEBUG"] = "|cffff0000[Personal Player Notes]|cffffffff"; -L["PPN_PRINT"] = "|cffffd700[Personal Player Notes]|cffffffff"; +L["PPN"] = "Personal Player Notes" +L["PPN_DEBUG"] = "|cffff0000[Personal Player Notes]|cffffffff" +L["PPN_PRINT"] = "|cffffd700[Personal Player Notes]|cffffffff" -L["PPN_NA"] = "N/A"; +L["PPN_NA"] = "N/A" -L["PPN_DISABLE"] = "Unloading..."; +L["PPN_DISABLE"] = "Unloading..." -L["PPN_MENU_TITLE"] = "Personal Player Notes"; -L["PPN_MENU_SETTINGS"] = "Options"; -L["PPN_MENU_REASONS"] = "Reasons"; -L["PPN_MENU_LISTED_PLAYERS"] = "Listed Players"; -L["PPN_MENU_PROFILES"] = "Profiles"; +L["PPN_MENU_TITLE"] = "Personal Player Notes" +L["PPN_MENU_SETTINGS"] = "Options" +L["PPN_MENU_REASONS"] = "Reasons" +L["PPN_MENU_LISTED_PLAYERS"] = "Listed Players" +L["PPN_MENU_PROFILES"] = "Profiles" -L["PPN_CONFIG_LOADING"] = "Loading..."; -L["PPN_CONFIG_LOADED"] = "Loaded."; -L["PPN_CONFIG_VERSION"] = "Version:"; -L["PPN_CONFIG_REASONS"] = "Reasons:"; -L["PPN_CONFIG_LISTEDPLAYERS"] = "Players:"; -L["PPN_CONFIG_REFRESH"] = "Reloading configuration..."; -L["PPN_CONFIG_CHECK_OLD_DATA"] = "Checking for old player data..."; -L["PPN_CONFIG_MIGRATE_OLD_DATA"] = "Migrating profiles from Shitlist."; -L["PPN_CONFIG_ADDED_OLD_DATA"] = "Added old player:"; -L["PPN_CONFIG_DUPLICATE_DATA"] = "Found duplicate:"; -L["PPN_CONFIG_MIGRATE"] = "Do you want to migrate profiles from Shitlist?"; -L["PPN_CONFIG_MIGRATE_YES"] = "Yes"; -L["PPN_CONFIG_MIGRATE_NO"] = "No"; -L["PPN_CONFIG_MIGRATE_DONE"] = "Profiles from Shitlist has been migrated."; +L["PPN_CONFIG_LOADING"] = "Loading..." +L["PPN_CONFIG_LOADED"] = "Loaded." +L["PPN_CONFIG_VERSION"] = "Version:" +L["PPN_CONFIG_REASONS"] = "Reasons:" +L["PPN_CONFIG_LISTEDPLAYERS"] = "Players:" +L["PPN_CONFIG_REFRESH"] = "Reloading configuration..." +L["PPN_CONFIG_CHECK_OLD_DATA"] = "Checking for old player data..." +L["PPN_CONFIG_MIGRATE_OLD_DATA"] = "Migrating profiles from Shitlist." +L["PPN_CONFIG_ADDED_OLD_DATA"] = "Added old player:" +L["PPN_CONFIG_DUPLICATE_DATA"] = "Found duplicate:" +L["PPN_CONFIG_MIGRATE"] = "Do you want to migrate profiles from Shitlist?" +L["PPN_CONFIG_MIGRATE_YES"] = "Yes" +L["PPN_CONFIG_MIGRATE_NO"] = "No" +L["PPN_CONFIG_MIGRATE_DONE"] = "Profiles from Shitlist has been migrated." -L["PPN_INFO_COMMANDS_TITLE"] = "Commands"; -L["PPN_INFO_COMMANDS_DESC"] = "All available slash commands."; -L["PPN_INFO_COMMANDS_1"] = "|cFFD2B48C/ppn|cffffffff - Open Blizzard options."; -L["PPN_INFO_COMMANDS_2"] = "|cFFD2B48C/ppno|cffffffff or |cFFD2B48C/ppnoptions|cffffffff - Change options."; -L["PPN_INFO_COMMANDS_3"] = "|cFFD2B48C/ppnr|cffffffff or |cFFD2B48C/ppnreasons|cffffffff - Edit reasons."; -L["PPN_INFO_COMMANDS_4"] = "|cFFD2B48C/ppnp|cffffffff or |cFFD2B48C/ppnplayers|cffffffff - Edit player information."; -L["PPN_INFO_COMMANDS_5"] = "|cFFD2B48C/ppnm|cffffffff or |cFFD2B48C/ppnminimap|cffffffff - Toggle minimap icon."; -L["PPN_INFO_ABOUT_TITLE"] = "About"; -L["PPN_INFO_ABOUT_VERSION"] = "Version"; -L["PPN_INFO_ABOUT_AUTHOR"] = "Author"; -L["PPN_INFO_ABOUT_CATEGORY"] = "Category"; -L["PPN_INFO_ABOUT_LOCALIZATION"] = "Localizations"; -L["PPN_INFO_ABOUT_LICENSE"] = "License"; -L["PPN_INFO_ABOUT_WEB"] = "Website"; +L["PPN_INFO_COMMANDS_TITLE"] = "Commands" +L["PPN_INFO_COMMANDS_DESC"] = "All available slash commands." +L["PPN_INFO_COMMANDS_1"] = "|cFFD2B48C/ppn|cffffffff - Open Blizzard options." +L["PPN_INFO_COMMANDS_2"] = "|cFFD2B48C/ppno|cffffffff or |cFFD2B48C/ppnoptions|cffffffff - Change options." +L["PPN_INFO_COMMANDS_3"] = "|cFFD2B48C/ppnr|cffffffff or |cFFD2B48C/ppnreasons|cffffffff - Edit reasons." +L["PPN_INFO_COMMANDS_4"] = "|cFFD2B48C/ppnp|cffffffff or |cFFD2B48C/ppnplayers|cffffffff - Edit player information." +L["PPN_INFO_COMMANDS_5"] = "|cFFD2B48C/ppnm|cffffffff or |cFFD2B48C/ppnminimap|cffffffff - Toggle minimap icon." +L["PPN_INFO_ABOUT_TITLE"] = "About" +L["PPN_INFO_ABOUT_VERSION"] = "Version" +L["PPN_INFO_ABOUT_AUTHOR"] = "Author" +L["PPN_INFO_ABOUT_CATEGORY"] = "Category" +L["PPN_INFO_ABOUT_LOCALIZATION"] = "Localizations" +L["PPN_INFO_ABOUT_LICENSE"] = "License" +L["PPN_INFO_ABOUT_WEB"] = "Website" -L["PPN_SETTINGS"] = "Options"; -L["PPN_SETTINGS_TITLE"] = "Personal Player Notes - Options"; -L["PPN_SETTINGS_MINIMAP"] = "Minimap"; -L["PPN_SETTINGS_MINIMAP_ICON"] = "Hide the icon."; -L["PPN_SETTINGS_MINIMAP_ICON_DESC"] = "Show or hide the minimap icon."; -L["PPN_SETTINGS_MINIMAP_POS"] = "Position"; -L["PPN_SETTINGS_MINIMAP_POS_DESC"] = "Set the position of the minimap icon."; +L["PPN_SETTINGS"] = "Options" +L["PPN_SETTINGS_TITLE"] = "Personal Player Notes - Options" +L["PPN_SETTINGS_MINIMAP"] = "Minimap" +L["PPN_SETTINGS_MINIMAP_ICON"] = "Hide the icon." +L["PPN_SETTINGS_MINIMAP_ICON_DESC"] = "Show or hide the minimap icon." +L["PPN_SETTINGS_MINIMAP_POS"] = "Position" +L["PPN_SETTINGS_MINIMAP_POS_DESC"] = "Set the position of the minimap icon." -L["PPN_SETTINGS_ALERT"] = "Alert"; -L["PPN_SETTINGS_ALERT_DESC"] = "Alert when a listed player is found."; -L["PPN_SETTINGS_ALERT_ENABLED"] = "Enabled"; -L["PPN_SETTINGS_ALERT_ENABLED_DESC"] = ""; -L["PPN_SETTINGS_ALERT_SOUNDS"] = "Sounds"; -L["PPN_SETTINGS_ALERT_SOUNDS_DESC"] = "Select the sound to be played."; -L["PPN_SETTINGS_ALERT_DELAY"] = "Delay"; -L["PPN_SETTINGS_ALERT_DELAY_DESC"] = "How many seconds to sleep before a new alert for the same player is played."; +L["PPN_SETTINGS_ALERT"] = "Alert" +L["PPN_SETTINGS_ALERT_DESC"] = "Alert when a listed player is found." +L["PPN_SETTINGS_ALERT_ENABLED"] = "Enabled" +L["PPN_SETTINGS_ALERT_ENABLED_DESC"] = "" +L["PPN_SETTINGS_ALERT_SOUNDS"] = "Sounds" +L["PPN_SETTINGS_ALERT_SOUNDS_DESC"] = "Select the sound to be played." +L["PPN_SETTINGS_ALERT_DELAY"] = "Delay" +L["PPN_SETTINGS_ALERT_DELAY_DESC"] = "How many seconds to sleep before a new alert for the same player is played." -L["PPN_REASONS_TITLE"] = "Personal Player Notes - Reasons"; -L["PPN_REASONS"] = "Reasons"; -L["PPN_REASON"] = "Reason"; -L["PPN_REASON_DESCRIPTION"] = "Here you can Edit, Add or Remove differrent reasons.\n"; -L["PPN_REASON_REMOVE"] = "Remove Reason"; -L["PPN_REASON_REMOVE_CONFIRMATION"] = "Do you really want to remove reason:\n|cffffd700"; -L["PPN_REASON_COLOR"] = "Select a color"; +L["PPN_REASONS_TITLE"] = "Personal Player Notes - Reasons" +L["PPN_REASONS"] = "Reasons" +L["PPN_REASON"] = "Reason" +L["PPN_REASON_DESCRIPTION"] = "Here you can Edit, Add or Remove differrent reasons.\n" +L["PPN_REASON_REMOVE"] = "Remove Reason" +L["PPN_REASON_REMOVE_CONFIRMATION"] = "Do you really want to remove reason:\n|cffffd700" +L["PPN_REASON_COLOR"] = "Select a color" L["PPN_REASON_ALERT_ENABLED"] = "Alert" L["PPN_REASON_ALERT_ENABLED_DESC"] = "Toggle sound effect for all players with this reason." -L["PPN_DEFAULT_REASON"] = "None"; +L["PPN_DEFAULT_REASON"] = "None" -L["PPN_LISTED_PLAYERS_TITLE"] = "Personal Player Notes - Listed Players"; -L["PPN_LISTED_PLAYERS"] = "Listed Players"; -L["PPN_LISTED_PLAYER"] = "Listed Player"; -L["PPN_LISTED_PLAYER_REMOVE"] = "Remove Player"; -L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] = "Do you really want to remove player:\n|cffffd700"; -L["PPN_LISTED_PLAYER_NAME"] = "Player Name"; -L["PPN_LISTED_PLAYER_REALM"] = "Realm"; -L["PPN_LISTED_PLAYER_REASON"] = "Reason"; -L["PPN_LISTED_PLAYER_DESCRIPTION"] = "Description"; -L["PPN_LISTED_PLAYER_COLOR"] = "Color"; +L["PPN_LISTED_PLAYERS_TITLE"] = "Personal Player Notes - Listed Players" +L["PPN_LISTED_PLAYERS"] = "Listed Players" +L["PPN_LISTED_PLAYER"] = "Listed Player" +L["PPN_LISTED_PLAYER_REMOVE"] = "Remove Player" +L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] = "Do you really want to remove player:\n|cffffd700" +L["PPN_LISTED_PLAYER_NAME"] = "Player Name" +L["PPN_LISTED_PLAYER_REALM"] = "Realm" +L["PPN_LISTED_PLAYER_REASON"] = "Reason" +L["PPN_LISTED_PLAYER_DESCRIPTION"] = "Description" +L["PPN_LISTED_PLAYER_COLOR"] = "Color" L["PPN_LISTED_PLAYER_ALERT_ENABLED"] = "Alert" L["PPN_LISTED_PLAYER_ALERT_ENABLED_DESC"] = -"Toggle sound effect for a specific player. If a sound effect is disabled on a pre defined reason then this has no effect." -L["PPN_LISTED_PLAYERS_EXAMPLE_NAME"] = "Example"; -L["PPN_LISTED_PLAYERS_EXAMPLE_REALM"] = "Silvermoon"; -L["PPN_LISTED_PLAYERS_EXAMPLE_DESC"] = "None"; + "Toggle sound effect for a specific player. If a sound effect is disabled on a pre defined reason then this has no effect." +L["PPN_LISTED_PLAYERS_EXAMPLE_NAME"] = "Example" +L["PPN_LISTED_PLAYERS_EXAMPLE_REALM"] = "Silvermoon" +L["PPN_LISTED_PLAYERS_EXAMPLE_DESC"] = "None" -L["PPN_POPUP_ADD"] = "Add Player"; -L["PPN_POPUP_EDIT"] = "Edit Player"; -L["PPN_POPUP_NEW_ADDED"] = "Added"; +L["PPN_POPUP_ADD"] = "Add Player" +L["PPN_POPUP_EDIT"] = "Edit Player" +L["PPN_POPUP_NEW_ADDED"] = "Added" L["PPN_MINIMAP_TOOLTIP_TITLE"] = "Personal Player Notes" L["PPN_MINIMAP_TOOLTIP_RIGHT_CLICK"] = "|cFFD2B48C Right-Click|cff00ff00 to open Blizzard options." diff --git a/Locales/zhCN.lua b/Locales/zhCN.lua index 8847d3b..8cd211c 100644 --- a/Locales/zhCN.lua +++ b/Locales/zhCN.lua @@ -1,5 +1,7 @@ local L = LibStub("AceLocale-3.0"):NewLocale("PersonalPlayerNotes", "zhCN") -if not L then return end +if not L then + return +end -- White |cffffffff -- Red |cffff0000 @@ -10,97 +12,101 @@ if not L then return end -- GOLDENROD |cFFDAA520 -- TAN |cFFD2B48C -L["PPN"] = "关注名单"; -L["PPN_DEBUG"] = "|cffff0000[关注名单]|cffffffff"; -L["PPN_PRINT"] = "|cffffd700[关注名单]|cffffffff"; +L["PPN"] = "关注名单" +L["PPN_DEBUG"] = "|cffff0000[关注名单]|cffffffff" +L["PPN_PRINT"] = "|cffffd700[关注名单]|cffffffff" -L["PPN_NA"] = "不适用"; +L["PPN_NA"] = "不适用" -L["PPN_DISABLE"] = "正在禁用..."; +L["PPN_DISABLE"] = "正在禁用..." -L["PPN_MENU_TITLE"] = "Personal Player Notes"; -L["PPN_MENU_SETTINGS"] = "设置选项"; -L["PPN_MENU_REASONS"] = "分组分类设置"; -L["PPN_MENU_LISTED_PLAYERS"] = "已关注的玩家"; -L["PPN_MENU_PROFILES"] = "配置文件"; +L["PPN_MENU_TITLE"] = "Personal Player Notes" +L["PPN_MENU_SETTINGS"] = "设置选项" +L["PPN_MENU_REASONS"] = "分组分类设置" +L["PPN_MENU_LISTED_PLAYERS"] = "已关注的玩家" +L["PPN_MENU_PROFILES"] = "配置文件" -L["PPN_CONFIG_LOADING"] = "正在加载..."; -L["PPN_CONFIG_LOADED"] = "已加载."; -L["PPN_CONFIG_VERSION"] = "版本:"; -L["PPN_CONFIG_REASONS"] = "分组分类:"; -L["PPN_CONFIG_LISTEDPLAYERS"] = "玩家名字:"; -L["PPN_CONFIG_REFRESH"] = "正在重新加载配置..."; -L["PPN_CONFIG_CHECK_OLD_DATA"] = "正在检查历史数据..."; -L["PPN_CONFIG_ADDED_OLD_DATA"] = "已添加历史数据:"; -L["PPN_CONFIG_DUPLICATE_DATA"] = "找到重复数据:"; -L["PPN_CONFIG_MIGRATE"] = "ShitList插件现已更名为Personal Player Notes. 你想把原Shitlist插件的配置文件迁移至Personal Player Notes吗?"; -L["PPN_CONFIG_MIGRATE_YES"] = "是"; -L["PPN_CONFIG_MIGRATE_NO"] = "否"; -L["PPN_CONFIG_MIGRATE_DONE"] = "Shitlist配置文件已迁移至Personal Player Notes."; +L["PPN_CONFIG_LOADING"] = "正在加载..." +L["PPN_CONFIG_LOADED"] = "已加载." +L["PPN_CONFIG_VERSION"] = "版本:" +L["PPN_CONFIG_REASONS"] = "分组分类:" +L["PPN_CONFIG_LISTEDPLAYERS"] = "玩家名字:" +L["PPN_CONFIG_REFRESH"] = "正在重新加载配置..." +L["PPN_CONFIG_CHECK_OLD_DATA"] = "正在检查历史数据..." +L["PPN_CONFIG_ADDED_OLD_DATA"] = "已添加历史数据:" +L["PPN_CONFIG_DUPLICATE_DATA"] = "找到重复数据:" +L["PPN_CONFIG_MIGRATE"] = + "ShitList插件现已更名为Personal Player Notes. 你想把原Shitlist插件的配置文件迁移至Personal Player Notes吗?" +L["PPN_CONFIG_MIGRATE_YES"] = "是" +L["PPN_CONFIG_MIGRATE_NO"] = "否" +L["PPN_CONFIG_MIGRATE_DONE"] = "Shitlist配置文件已迁移至Personal Player Notes." -L["PPN_INFO_COMMANDS_TITLE"] = "命令"; -L["PPN_INFO_COMMANDS_DESC"] = "所有可用的斜杠命令"; -L["PPN_INFO_COMMANDS_1"] = "|cFFD2B48C/ppn|cffffffff - Open Blizzard options."; -L["PPN_INFO_COMMANDS_2"] = "|cFFD2B48C/ppno|cffffffff or |cFFD2B48C/ppnoptions|cffffffff - 更改选项."; -L["PPN_INFO_COMMANDS_3"] = "|cFFD2B48C/ppnr|cffffffff or |cFFD2B48C/ppnreasons|cffffffff - 编辑分组分类."; -L["PPN_INFO_COMMANDS_4"] = "|cFFD2B48C/ppnp|cffffffff or |cFFD2B48C/ppnplayers|cffffffff - 编辑玩家信息."; -L["PPN_INFO_COMMANDS_5"] = "|cFFD2B48C/ppnm|cffffffff or |cFFD2B48C/ppnminimap|cffffffff - 小地图图标开关."; -L["PPN_INFO_ABOUT_TITLE"] = "关于"; -L["PPN_INFO_ABOUT_VERSION"] = "版本"; -L["PPN_INFO_ABOUT_AUTHOR"] = "作者"; -L["PPN_INFO_ABOUT_CATEGORY"] = "插件类型"; -L["PPN_INFO_ABOUT_LOCALIZATION"] = "本地化"; -L["PPN_INFO_ABOUT_LICENSE"] = "许可证"; -L["PPN_INFO_ABOUT_WEB"] = "发布网站"; +L["PPN_INFO_COMMANDS_TITLE"] = "命令" +L["PPN_INFO_COMMANDS_DESC"] = "所有可用的斜杠命令" +L["PPN_INFO_COMMANDS_1"] = "|cFFD2B48C/ppn|cffffffff - Open Blizzard options." +L["PPN_INFO_COMMANDS_2"] = "|cFFD2B48C/ppno|cffffffff or |cFFD2B48C/ppnoptions|cffffffff - 更改选项." +L["PPN_INFO_COMMANDS_3"] = "|cFFD2B48C/ppnr|cffffffff or |cFFD2B48C/ppnreasons|cffffffff - 编辑分组分类." +L["PPN_INFO_COMMANDS_4"] = "|cFFD2B48C/ppnp|cffffffff or |cFFD2B48C/ppnplayers|cffffffff - 编辑玩家信息." +L["PPN_INFO_COMMANDS_5"] = "|cFFD2B48C/ppnm|cffffffff or |cFFD2B48C/ppnminimap|cffffffff - 小地图图标开关." +L["PPN_INFO_ABOUT_TITLE"] = "关于" +L["PPN_INFO_ABOUT_VERSION"] = "版本" +L["PPN_INFO_ABOUT_AUTHOR"] = "作者" +L["PPN_INFO_ABOUT_CATEGORY"] = "插件类型" +L["PPN_INFO_ABOUT_LOCALIZATION"] = "本地化" +L["PPN_INFO_ABOUT_LICENSE"] = "许可证" +L["PPN_INFO_ABOUT_WEB"] = "发布网站" -L["PPN_SETTINGS"] = "设置选项"; -L["PPN_SETTINGS_TITLE"] = "关注名单 - 设置选项"; -L["PPN_SETTINGS_MINIMAP"] = "小地图图标"; -L["PPN_SETTINGS_MINIMAP_ICON"] = "隐藏图标"; -L["PPN_SETTINGS_MINIMAP_ICON_DESC"] = "显示或隐藏小地图图标"; -L["PPN_SETTINGS_MINIMAP_POS"] = "图标位置"; -L["PPN_SETTINGS_MINIMAP_POS_DESC"] = "调整小地图图标位置"; +L["PPN_SETTINGS"] = "设置选项" +L["PPN_SETTINGS_TITLE"] = "关注名单 - 设置选项" +L["PPN_SETTINGS_MINIMAP"] = "小地图图标" +L["PPN_SETTINGS_MINIMAP_ICON"] = "隐藏图标" +L["PPN_SETTINGS_MINIMAP_ICON_DESC"] = "显示或隐藏小地图图标" +L["PPN_SETTINGS_MINIMAP_POS"] = "图标位置" +L["PPN_SETTINGS_MINIMAP_POS_DESC"] = "调整小地图图标位置" -L["PPN_SETTINGS_ALERT"] = "提示音"; -L["PPN_SETTINGS_ALERT_DESC"] = "配置当发现已关注的玩家时播放的提示音"; -L["PPN_SETTINGS_ALERT_ENABLED"] = "启用提示音"; -L["PPN_SETTINGS_ALERT_ENABLED_DESC"] = "勾选此选项以启用提示音功能. 当发现已关注的玩家时, 将播放配置的提示音."; -L["PPN_SETTINGS_ALERT_SOUNDS"] = "声音列表"; -L["PPN_SETTINGS_ALERT_SOUNDS_DESC"] = "选择当发现已关注的玩家时要播放的声音."; -L["PPN_SETTINGS_ALERT_DELAY"] = "提示间隔"; -L["PPN_SETTINGS_ALERT_DELAY_DESC"] = "设置在为同一玩家播放下一个提示音之前需要等待的时间(秒). 这有助于防止在短时间内重复播放相同的提示音."; +L["PPN_SETTINGS_ALERT"] = "提示音" +L["PPN_SETTINGS_ALERT_DESC"] = "配置当发现已关注的玩家时播放的提示音" +L["PPN_SETTINGS_ALERT_ENABLED"] = "启用提示音" +L["PPN_SETTINGS_ALERT_ENABLED_DESC"] = + "勾选此选项以启用提示音功能. 当发现已关注的玩家时, 将播放配置的提示音." +L["PPN_SETTINGS_ALERT_SOUNDS"] = "声音列表" +L["PPN_SETTINGS_ALERT_SOUNDS_DESC"] = "选择当发现已关注的玩家时要播放的声音." +L["PPN_SETTINGS_ALERT_DELAY"] = "提示间隔" +L["PPN_SETTINGS_ALERT_DELAY_DESC"] = + "设置在为同一玩家播放下一个提示音之前需要等待的时间(秒). 这有助于防止在短时间内重复播放相同的提示音." -L["PPN_REASONS_TITLE"] = "关注名单 - 分组分类设置"; -L["PPN_REASONS"] = "现有分组分类"; -L["PPN_REASON"] = "添加或编辑分组分类"; -L["PPN_REASON_DESCRIPTION"] = "添加,编辑或删除关注名单分组分类\n"; -L["PPN_REASON_REMOVE"] = "删除分组"; -L["PPN_REASON_REMOVE_CONFIRMATION"] = "您确定要删除:\n|cffffd700"; -L["PPN_REASON_COLOR"] = "选择颜色(此分组在鼠标提示中的颜色)"; +L["PPN_REASONS_TITLE"] = "关注名单 - 分组分类设置" +L["PPN_REASONS"] = "现有分组分类" +L["PPN_REASON"] = "添加或编辑分组分类" +L["PPN_REASON_DESCRIPTION"] = "添加,编辑或删除关注名单分组分类\n" +L["PPN_REASON_REMOVE"] = "删除分组" +L["PPN_REASON_REMOVE_CONFIRMATION"] = "您确定要删除:\n|cffffd700" +L["PPN_REASON_COLOR"] = "选择颜色(此分组在鼠标提示中的颜色)" L["PPN_REASON_ALERT_ENABLED"] = "启用分组提示音" -L["PPN_REASON_ALERT_ENABLED_DESC"] = "当前分组提示音效开关. 启用后, 当发现此分组内的玩家时, 将播放配置的提示音." -L["PPN_DEFAULT_REASON"] = "默认"; +L["PPN_REASON_ALERT_ENABLED_DESC"] = + "当前分组提示音效开关. 启用后, 当发现此分组内的玩家时, 将播放配置的提示音." +L["PPN_DEFAULT_REASON"] = "默认" -L["PPN_LISTED_PLAYERS_TITLE"] = "关注名单 - 已关注的玩家"; -L["PPN_LISTED_PLAYERS"] = "已关注的玩家"; -L["PPN_LISTED_PLAYER"] = "已关注的玩家"; -L["PPN_LISTED_PLAYER_REMOVE"] = "取消关注玩家"; -L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] = "你确定要从关注名单里删除:\n|cffffd700"; -L["PPN_LISTED_PLAYER_NAME"] = "玩家名称"; -L["PPN_LISTED_PLAYER_REALM"] = "所在服务器"; -L["PPN_LISTED_PLAYER_REASON"] = "分组分类"; -L["PPN_LISTED_PLAYER_DESCRIPTION"] = "附加描述"; -L["PPN_LISTED_PLAYER_COLOR"] = "文字颜色(此附加描述在鼠标提示中的颜色)"; +L["PPN_LISTED_PLAYERS_TITLE"] = "关注名单 - 已关注的玩家" +L["PPN_LISTED_PLAYERS"] = "已关注的玩家" +L["PPN_LISTED_PLAYER"] = "已关注的玩家" +L["PPN_LISTED_PLAYER_REMOVE"] = "取消关注玩家" +L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] = "你确定要从关注名单里删除:\n|cffffd700" +L["PPN_LISTED_PLAYER_NAME"] = "玩家名称" +L["PPN_LISTED_PLAYER_REALM"] = "所在服务器" +L["PPN_LISTED_PLAYER_REASON"] = "分组分类" +L["PPN_LISTED_PLAYER_DESCRIPTION"] = "附加描述" +L["PPN_LISTED_PLAYER_COLOR"] = "文字颜色(此附加描述在鼠标提示中的颜色)" L["PPN_LISTED_PLAYER_ALERT_ENABLED"] = "启用玩家提示音" L["PPN_LISTED_PLAYER_ALERT_ENABLED_DESC"] = -"为该玩家启用或禁用提示音. 如果该玩家所在的分组分类已禁用了提示音, 则不能开启." -L["PPN_LISTED_PLAYERS_EXAMPLE_NAME"] = "变异骷髅"; -L["PPN_LISTED_PLAYERS_EXAMPLE_REALM"] = "灰烬使者"; -L["PPN_LISTED_PLAYERS_EXAMPLE_DESC"] = "关注名单汉化先行者!RESPECT!"; + "为该玩家启用或禁用提示音. 如果该玩家所在的分组分类已禁用了提示音, 则不能开启." +L["PPN_LISTED_PLAYERS_EXAMPLE_NAME"] = "变异骷髅" +L["PPN_LISTED_PLAYERS_EXAMPLE_REALM"] = "灰烬使者" +L["PPN_LISTED_PLAYERS_EXAMPLE_DESC"] = "关注名单汉化先行者!RESPECT!" -L["PPN_POPUP_ADD"] = "添加到关注名单"; -L["PPN_POPUP_EDIT"] = "编辑关注信息"; -L["PPN_POPUP_NEW_ADDED"] = "已成功添加到关注名单"; +L["PPN_POPUP_ADD"] = "添加到关注名单" +L["PPN_POPUP_EDIT"] = "编辑关注信息" +L["PPN_POPUP_NEW_ADDED"] = "已成功添加到关注名单" L["PPN_MINIMAP_TOOLTIP_TITLE"] = "关注名单" L["PPN_MINIMAP_TOOLTIP_RIGHT_CLICK"] = "|cFFD2B48C 右键点击|cff00ff00 打开插件选项." diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d348205 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +# Local developer workflow for PersonalPlayerNotes. +# +# Mirrors the checks run in .github/workflows/ci.yml so problems are caught +# before pushing. Requires: lua5.1 (or lua), a local `stylua` binary on PATH +# (or set STYLUA=/path/to/stylua), and luacheck on PATH. +# +# On Windows, run these via Git Bash/WSL, or invoke the equivalent commands +# directly (see README.md / CONTRIBUTING.md). + +LUA ?= lua5.1 +STYLUA ?= stylua +LUACHECK ?= luacheck + +LUA_FILES := PersonalPlayerNotes.lua PersonalPlayerNotesConfig.lua PersonalPlayerNotesUtils.lua \ + Tests/test_utils.lua Tests/test_config.lua Tests/test_main.lua Tests/support/bootstrap.lua \ + Locales/enUS.lua Locales/zhCN.lua Tools/validate-toc.lua + +TEST_FILES := Tests/test_utils.lua Tests/test_config.lua Tests/test_main.lua + +.PHONY: format format-check lint test validate build check clean + +format: ## Format all Lua files in-place with StyLua + $(STYLUA) $(LUA_FILES) + +format-check: ## Check formatting without writing changes (same as CI) + $(STYLUA) --check $(LUA_FILES) + +lint: ## Run Luacheck + $(LUACHECK) . + +test: ## Run the pure-Lua unit test suite + @for f in $(TEST_FILES); do \ + echo "-- $$f --"; \ + $(LUA) $$f || exit 1; \ + done + +validate: ## Validate PersonalPlayerNotes.toc (metadata, referenced files, duplicates) + $(LUA) Tools/validate-toc.lua + +build: ## Build a local, unsigned addon zip without uploading anywhere (requires BigWigsMods/packager's release.sh in PATH or .release/) + @if [ ! -f .release/release.sh ]; then \ + echo "Fetching release.sh from BigWigsMods/packager@v2.5.1..."; \ + mkdir -p .release; \ + curl -s -o .release/release.sh https://raw.githubusercontent.com/BigWigsMods/packager/v2.5.1/release.sh; \ + chmod +x .release/release.sh; \ + fi + .release/release.sh -d -p 0 -w 0 -a 0 -m .pkgmeta + +check: format-check lint test validate ## Run every local check (does not build a package) + +clean: ## Remove local build output + rm -rf .release diff --git a/PersonalPlayerNotes.lua b/PersonalPlayerNotes.lua index 243bddb..dbd2371 100644 --- a/PersonalPlayerNotes.lua +++ b/PersonalPlayerNotes.lua @@ -1,17 +1,21 @@ -local personalPlayerNotes = ... -PersonalPlayerNotes = LibStub("AceAddon-3.0"):NewAddon(personalPlayerNotes, "AceConsole-3.0", "AceEvent-3.0", +local personalPlayerNotes = ... +PersonalPlayerNotes = LibStub("AceAddon-3.0"):NewAddon( + personalPlayerNotes, + "AceConsole-3.0", + "AceEvent-3.0", "AceHook-3.0", - "AceTimer-3.0" - , "AceSerializer-3.0") -local L = LibStub("AceLocale-3.0"):GetLocale(personalPlayerNotes, true) -local AceConfig = LibStub("AceConfig-3.0") -local AceConfigDialog = LibStub("AceConfigDialog-3.0") -local AceConfigRegistry = LibStub("AceConfigRegistry-3.0") -local LibDataBroker = LibStub("LibDataBroker-1.1") -local LibDBIcon = LibStub("LibDBIcon-1.0") - -local IS_RETAIL = WOW_PROJECT_ID == WOW_PROJECT_MAINLINE or false; -PersonalPlayerNotes.IsRetail = IS_RETAIL; + "AceTimer-3.0", + "AceSerializer-3.0" +) +local L = LibStub("AceLocale-3.0"):GetLocale(personalPlayerNotes, true) +local AceConfig = LibStub("AceConfig-3.0") +local AceConfigDialog = LibStub("AceConfigDialog-3.0") +local AceConfigRegistry = LibStub("AceConfigRegistry-3.0") +local LibDataBroker = LibStub("LibDataBroker-1.1") +local LibDBIcon = LibStub("LibDBIcon-1.0") + +local IS_RETAIL = WOW_PROJECT_ID == WOW_PROJECT_MAINLINE or false +PersonalPlayerNotes.IsRetail = IS_RETAIL function PersonalPlayerNotes:OnInitialize() -- uses the "Default" profile instead of character-specific profiles @@ -22,36 +26,68 @@ function PersonalPlayerNotes:OnInitialize() self.db.RegisterCallback(self, "OnProfileCopied", "LoadConfig") self.db.RegisterCallback(self, "OnProfileReset", "LoadConfig") + -- fills in any missing SavedVariables fields and runs schema migrations, if needed + self:MigrateSavedVariablesSchema() + -- registers an options table and adds it to the Blizzard options window -- https://www.wowace.com/projects/ace3/pages/api/ace-config-3-0 AceConfig:RegisterOptionsTable("PersonalPlayerNotesSettings Info", self.options.Info) - AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Info", personalPlayerNotes) - - AceConfig:RegisterOptionsTable("PersonalPlayerNotesSettings Options", self.options.Settings, { "ppno", "ppnoptions" }) + -- the 2nd return value is the registered category ID Settings.OpenToCategory() + -- expects (a numeric ID/category object on modern clients) - the addon name + -- string itself is NOT a valid argument, see OpenBlizzardOptions() below + local _, blizOptionsCategoryID = + AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Info", personalPlayerNotes) + self.blizOptionsCategoryID = blizOptionsCategoryID + + AceConfig:RegisterOptionsTable( + "PersonalPlayerNotesSettings Options", + self.options.Settings, + { "ppno", "ppnoptions" } + ) AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Options", L["PPN_MENU_SETTINGS"], personalPlayerNotes) - AceConfig:RegisterOptionsTable("PersonalPlayerNotesSettings Reasons", self.options.Reasons, { "ppnr", "ppnreasons" }) + AceConfig:RegisterOptionsTable( + "PersonalPlayerNotesSettings Reasons", + self.options.Reasons, + { "ppnr", "ppnreasons" } + ) AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Reasons", L["PPN_MENU_REASONS"], personalPlayerNotes) - AceConfig:RegisterOptionsTable("PersonalPlayerNotesSettings Listed_Players", self.options.ListedPlayers, - { "ppnp", "ppnplayers" }) - AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Listed_Players", L["PPN_MENU_LISTED_PLAYERS"], - personalPlayerNotes) + AceConfig:RegisterOptionsTable( + "PersonalPlayerNotesSettings Listed_Players", + self.options.ListedPlayers, + { "ppnp", "ppnplayers" } + ) + AceConfigDialog:AddToBlizOptions( + "PersonalPlayerNotesSettings Listed_Players", + L["PPN_MENU_LISTED_PLAYERS"], + personalPlayerNotes + ) local profiles = LibStub("AceDBOptions-3.0"):GetOptionsTable(self.db) AceConfig:RegisterOptionsTable("PersonalPlayerNotesSettings Profiles", profiles) - AceConfigDialog:AddToBlizOptions("PersonalPlayerNotesSettings Profiles", L["PPN_MENU_PROFILES"], personalPlayerNotes) + AceConfigDialog:AddToBlizOptions( + "PersonalPlayerNotesSettings Profiles", + L["PPN_MENU_PROFILES"], + personalPlayerNotes + ) LibDBIcon:Register(personalPlayerNotes, self:MiniMapIcon(), self.db.profile.minimap) self:RegisterChatCommand("ppn", function() - Settings.OpenToCategory(personalPlayerNotes) + PersonalPlayerNotes:OpenBlizzardOptions() end) self:RegisterChatCommand("ppnm", "ToggleMiniMapIcon") self:RegisterChatCommand("ppnminimap", "ToggleMiniMapIcon") self:RegisterChatCommand("ppndebug", "ToggleDebug") - local loaded, reason = C_AddOns.LoadAddOn("Shitlist") + local loaded + if self:HasModernAddOnAPI() then + loaded = C_AddOns.LoadAddOn("Shitlist") + else + -- Some Classic clients don't expose the C_AddOns namespace yet. + loaded = LoadAddOn("Shitlist") + end if loaded then self:GetOldConfigData() end @@ -64,18 +100,27 @@ function PersonalPlayerNotes:OnEnable() self:Print(L["PPN_CONFIG_REASONS"], _G["GREEN_FONT_COLOR_CODE"], #self:GetReasons()) self:Print(L["PPN_CONFIG_LISTEDPLAYERS"], _G["GREEN_FONT_COLOR_CODE"], #self:GetListedPlayers()) - if (self.IsRetail) then - -- New Menu System in Retail 11.0.0 + -- Feature detection instead of a hardcoded Retail/Classic branch: some Classic + -- clients have already picked up the modern Menu/Tooltip APIs, and future + -- clients may too, so we check for the actual API surface instead. + if self:HasModernMenuAPI() then + -- New Menu System, introduced in Retail 11.0.0 -- https://warcraft.wiki.gg/wiki/Patch_11.0.0/API_changes -- https://www.townlong-yak.com/framexml/latest/Blizzard_Menu/11_0_0_MenuImplementationGuide.lua self:DropDownMenuInitialize() - -- Retail 10.0.2 https://wowpedia.fandom.com/wiki/Patch_10.0.2/API_changes#Tooltip_Changes - TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, self.GameTooltip) - else - -- Backwards compatibility Classic + elseif UnitPopup_ShowMenu then + -- Legacy dropdown menu, still used on some Classic clients if not self:IsHooked("UnitPopup_ShowMenu") then self:SecureHook("UnitPopup_ShowMenu", self.UnitPopup_ShowMenu) end + else + self:PrintDebug("No supported unit menu API was found on this client.") + end + + if self:HasModernTooltipAPI() then + -- Retail 10.0.2 https://wowpedia.fandom.com/wiki/Patch_10.0.2/API_changes#Tooltip_Changes + TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, self.GameTooltip) + else GameTooltip:HookScript("OnTooltipSetUnit", self.GameTooltip) end self:Print(L["PPN_CONFIG_LOADED"]) @@ -100,6 +145,11 @@ function PersonalPlayerNotes:LoadConfig() end function PersonalPlayerNotes:GetOldConfigData() + -- Shitlist may be loadable without ever having saved any data (e.g. a fresh account). + if not ShitlistDB then + return + end + local reasons = self:GetReasons() local listedPlayers = self:GetListedPlayers() -- Check if old data exist pre addon 2.0.0 version @@ -116,7 +166,7 @@ function PersonalPlayerNotes:GetOldConfigData() end for key, value in pairs(oldListedPlayers) do - local name, realm = key:match("([^-]+)-([^-]+)") + local name, realm = self:ParseLegacyPlayerKey(key) if name and realm then if not newPlayers[name .. "-" .. realm] then local reason = value[1] @@ -166,7 +216,7 @@ function PersonalPlayerNotes:GetOldConfigData() end -- Move old shitlist profiles data to new Personal Player Notes database - if ShitlistDB and ShitlistDB.profiles then + if ShitlistDB.profiles then self:Print(L["PPN_CONFIG_MIGRATE_OLD_DATA"]) StaticPopupDialogs["MIGRATE_PROFILES"] = { text = L["PPN_CONFIG_MIGRATE"], @@ -180,8 +230,18 @@ function PersonalPlayerNotes:GetOldConfigData() self:Print(L["PPN_CONFIG_LISTEDPLAYERS"], _G["GREEN_FONT_COLOR_CODE"], #self:GetListedPlayers()) PersonalPlayerNotes:Print(L["PPN_CONFIG_MIGRATE_DONE"]) self.db:SetProfile("Default") - C_AddOns.DisableAddOn("Shitlist") - C_UI.Reload() + if self:HasModernAddOnAPI() then + C_AddOns.DisableAddOn("Shitlist") + else + -- Some Classic clients don't expose the C_AddOns namespace yet. + DisableAddOn("Shitlist") + end + if self:HasModernUIReloadAPI() then + C_UI.Reload() + else + -- Some Classic clients don't expose the C_UI namespace yet. + ReloadUI() + end end, timeout = 0, whileDead = true, @@ -201,10 +261,12 @@ function PersonalPlayerNotes:DropDownMenuInitialize() -- retrieve name and realm from wow api local name, realm = UnitName(contextData.unit) -- if the unit is from the same realm then realm is empty, use current realm instead - if realm == nil then realm = GetRealmName() end + if realm == nil then + realm = GetRealmName() + end local listedPlayer = PersonalPlayerNotes:GetListedPlayer(name, realm) - if (not listedPlayer) then + if not listedPlayer then rootDescription:CreateDivider() rootDescription:CreateTitle(L["PPN"]) rootDescription:CreateButton(L["PPN_POPUP_ADD"], function() @@ -245,9 +307,15 @@ function PersonalPlayerNotes:DropDownMenuInitialize() end) end end - Menu.ModifyMenu("MENU_UNIT_PLAYER", function(...) DropDownMenu(...) end) - Menu.ModifyMenu("MENU_UNIT_ENEMY_PLAYER", function(...) DropDownMenu(...) end) - Menu.ModifyMenu("MENU_UNIT_FRIEND", function(...) DropDownMenu(...) end) + Menu.ModifyMenu("MENU_UNIT_PLAYER", function(...) + DropDownMenu(...) + end) + Menu.ModifyMenu("MENU_UNIT_ENEMY_PLAYER", function(...) + DropDownMenu(...) + end) + Menu.ModifyMenu("MENU_UNIT_FRIEND", function(...) + DropDownMenu(...) + end) end -- Classic Deprecated @@ -264,14 +332,16 @@ function PersonalPlayerNotes:UnitPopup_ShowMenu(target, unit, menuList) -- retrieve name and realm from wow api local name, realm = UnitName(unit) -- if the unit is from the same realm then realm is empty, use current realm instead - if realm == nil then realm = GetRealmName() end + if realm == nil then + realm = GetRealmName() + end local listedPlayer = PersonalPlayerNotes:GetListedPlayer(name, realm) PersonalPlayerNotes:PrintDebug("Name: ", name, ", Realm: ", realm) -- Check if this is the root level of the dropdown menu if UIDROPDOWNMENU_MENU_LEVEL == 1 then - if (listedPlayer) then + if listedPlayer then UIDropDownMenu_AddButton({ text = personalPlayerNotes, notCheckable = true, @@ -321,7 +391,7 @@ function PersonalPlayerNotes:UnitPopup_ShowMenu(target, unit, menuList) menuItem.hasArrow = false menuItem.value = listedPlayer menuItem.func = function() - if (listedPlayer) then + if listedPlayer then PersonalPlayerNotes.db.profile.listedPlayer.id = listedPlayer.id PersonalPlayerNotes.db.profile.listedPlayer.name = listedPlayer.name PersonalPlayerNotes.db.profile.listedPlayer.realm = listedPlayer.realm @@ -343,45 +413,73 @@ end function PersonalPlayerNotes:GameTooltip() local _name, unit = self:GetUnit() - if not (unit and UnitIsPlayer(unit)) then return end + if not (unit and UnitIsPlayer(unit)) then + return + end local name, realm = UnitFullName(unit) - if (_name ~= name) then return end + if _name ~= name then + return + end - if (realm == nil) then realm = GetRealmName() end + if realm == nil then + realm = GetRealmName() + end local listedPlayer = PersonalPlayerNotes:GetListedPlayer(name, realm) - if (not listedPlayer) then return end + if not listedPlayer then + return + end local reason = PersonalPlayerNotes:GetReasons()[listedPlayer.reason] local _reason = reason local _listedPlayer = listedPlayer - PersonalPlayerNotes:PrintDebug("|cffff0000|cffffffff Playername:", name, "Realm:", realm, "Reason:", - _reason - .reason, - "Note:", _listedPlayer.description) + PersonalPlayerNotes:PrintDebug( + "|cffff0000|cffffffff Playername:", + name, + "Realm:", + realm, + "Reason:", + _reason.reason, + "Note:", + _listedPlayer.description + ) -- Tooltip if not (_reason.reason == "None" and _listedPlayer.description == "") then self:AddLine("\n") - self:AddDoubleLine(_reason.reason:gsub("None", ""), "|T" .. PersonalPlayerNotes.db.profile.icon .. ":0|t", - _reason.color.r or 1, _reason.color.g or 1, _reason.color.b or 1) - self:AddLine(_listedPlayer.description, _listedPlayer.color.r or 1, _listedPlayer.color.g or 1, - _listedPlayer.color.b or 1, false) + self:AddDoubleLine( + _reason.reason:gsub("None", ""), + "|T" .. PersonalPlayerNotes.db.profile.icon .. ":0|t", + _reason.color.r or 1, + _reason.color.g or 1, + _reason.color.b or 1 + ) + self:AddLine( + _listedPlayer.description, + _listedPlayer.color.r or 1, + _listedPlayer.color.g or 1, + _listedPlayer.color.b or 1, + false + ) end -- Alert local time = time() local alert = PersonalPlayerNotes.db.profile.alert - if (alert.enabled and reason.alert) then - if (listedPlayer.alert and not alert.last[name]) then + if alert.enabled and reason.alert then + if listedPlayer.alert and not alert.last[name] then alert.last[name] = time + alert.delay PersonalPlayerNotes:ScheduleTimer("AlertDelayTimer", alert.delay, name) PersonalPlayerNotes:PlayAlertSoundEffect() - PersonalPlayerNotes:PrintDebug("|cffff0000|cffffffff Sound effect disabled for player", name, "for", + PersonalPlayerNotes:PrintDebug( + "|cffff0000|cffffffff Sound effect disabled for player", + name, + "for", alert.delay, - "seconds.") + "seconds." + ) end end end @@ -400,11 +498,15 @@ function PersonalPlayerNotes:MiniMapIcon() text = L["PPN"], icon = PersonalPlayerNotes.db.profile.icon, OnClick = function(clickedframe, button) - HideUIPanel(SettingsPanel) - HideUIPanel(GameMenuFrame) + if SettingsPanel then + HideUIPanel(SettingsPanel) + end + if GameMenuFrame then + HideUIPanel(GameMenuFrame) + end AceConfigDialog:CloseAll() if button == "RightButton" then - Settings.OpenToCategory(personalPlayerNotes) + PersonalPlayerNotes:OpenBlizzardOptions() elseif button == "LeftButton" then local AceGUI = PersonalPlayerNotes:AceGUIDefaults() if IsShiftKeyDown() then @@ -425,7 +527,8 @@ function PersonalPlayerNotes:MiniMapIcon() OnTooltipShow = function(tooltip) tooltip:AddDoubleLine( "|T" .. PersonalPlayerNotes.db.profile.icon .. ":0|t " .. L["PPN_MINIMAP_TOOLTIP_TITLE"], - PersonalPlayerNotes:GetVersion()) + PersonalPlayerNotes:GetVersion() + ) tooltip:AddLine("\n") tooltip:AddLine(L["PPN_MINIMAP_TOOLTIP_RIGHT_CLICK"]) tooltip:AddLine(L["PPN_MINIMAP_TOOLTIP_LEFT_CLICK"]) diff --git a/PersonalPlayerNotes.toc b/PersonalPlayerNotes.toc index 69eb2c1..f3ea4ac 100644 --- a/PersonalPlayerNotes.toc +++ b/PersonalPlayerNotes.toc @@ -1,7 +1,7 @@ -## Interface: 110002 -## Interface-Classic: 11503 +## Interface: 120007 +## Interface-Classic: 11509 ## Interface-Wrath: 30403 -## Interface-Cata: 40400 +## Interface-Mists: 50504 ## Title : Personal Player Notes ## Notes: Set a personal comment on players and adding it to player information tooltip. ## Notes-zhCN: Set a personal comment on players and adding it to player information tooltip. diff --git a/PersonalPlayerNotesConfig.lua b/PersonalPlayerNotesConfig.lua index 875f8d6..7b74013 100644 --- a/PersonalPlayerNotesConfig.lua +++ b/PersonalPlayerNotesConfig.lua @@ -3,6 +3,7 @@ local L = LibStub("AceLocale-3.0"):GetLocale(personalPlayerNotes, true) PersonalPlayerNotes.defaults = { profile = { + schemaVersion = PersonalPlayerNotes.SCHEMA_VERSION, icon = "Interface\\AddOns\\" .. personalPlayerNotes .. "\\Images\\icon.png", debug = false, minimap = { hide = false, minimapPos = 240 }, @@ -12,12 +13,12 @@ PersonalPlayerNotes.defaults = { sound = 1, sounds = { "alarmbeep", "alarmbuzz", "alarmbuzzer", "alarmdouble" }, last = {}, - time = 0 + time = 0, }, reasons = { - { id = 1, reason = L["PPN_DEFAULT_REASON"], color = { r = 1, g = 1, b = 1 }, alert = false, }, + { id = 1, reason = L["PPN_DEFAULT_REASON"], color = { r = 1, g = 1, b = 1 }, alert = false }, }, - reason = { id = 1, reason = L["PPN_DEFAULT_REASON"], color = { r = 1, g = 1, b = 1 }, alert = false, }, + reason = { id = 1, reason = L["PPN_DEFAULT_REASON"], color = { r = 1, g = 1, b = 1 }, alert = false }, listedPlayer = { id = 1, name = L["PPN_LISTED_PLAYERS_EXAMPLE_NAME"], @@ -37,8 +38,8 @@ PersonalPlayerNotes.defaults = { color = { r = 1, g = 1, b = 1 }, alert = true, }, - } - } + }, + }, } -- https://www.wowace.com/projects/ace3/pages/ace-config-3-0-options-tables @@ -106,52 +107,64 @@ PersonalPlayerNotes.options = { order = 0, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_VERSION"] .. - ": |cffff8c00" .. PersonalPlayerNotes:GetVersion() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_VERSION"] + .. ": |cffff8c00" + .. PersonalPlayerNotes:GetVersion(), }, author = { type = "description", order = 1, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_AUTHOR"] .. - ": |cffffffff" .. PersonalPlayerNotes:GetAuthor() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_AUTHOR"] + .. ": |cffffffff" + .. PersonalPlayerNotes:GetAuthor(), }, category = { type = "description", order = 2, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_CATEGORY"] .. - ": |cffffffff" .. PersonalPlayerNotes:GetCategory() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_CATEGORY"] + .. ": |cffffffff" + .. PersonalPlayerNotes:GetCategory(), }, localizations = { type = "description", order = 3, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_LOCALIZATION"] .. - ": |cffffffff" .. PersonalPlayerNotes:GetLocalizations() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_LOCALIZATION"] + .. ": |cffffffff" + .. PersonalPlayerNotes:GetLocalizations(), }, license = { type = "description", order = 4, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_LICENSE"] .. - ": |cffffffff" .. PersonalPlayerNotes:GetLicense() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_LICENSE"] + .. ": |cffffffff" + .. PersonalPlayerNotes:GetLicense(), }, website = { type = "description", order = 5, width = "full", fontSize = "medium", - name = "|cffffd700" .. L["PPN_INFO_ABOUT_WEB"] .. - ": |cffffffff" .. PersonalPlayerNotes:GetWebsite() + name = "|cffffd700" + .. L["PPN_INFO_ABOUT_WEB"] + .. ": |cffffffff" + .. PersonalPlayerNotes:GetWebsite(), }, - } - } - } + }, + }, + }, }, Settings = { type = "group", @@ -173,12 +186,12 @@ PersonalPlayerNotes.options = { name = L["PPN_SETTINGS_MINIMAP_ICON"], desc = L["PPN_SETTINGS_MINIMAP_ICON_DESC"], get = function(info) - return PersonalPlayerNotes.db.profile.minimap.hide; + return PersonalPlayerNotes.db.profile.minimap.hide end, set = function(info, value) - PersonalPlayerNotes.db.profile.minimap.hide = value; - PersonalPlayerNotes:LoadConfig(); - end + PersonalPlayerNotes.db.profile.minimap.hide = value + PersonalPlayerNotes:LoadConfig() + end, }, minimapPos = { type = "range", @@ -187,11 +200,11 @@ PersonalPlayerNotes.options = { desc = L["PPN_SETTINGS_MINIMAP_POS_DESC"], width = 1.5, get = function(info) - return PersonalPlayerNotes.db.profile.minimap.minimapPos; + return PersonalPlayerNotes.db.profile.minimap.minimapPos end, set = function(info, value) - PersonalPlayerNotes.db.profile.minimap.minimapPos = value; - PersonalPlayerNotes:LoadConfig(); + PersonalPlayerNotes.db.profile.minimap.minimapPos = value + PersonalPlayerNotes:LoadConfig() end, min = 0, max = 360, @@ -211,14 +224,14 @@ PersonalPlayerNotes.options = { description = { type = "description", order = 0, - name = L["PPN_SETTINGS_ALERT_DESC"] + name = L["PPN_SETTINGS_ALERT_DESC"], }, enabled = { type = "toggle", order = 1, name = L["PPN_SETTINGS_ALERT_ENABLED"], desc = L["PPN_SETTINGS_ALERT_ENABLED_DESC"], - width = 0.5 + width = 0.5, }, sounds = { type = "select", @@ -240,11 +253,11 @@ PersonalPlayerNotes.options = { step = 1, name = L["PPN_SETTINGS_ALERT_DELAY"], desc = L["PPN_SETTINGS_ALERT_DELAY_DESC"], - width = 1 - } - } - } - } + width = 1, + }, + }, + }, + }, }, Reasons = { type = "group", @@ -257,7 +270,7 @@ PersonalPlayerNotes.options = { type = "description", order = 0, width = "full", - name = L["PPN_REASON_DESCRIPTION"] + name = L["PPN_REASON_DESCRIPTION"], }, id = { type = "select", @@ -281,16 +294,17 @@ PersonalPlayerNotes.options = { cmdHidden = true, name = L["PPN_REASON_REMOVE"], confirm = function() - return L["PPN_REASON_REMOVE_CONFIRMATION"] .. - PersonalPlayerNotes.db.profile.reason.reason .. "|cffffffff?"; + return L["PPN_REASON_REMOVE_CONFIRMATION"] + .. PersonalPlayerNotes.db.profile.reason.reason + .. "|cffffffff?" end, func = "RemoveReason", disabled = function() - if (PersonalPlayerNotes.db.profile.reason.id <= #PersonalPlayerNotes.defaults.profile.reasons) then + if PersonalPlayerNotes.db.profile.reason.id <= #PersonalPlayerNotes.defaults.profile.reasons then return true end return false - end + end, }, reason = { type = "input", @@ -318,7 +332,7 @@ PersonalPlayerNotes.options = { get = "GetReasonAlert", set = "SetReasonAlert", }, - } + }, }, ListedPlayers = { type = "group", @@ -349,9 +363,11 @@ PersonalPlayerNotes.options = { cmdHidden = true, name = L["PPN_LISTED_PLAYER_REMOVE"], confirm = function() - return L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] .. - PersonalPlayerNotes.db.profile.listedPlayer.name .. - "-" .. PersonalPlayerNotes.db.profile.listedPlayer.realm .. "|cffffffff?"; + return L["PPN_LISTED_PLAYER_REMOVE_CONFIRMATION"] + .. PersonalPlayerNotes.db.profile.listedPlayer.name + .. "-" + .. PersonalPlayerNotes.db.profile.listedPlayer.realm + .. "|cffffffff?" end, func = "RemoveListedPlayer", }, @@ -412,12 +428,11 @@ PersonalPlayerNotes.options = { get = "GetListedPlayerAlert", set = "SetListedPlayerAlert", disabled = function() - return not PersonalPlayerNotes.db.profile.reasons - [PersonalPlayerNotes.db.profile.listedPlayer.reason].alert + return not PersonalPlayerNotes.db.profile.reasons[PersonalPlayerNotes.db.profile.listedPlayer.reason].alert end, }, - } - } + }, + }, } --#region Sound @@ -441,9 +456,11 @@ end function PersonalPlayerNotes:PlayAlertSoundEffect(effect, channel) PlaySoundFile( - "Interface\\AddOns\\" .. - personalPlayerNotes .. - "\\Sounds\\" .. PersonalPlayerNotes.db.profile.alert.sounds[effect or self:GetAlertSoundEffect()] .. ".ogg", + "Interface\\AddOns\\" + .. personalPlayerNotes + .. "\\Sounds\\" + .. PersonalPlayerNotes.db.profile.alert.sounds[effect or self:GetAlertSoundEffect()] + .. ".ogg", channel or "master" ) end @@ -469,17 +486,15 @@ end function PersonalPlayerNotes:SetReason(info, value) self.db.profile.reason[info[#info]] = value - if (self.db.profile.reasons[self.db.profile.reason.id].reason == value) then + if self.db.profile.reasons[self.db.profile.reason.id].reason == value then return else - tinsert(self.db.profile.reasons, - #self.db.profile.reasons + 1, - { - id = #self.db.profile.reasons + 1, - reason = value, - color = { r = 1, g = 1, b = 1 }, - alert = true - }) + tinsert(self.db.profile.reasons, #self.db.profile.reasons + 1, { + id = #self.db.profile.reasons + 1, + reason = value, + color = { r = 1, g = 1, b = 1 }, + alert = true, + }) self.db.profile.reason.id = #self.db.profile.reasons self:SelectedReason(info, self.db.profile.reason.id) self:GetReason(info) @@ -488,7 +503,7 @@ end function PersonalPlayerNotes:SelectedReason(info, value) local r = self.db.profile.reasons[value] - if (not r) then + if not r then return end self.db.profile.reason[info[#info]] = value @@ -504,7 +519,7 @@ function PersonalPlayerNotes:RemoveReason() PersonalPlayerNotes.db.profile.reason.reason = reasons[#reasons].reason PersonalPlayerNotes.db.profile.reason.color = reasons[#reasons].color PersonalPlayerNotes.db.profile.reason.alert = reasons[#reasons].alert - return true; + return true end function PersonalPlayerNotes:GetReasonColor(info) @@ -543,7 +558,7 @@ end ]] function PersonalPlayerNotes:GetListedPlayer(name, realm) for index, value in pairs(self.db.profile.listedPlayers) do - if (tostring(name) == value.name and tostring(realm) == value.realm) then + if tostring(name) == value.name and tostring(realm) == value.realm then return self.db.profile.listedPlayers[index] end end @@ -563,7 +578,7 @@ end function PersonalPlayerNotes:SetListedPlayerSelected(info, value) self.db.profile.listedPlayer[info[#info]] = value local player = self.db.profile.listedPlayers[self.db.profile.listedPlayer.id] - if (player) then + if player then self.db.profile.listedPlayer.id = value self.db.profile.listedPlayer.name = player.name self.db.profile.listedPlayer.realm = player.realm @@ -582,7 +597,7 @@ function PersonalPlayerNotes:SetListedPlayerRealm(info, value) self.db.profile.listedPlayer[info[#info]] = value local player = PersonalPlayerNotes:GetListedPlayers()[self.db.profile.listedPlayer.id] --local player = PersonalPlayerNotes:GetListedPlayer(self.db.profile.listedPlayer.name, value) - if (player) then + if player then player.id = self.db.profile.listedPlayer.id player.name = self.db.profile.listedPlayer.name player.realm = value @@ -601,7 +616,7 @@ function PersonalPlayerNotes:SetListedPlayerName(info, value) self.db.profile.listedPlayer[info[#info]] = value --local player = PersonalPlayerNotes:GetListedPlayers()[self.db.profile.listedPlayer.id] local player = PersonalPlayerNotes:GetListedPlayer(value, self.db.profile.listedPlayer.realm) - if (player) then + if player then player.id = self.db.profile.listedPlayer.id player.name = value player.realm = self.db.profile.listedPlayer.realm @@ -668,7 +683,7 @@ function PersonalPlayerNotes:NewListedPlayer(name, realm, reason, description) reason = reason or 1, description = description or "", color = { r = 1, g = 1, b = 1 }, - alert = true + alert = true, } tinsert(self.db.profile.listedPlayers, self.db.profile.listedPlayer.id, newPlayer) return newPlayer diff --git a/PersonalPlayerNotesUtils.lua b/PersonalPlayerNotesUtils.lua index 3386f01..8028bb9 100644 --- a/PersonalPlayerNotesUtils.lua +++ b/PersonalPlayerNotesUtils.lua @@ -1,41 +1,139 @@ local personalPlayerNotes = ... local L = LibStub("AceLocale-3.0"):GetLocale(personalPlayerNotes, true) +local AceConfigDialog = LibStub("AceConfigDialog-3.0") + +-- Current SavedVariables schema version. Bump this and extend +-- MigrateSavedVariablesSchema() whenever the profile table shape changes +-- in a way that requires converting existing user data. +PersonalPlayerNotes.SCHEMA_VERSION = 1 + +--#region Client feature detection + +--[[ + Client capabilities are feature-detected instead of branched on + WOW_PROJECT_ID/flavor, since Classic clients have progressively picked up + modern Retail APIs and this avoids hardcoding assumptions per client. +]] +function PersonalPlayerNotes:HasModernMenuAPI() + return type(Menu) == "table" and type(Menu.ModifyMenu) == "function" +end + +function PersonalPlayerNotes:HasModernTooltipAPI() + return type(TooltipDataProcessor) == "table" + and type(TooltipDataProcessor.AddTooltipPostCall) == "function" + and type(Enum) == "table" + and Enum.TooltipDataType ~= nil +end + +function PersonalPlayerNotes:HasModernSettingsAPI() + return type(Settings) == "table" and type(Settings.OpenToCategory) == "function" +end + +--[[ + Some Classic clients don't expose the C_AddOns/C_UI namespaces yet, still + relying on the older global LoadAddOn/DisableAddOn/ReloadUI functions. + Confirmed via live testing on Wrath Classic. +]] +function PersonalPlayerNotes:HasModernAddOnAPI() + return type(C_AddOns) == "table" + and type(C_AddOns.LoadAddOn) == "function" + and type(C_AddOns.DisableAddOn) == "function" +end + +function PersonalPlayerNotes:HasModernUIReloadAPI() + return type(C_UI) == "table" and type(C_UI.Reload) == "function" +end + +--[[ + Opens the Blizzard options window to this addon's category, falling back + to opening the Ace3 config dialog directly on clients without the modern + Settings API. + + Settings.OpenToCategory() expects the numeric/category-object ID returned + by AceConfigDialog:AddToBlizOptions() (stored in + self.blizOptionsCategoryID during OnInitialize), NOT the addon name + string - passing the string throws "bad argument #1 to + 'OpenSettingsPanel' (outside of expected range)" on modern clients. +]] +function PersonalPlayerNotes:OpenBlizzardOptions() + if self:HasModernSettingsAPI() and self.blizOptionsCategoryID then + Settings.OpenToCategory(self.blizOptionsCategoryID) + else + AceConfigDialog:Open("PersonalPlayerNotesSettings Info") + end +end + +--#endregion + +--#region SavedVariables migration + +--[[ + Parses a legacy Shitlist "Name-Realm" key into separate name/realm values. + Kept as a pure string function (no SavedVariables/WoW API access) so it + can be unit tested outside of WoW, see Tests/test_utils.lua. +]] +function PersonalPlayerNotes:ParseLegacyPlayerKey(key) + if type(key) ~= "string" then + return nil, nil + end + return key:match("([^-]+)-([^-]+)") +end + +--[[ + Runs once per login after the AceDB profile is loaded. AceDB-3.0 already + fills in missing fields from PersonalPlayerNotes.defaults, so this only + needs to handle actual structural changes between schema versions. +]] +function PersonalPlayerNotes:MigrateSavedVariablesSchema() + local profile = self.db.profile + local fromVersion = profile.schemaVersion or 0 + + if fromVersion < 1 then + -- Initial schema version, nothing to convert yet. + end + + profile.schemaVersion = PersonalPlayerNotes.SCHEMA_VERSION +end + +--#endregion function PersonalPlayerNotes:GetVersion() - return tostring(C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Version")) or L["PPN_NA"]; + return tostring(C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Version")) or L["PPN_NA"] end function PersonalPlayerNotes:GetTitle() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Title") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Title") or L["PPN_NA"] end function PersonalPlayerNotes:GetAuthor() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Author") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Author") or L["PPN_NA"] end function PersonalPlayerNotes:GetNotes() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Notes") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "Notes") or L["PPN_NA"] end function PersonalPlayerNotes:GetLocalizations() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Localizations") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Localizations") or L["PPN_NA"] end function PersonalPlayerNotes:GetCategory() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Category") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Category") or L["PPN_NA"] end function PersonalPlayerNotes:GetWebsite() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Website") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-Website") or L["PPN_NA"] end function PersonalPlayerNotes:GetLicense() - return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-License") or L["PPN_NA"]; + return C_AddOns.GetAddOnMetadata(personalPlayerNotes, "X-License") or L["PPN_NA"] end function PersonalPlayerNotes:AceGUIDefaults() local aceGUI = LibStub("AceGUI-3.0"):Create("Frame") - aceGUI:SetCallback("OnClose", function(widget) aceGUI:Release() end) + aceGUI:SetCallback("OnClose", function(widget) + aceGUI:Release() + end) aceGUI:SetLayout("Fill") aceGUI:SetStatusText(nil) aceGUI.statustext:Hide() @@ -45,14 +143,14 @@ function PersonalPlayerNotes:AceGUIDefaults() end function PersonalPlayerNotes:Print(...) - if (self.db and self.db.profile.debug) then + if self.db and self.db.profile.debug then return print(L["PPN_DEBUG"], ...) end return print(L["PPN_PRINT"], ...) end function PersonalPlayerNotes:PrintDebug(...) - if (self.db and self.db.profile.debug) then + if self.db and self.db.profile.debug then self:Print(...) end end diff --git a/README.md b/README.md index ccd2780..f37d1ef 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,55 @@ -[![Check formating](https://github.com/Limmek/Shitlist/actions/workflows/luacheck.yml/badge.svg)](https://github.com/Limmek/Shitlist/actions/workflows/luacheck.yml) -[![Interface versions](https://github.com/Limmek/Shitlist/actions/workflows/interface-version.yml/badge.svg)](https://github.com/Limmek/Shitlist/actions/workflows/interface-version.yml) -[![Package and release](https://github.com/Limmek/Shitlist/actions/workflows/build.yml/badge.svg)](https://github.com/Limmek/Shitlist/actions/workflows/build.yml) +[![CI](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/ci.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/ci.yml) +[![Interface versions](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/interface-version.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/interface-version.yml) +[![Release](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/release.yml/badge.svg)](https://github.com/Limmek/PersonalPlayerNotes/actions/workflows/release.yml) # Personal Player Notes *(former #Shitlist)* -Set personal notes and pre-defined descriptions on players to remind your self in the future. The note and pre-defined description is displayed in the game tooltip. Both descriptions and notes support different colors and a alert sound. -### Notes and pre-defined Descriptions can be set and managed: -- By right-clicking on a unit frame or in chat -- From a GUI interface or blizzard addon options -- Minimap button to open GUI interface or the blizzard addon options +Set personal notes and pre-defined descriptions ("reasons") on players to remind yourself of who they are in the future. The note and its reason are shown directly in the player's tooltip and, optionally, as an alert sound the next time you encounter them. Both reasons and individual player notes support their own text color and their own alert sound. -**Download Now**: Latest release can be found on [Github](https://github.com/Limmek/Shitlist/releases) or [Curse Forge](https://www.curseforge.com/wow/addons/personal-player-notes) +Notes and reasons can be managed: +- By right-clicking a player's unit frame or name in chat +- From the in-game GUI (`/ppn`, `/ppnr`, `/ppnp`, see [Usage](#usage)) +- Via the Blizzard AddOns options panel +- From the minimap button (left/right/shift/ctrl-click for different shortcuts, see its tooltip) + +**Download**: latest release on [GitHub](https://github.com/Limmek/PersonalPlayerNotes/releases) or [CurseForge](https://www.curseforge.com/wow/addons/personal-player-notes). + +## Supported WoW clients + +| Client | Status | +|-----------------------------------------|-------------| +| Retail | Supported | +| Classic Era (incl. Hardcore, SoM, SoD) | Supported | +| Wrath Classic | Supported | +| Mists of Pandaria Classic | Supported | +| Cataclysm Classic | Retired by Blizzard (all realms moved to Mists of Pandaria Classic on 2025-07-01); no longer supported | +| Burning Crusade Classic (Anniversary) | Not currently supported | + +## Installation + +1. Download the latest release from [CurseForge](https://www.curseforge.com/wow/addons/personal-player-notes) or [GitHub Releases](https://github.com/Limmek/PersonalPlayerNotes/releases) (or install it through your addon manager of choice). +2. Extract it into your WoW `Interface/AddOns` folder so that `PersonalPlayerNotes.toc` sits directly inside `Interface/AddOns/PersonalPlayerNotes/`. +3. Enable the addon on the character-select AddOns list. + +If you previously used the old `Shitlist` addon, keep it installed for one login after installing Personal Player Notes — it will offer to migrate your reasons and listed players automatically, after which it can safely be removed. + +## Usage + +| Command | Effect | +|-------------------------------|--------------------------------------------------| +| `/ppn` | Open the Blizzard options to this addon's page | +| `/ppno` or `/ppnoptions` | Open the general settings | +| `/ppnr` or `/ppnreasons` | Manage reasons (categories) | +| `/ppnp` or `/ppnplayers` | Manage listed players | +| `/ppnm` or `/ppnminimap` | Toggle the minimap button | +| `/ppndebug` | Toggle debug output | + +Right-clicking a player (unit frame, chat, etc.) adds a "Personal Player Notes" entry to their context menu for quickly adding/editing a note. Hovering a listed player's tooltip shows their reason and note in the configured colors, and can optionally play an alert sound. + +## SavedVariables and data migration + +All data is stored in the `PersonalPlayerNotesDB` SavedVariables table, containing your reasons, listed players, and minimap/alert settings. On first load, if the legacy `Shitlist` addon and its data are present, you'll be prompted to migrate it into Personal Player Notes. + +--- + +Contributing? See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md). diff --git a/Tests/support/bootstrap.lua b/Tests/support/bootstrap.lua new file mode 100644 index 0000000..c639cc3 --- /dev/null +++ b/Tests/support/bootstrap.lua @@ -0,0 +1,121 @@ +--[[ + Shared stub/bootstrap helper for loading the addon's own Lua files + outside of a WoW client, mirroring the real load order declared in + PersonalPlayerNotes.toc: PersonalPlayerNotes.lua, then + PersonalPlayerNotesUtils.lua, then PersonalPlayerNotesConfig.lua. + + Used by the test files in Tests/ so each one doesn't need to duplicate + the same WoW/Ace3 stub setup. Only pure, isolable logic is exercised + this way - see .github/AGENTS.md's testing guidance for why WoW-API-heavy + code (menu/tooltip rendering, AceGUI widgets, etc.) is deliberately left + untested here. + + Not a test file itself - loaded with dofile("Tests/support/bootstrap.lua") + by the actual test files, which must be run from the repo root (matching + every other Tests/*.lua file and the Makefile/CI commands). +]] + +local M = {} + +--[[ + A minimal LibStub stub: + - "AceLocale-3.0" returns a locale table whose __index returns the key + itself, so assertions can rely on stable, predictable strings + instead of real (or missing) locale text. + - "AceAddon-3.0" returns an object whose NewAddon() creates a fresh + plain table, standing in for the real Ace3 addon object. + - Anything else (AceConfig-3.0, AceConfigDialog-3.0, LibDataBroker-1.1, + LibDBIcon-1.0, AceDBOptions-3.0, ...) returns a table whose __index + returns a no-op function, so unrelated `Lib:Method(...)` calls at + module load time or inside untested code paths don't error. +]] +function M.defaultLibStub() + return function(major) + if major == "AceLocale-3.0" then + return { + GetLocale = function() + return setmetatable({}, { + __index = function(_, key) + return key + end, + }) + end, + } + end + if major == "AceAddon-3.0" then + return { + NewAddon = function(_, ...) + return {} + end, + } + end + return setmetatable({}, { + __index = function() + return function() end + end, + }) + end +end + +--[[ + Loads PersonalPlayerNotes.lua, PersonalPlayerNotesUtils.lua and + PersonalPlayerNotesConfig.lua (in that order) into a fresh + _G.PersonalPlayerNotes, stubbing just enough of the WoW/Ace3 global + surface for their module-level code to run without error, and returns + the resulting PersonalPlayerNotes table. + + `libStubOverride`, if given, replaces the default LibStub stub (e.g. to + intercept a specific library with a recording spy for one test). +]] +function M.loadAddon(libStubOverride) + _G.LibStub = libStubOverride or M.defaultLibStub() + -- PersonalPlayerNotesConfig.lua calls PersonalPlayerNotes:GetNotes()/ + -- :GetAuthor()/etc. (which read C_AddOns.GetAddOnMetadata) at module + -- load time to build its options table, so this must exist before it's + -- loaded even if a given test doesn't care about addon metadata. + _G.C_AddOns = _G.C_AddOns or { + GetAddOnMetadata = function() + return "" + end, + } + -- tinsert/tremove are WoW-injected aliases for table.insert/table.remove, + -- not real Lua 5.1 globals, but PersonalPlayerNotesConfig.lua calls them + -- as bare globals (e.g. in SetReason/RemoveReason/NewListedPlayer). + _G.tinsert = _G.tinsert or table.insert + _G.tremove = _G.tremove or table.remove + _G.PersonalPlayerNotes = nil + + local main = assert(loadfile("PersonalPlayerNotes.lua")) + main("PersonalPlayerNotes") + + local utils = assert(loadfile("PersonalPlayerNotesUtils.lua")) + utils("PersonalPlayerNotes") + + local config = assert(loadfile("PersonalPlayerNotesConfig.lua")) + config("PersonalPlayerNotes") + + return _G.PersonalPlayerNotes +end + +--[[ + Returns a deep copy of PersonalPlayerNotes.defaults.profile, suitable for + assigning to `addon.db = { profile = ..., profiles = {} }` in tests that + need a realistic starting SavedVariables profile without pulling in the + real AceDB-3.0 library. +]] +function M.freshProfile(addon) + local function deepCopy(value) + if type(value) ~= "table" then + return value + end + local copy = {} + for k, v in pairs(value) do + copy[k] = deepCopy(v) + end + return copy + end + + return deepCopy(addon.defaults.profile) +end + +return M diff --git a/Tests/test_config.lua b/Tests/test_config.lua new file mode 100644 index 0000000..e5d5764 --- /dev/null +++ b/Tests/test_config.lua @@ -0,0 +1,443 @@ +--[[ + Unit tests for the pure data-manipulation logic in + PersonalPlayerNotesConfig.lua (AceDB defaults shape, reasons CRUD, + listed players CRUD, alert getters/setters). No WoW client or Ace3 + libraries are required - see Tests/support/bootstrap.lua. + + Run with: lua5.1 Tests/test_config.lua +]] + +local bootstrap = dofile("Tests/support/bootstrap.lua") + +local failures = 0 + +local function check(description, actual, expected) + if actual ~= expected then + failures = failures + 1 + print(string.format("FAIL: %s (expected %s, got %s)", description, tostring(expected), tostring(actual))) + else + print(string.format("PASS: %s", description)) + end +end + +local PersonalPlayerNotes = bootstrap.loadAddon() + +-- Give the addon a fresh, real-shaped profile without depending on AceDB-3.0. +local function freshDb() + return { profile = bootstrap.freshProfile(PersonalPlayerNotes), profiles = {} } +end + +--#region defaults shape + +do + PersonalPlayerNotes.db = freshDb() + local profile = PersonalPlayerNotes.db.profile + + check("defaults.schemaVersion matches SCHEMA_VERSION", profile.schemaVersion, PersonalPlayerNotes.SCHEMA_VERSION) + check("defaults.debug starts false", profile.debug, false) + check("defaults.minimap.hide starts false", profile.minimap.hide, false) + check("defaults.minimap.minimapPos starts at 240", profile.minimap.minimapPos, 240) + check("defaults.alert.delay starts at 10", profile.alert.delay, 10) + check("defaults.alert.enabled starts true", profile.alert.enabled, true) + check("defaults.alert.sounds has 4 entries", #profile.alert.sounds, 4) + check("defaults.reasons has exactly 1 entry", #profile.reasons, 1) + check("defaults.reasons[1].id is 1", profile.reasons[1].id, 1) + check("defaults.listedPlayers has exactly 1 entry", #profile.listedPlayers, 1) + check("defaults.listedPlayer.id is 1", profile.listedPlayer.id, 1) +end + +--#endregion + +--#region Alert + +do + PersonalPlayerNotes.db = freshDb() + + -- SetAlertSoundEffect/PlayAlertSoundEffect call the global PlaySoundFile, + -- so it must be stubbed before either is invoked. + local playedPath, playedChannel + _G.PlaySoundFile = function(path, channel) + playedPath, playedChannel = path, channel + end + + check("GetAlert reads the requested alert field", PersonalPlayerNotes:GetAlert({ "delay" }), 10) + PersonalPlayerNotes:SetAlert({ "delay" }, 25) + check("SetAlert writes the requested alert field", PersonalPlayerNotes:GetAlert({ "delay" }), 25) + + check("GetAlertSoundEffect reads the current sound index", PersonalPlayerNotes:GetAlertSoundEffect(), 1) + + PersonalPlayerNotes:PlayAlertSoundEffect() + check( + "PlayAlertSoundEffect plays the currently selected sound file", + playedPath, + "Interface\\AddOns\\PersonalPlayerNotes\\Sounds\\alarmbeep.ogg" + ) + check("PlayAlertSoundEffect defaults to the master channel", playedChannel, "master") + + PersonalPlayerNotes:PlayAlertSoundEffect(2, "sfx") + check( + "PlayAlertSoundEffect accepts an explicit effect index", + playedPath, + "Interface\\AddOns\\PersonalPlayerNotes\\Sounds\\alarmbuzz.ogg" + ) + check("PlayAlertSoundEffect accepts an explicit channel", playedChannel, "sfx") + + PersonalPlayerNotes:SetAlertSoundEffect({ "sound" }, 3) + check("SetAlertSoundEffect writes the sound index", PersonalPlayerNotes.db.profile.alert.sound, 3) + check( + "SetAlertSoundEffect also plays the newly selected sound", + playedPath, + "Interface\\AddOns\\PersonalPlayerNotes\\Sounds\\alarmbuzzer.ogg" + ) + + _G.PlaySoundFile = nil +end + +--#endregion + +--#region Reasons + +do + PersonalPlayerNotes.db = freshDb() + + check( + "GetReasons returns the profile's reasons table", + PersonalPlayerNotes:GetReasons(), + PersonalPlayerNotes.db.profile.reasons + ) + + local newReasons = { { id = 1, reason = "Custom", color = { r = 1, g = 1, b = 1 }, alert = true } } + PersonalPlayerNotes:SetReasons(newReasons) + check("SetReasons replaces the profile's reasons table", PersonalPlayerNotes.db.profile.reasons, newReasons) +end + +do + PersonalPlayerNotes.db = freshDb() + local originalReasonText = PersonalPlayerNotes.db.profile.reason.reason + + -- Re-submitting the same text is a no-op: no new reason is added. + PersonalPlayerNotes:SetReason({ "reason" }, originalReasonText) + check("SetReason with the same text does not add a new reason", #PersonalPlayerNotes:GetReasons(), 1) + + -- Submitting different text adds a *new* reason and selects it, leaving + -- the original reason entry untouched (this is the actual behavior of + -- SetReason: it never edits reasons[reason.id] in place). + PersonalPlayerNotes:SetReason({ "reason" }, "Ninja looter") + check("SetReason with new text adds a new reason", #PersonalPlayerNotes:GetReasons(), 2) + check("SetReason selects the newly added reason", PersonalPlayerNotes.db.profile.reason.id, 2) + check("SetReason's new reason has the submitted text", PersonalPlayerNotes.db.profile.reason.reason, "Ninja looter") + check( + "SetReason leaves the original reason entry untouched", + PersonalPlayerNotes:GetReasons()[1].reason, + originalReasonText + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ + { id = 1, reason = "First", color = { r = 1, g = 1, b = 1 }, alert = false }, + { id = 2, reason = "Second", color = { r = 0, g = 0, b = 0 }, alert = true }, + }) + + PersonalPlayerNotes:SelectedReason({ "id" }, 2) + check( + "SelectedReason syncs reason.reason from the chosen entry", + PersonalPlayerNotes.db.profile.reason.reason, + "Second" + ) + check("SelectedReason syncs reason.alert from the chosen entry", PersonalPlayerNotes.db.profile.reason.alert, true) + + local idBeforeInvalidSelection = PersonalPlayerNotes.db.profile.reason.id + PersonalPlayerNotes:SelectedReason({ "id" }, 99) + check( + "SelectedReason with an out-of-range id leaves the current selection untouched", + PersonalPlayerNotes.db.profile.reason.id, + idBeforeInvalidSelection + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ + { id = 1, reason = "First", color = { r = 1, g = 1, b = 1 }, alert = false }, + { id = 2, reason = "Second", color = { r = 0, g = 0, b = 0 }, alert = true }, + }) + PersonalPlayerNotes.db.profile.reason.id = 2 + + PersonalPlayerNotes:RemoveReason() + check("RemoveReason removes the selected reason", #PersonalPlayerNotes:GetReasons(), 1) + check("RemoveReason re-selects the new last reason", PersonalPlayerNotes.db.profile.reason.id, 1) + check( + "RemoveReason syncs reason.reason to the new last reason", + PersonalPlayerNotes.db.profile.reason.reason, + "First" + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasonColor({ "color" }, 0.25, 0.5, 0.75) + local r, g, b = PersonalPlayerNotes:GetReasonColor({ "color" }) + check("SetReasonColor/GetReasonColor round-trip (r)", r, 0.25) + check("SetReasonColor/GetReasonColor round-trip (g)", g, 0.5) + check("SetReasonColor/GetReasonColor round-trip (b)", b, 0.75) + + PersonalPlayerNotes.db.profile.reason.color = {} + local dr, dg, db = PersonalPlayerNotes:GetReasonColor({ "color" }) + check("GetReasonColor defaults a missing r to 1", dr, 1) + check("GetReasonColor defaults a missing g to 1", dg, 1) + check("GetReasonColor defaults a missing b to 1", db, 1) +end + +do + PersonalPlayerNotes.db = freshDb() + check("GetReasonAlert reads the current alert flag", PersonalPlayerNotes:GetReasonAlert({ "alert" }), false) + PersonalPlayerNotes:SetReasonAlert({ "alert" }, true) + check("SetReasonAlert writes the alert flag on reason", PersonalPlayerNotes.db.profile.reason.alert, true) + check( + "SetReasonAlert also updates the matching entry in reasons[]", + PersonalPlayerNotes:GetReasons()[PersonalPlayerNotes.db.profile.reason.id].alert, + true + ) +end + +--#endregion + +--#region Listed players + +do + PersonalPlayerNotes.db = freshDb() + check( + "GetListedPlayers returns the profile's listedPlayers table", + PersonalPlayerNotes:GetListedPlayers(), + PersonalPlayerNotes.db.profile.listedPlayers + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ { id = 1, reason = "AFK", color = { r = 1, g = 1, b = 1 }, alert = false } }) + PersonalPlayerNotes.db.profile.listedPlayers = {} + PersonalPlayerNotes.db.profile.listedPlayer.id = 0 + + local created = PersonalPlayerNotes:NewListedPlayer("Thrall", "Frostmourne") + check("NewListedPlayer assigns the first id", created.id, 1) + check("NewListedPlayer stores the given name", created.name, "Thrall") + check("NewListedPlayer stores the given realm", created.realm, "Frostmourne") + check("NewListedPlayer defaults reason to 1", created.reason, 1) + check("NewListedPlayer defaults description to an empty string", created.description, "") + check("NewListedPlayer appends to listedPlayers", #PersonalPlayerNotes:GetListedPlayers(), 1) + + local found = PersonalPlayerNotes:GetListedPlayer("Thrall", "Frostmourne") + check("GetListedPlayer finds a player by name/realm", found, created) + + local notFound = PersonalPlayerNotes:GetListedPlayer("Jaina", "Frostmourne") + check("GetListedPlayer returns nil for an unlisted player", notFound, nil) +end + +-- SetListedPlayerRealm/SetListedPlayerName sync ALL fields from the +-- `listedPlayer` scratch/mirror object onto the stored player entry (not +-- just the field being edited), so the mirror must be fully populated to +-- match the player being edited first - exactly like the real options UI +-- would do via SetListedPlayerSelected before editing a sub-field. +local function selectPlayerForEditing(player) + local listedPlayer = PersonalPlayerNotes.db.profile.listedPlayer + listedPlayer.id = player.id + listedPlayer.name = player.name + listedPlayer.realm = player.realm + listedPlayer.reason = player.reason + listedPlayer.description = player.description + listedPlayer.color = player.color + listedPlayer.alert = player.alert +end + +do + PersonalPlayerNotes.db = freshDb() + local player = { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = true, + } + PersonalPlayerNotes.db.profile.listedPlayers = { player } + selectPlayerForEditing(player) + + PersonalPlayerNotes:SetListedPlayerRealm({ "realm" }, "Stormrage") + check( + "SetListedPlayerRealm updates the stored player's realm", + PersonalPlayerNotes:GetListedPlayers()[1].realm, + "Stormrage" + ) + check( + "SetListedPlayerRealm keeps the stored player's name intact", + PersonalPlayerNotes:GetListedPlayers()[1].name, + "Thrall" + ) +end + +do + PersonalPlayerNotes.db = freshDb() + local player = { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = true, + } + PersonalPlayerNotes.db.profile.listedPlayers = { player } + selectPlayerForEditing(player) + + -- Renaming to a name that already exists at the same realm should sync + -- fields onto the existing entry instead of creating a duplicate. + PersonalPlayerNotes:SetListedPlayerName({ "name" }, "Thrall") + check("SetListedPlayerName on an unchanged name keeps a single entry", #PersonalPlayerNotes:GetListedPlayers(), 1) + + -- Renaming to a name/realm combination that doesn't exist yet creates a + -- brand new listed player instead. + PersonalPlayerNotes:SetListedPlayerName({ "name" }, "Jaina") + check("SetListedPlayerName with an unknown name creates a new entry", #PersonalPlayerNotes:GetListedPlayers(), 2) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes.db.profile.listedPlayers = { + { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = true, + }, + { + id = 2, + name = "Jaina", + realm = "Theramore", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = false, + }, + } + PersonalPlayerNotes.db.profile.listedPlayer.id = 2 + + PersonalPlayerNotes:RemoveListedPlayer() + check("RemoveListedPlayer removes the selected player", #PersonalPlayerNotes:GetListedPlayers(), 1) + check("RemoveListedPlayer re-selects the new last player", PersonalPlayerNotes.db.profile.listedPlayer.id, 1) + check( + "RemoveListedPlayer syncs listedPlayer.name to the new last player", + PersonalPlayerNotes.db.profile.listedPlayer.name, + "Thrall" + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes.db.profile.listedPlayers = { + { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = false, + }, + } + PersonalPlayerNotes.db.profile.listedPlayer.id = 1 + + PersonalPlayerNotes:SetListedPlayerSelectedReason({ "reason" }, 2) + check( + "SetListedPlayerSelectedReason writes reason on the stored player", + PersonalPlayerNotes:GetListedPlayers()[1].reason, + 2 + ) + check( + "GetListedPlayerSelectedReason reads back the selected-player mirror", + PersonalPlayerNotes:GetListedPlayerSelectedReason({ "reason" }), + 2 + ) + + PersonalPlayerNotes:SetListedPlayerSelectedDescription({ "description" }, "Ninja looted raid gear") + check( + "SetListedPlayerSelectedDescription writes description on the stored player", + PersonalPlayerNotes:GetListedPlayers()[1].description, + "Ninja looted raid gear" + ) + + PersonalPlayerNotes:SetListedPlayerColor({ "color" }, 0.1, 0.2, 0.3) + local r, g, b = PersonalPlayerNotes:GetListedPlayerColor({ "color" }) + check("SetListedPlayerColor/GetListedPlayerColor round-trip (r)", r, 0.1) + check("SetListedPlayerColor/GetListedPlayerColor round-trip (g)", g, 0.2) + check("SetListedPlayerColor/GetListedPlayerColor round-trip (b)", b, 0.3) + + PersonalPlayerNotes:SetListedPlayerAlert({ "alert" }, true) + check( + "SetListedPlayerAlert writes the stored player's alert flag", + PersonalPlayerNotes:GetListedPlayers()[1].alert, + true + ) + check( + "GetListedPlayerAlert reads back the selected-player mirror", + PersonalPlayerNotes:GetListedPlayerAlert({ "alert" }), + true + ) +end + +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes.db.profile.listedPlayers = { + { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = true, + }, + { + id = 2, + name = "Jaina", + realm = "Theramore", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = false, + }, + } + PersonalPlayerNotes.db.profile.listedPlayer.id = 1 + + PersonalPlayerNotes:SetListedPlayerSelected({ "id" }, 2) + check( + "SetListedPlayerSelected switches to the requested player's id", + PersonalPlayerNotes.db.profile.listedPlayer.id, + 2 + ) + check( + "SetListedPlayerSelected syncs the selected player's name", + PersonalPlayerNotes.db.profile.listedPlayer.name, + "Jaina" + ) + check( + "GetListedPlayerSelected reads back the requested field", + PersonalPlayerNotes:GetListedPlayerSelected({ "name" }), + "Jaina" + ) +end + +--#endregion + +if failures > 0 then + print(string.format("\n%d test(s) failed.", failures)) + os.exit(1) +end + +print("\nAll tests passed.") diff --git a/Tests/test_main.lua b/Tests/test_main.lua new file mode 100644 index 0000000..f6eda2d --- /dev/null +++ b/Tests/test_main.lua @@ -0,0 +1,182 @@ +--[[ + Unit tests for PersonalPlayerNotes:GetOldConfigData(), the legacy + Shitlist -> Personal Player Notes data migration logic in + PersonalPlayerNotes.lua. This is the one piece of non-trivial pure(ish) + business logic in that file - the rest (menu/tooltip hooks, AceGUI + widgets, minimap icon) is too tightly coupled to the real WoW client to + usefully unit test, see .github/AGENTS.md. + + Run with: lua5.1 Tests/test_main.lua +]] + +local bootstrap = dofile("Tests/support/bootstrap.lua") + +local failures = 0 + +local function check(description, actual, expected) + if actual ~= expected then + failures = failures + 1 + print(string.format("FAIL: %s (expected %s, got %s)", description, tostring(expected), tostring(actual))) + else + print(string.format("PASS: %s", description)) + end +end + +local PersonalPlayerNotes = bootstrap.loadAddon() + +local function freshDb() + return { profile = bootstrap.freshProfile(PersonalPlayerNotes), profiles = {} } +end + +local function resetGlobals() + _G.ShitlistDB = nil + _G.StaticPopupDialogs = {} + _G.StaticPopup_Show = function() end +end + +-- No Shitlist data installed at all: must not error, must not touch the db. +do + PersonalPlayerNotes.db = freshDb() + resetGlobals() + local reasonsBefore = #PersonalPlayerNotes:GetReasons() + local playersBefore = #PersonalPlayerNotes:GetListedPlayers() + + PersonalPlayerNotes:GetOldConfigData() + + check("GetOldConfigData is a no-op without a ShitlistDB global", #PersonalPlayerNotes:GetReasons(), reasonsBefore) + check( + "GetOldConfigData leaves listedPlayers untouched without ShitlistDB", + #PersonalPlayerNotes:GetListedPlayers(), + playersBefore + ) +end + +-- ShitlistDB exists but has neither pre-2.0 Reasons/ListedPlayers nor +-- profiles to migrate: must still be a no-op. +do + PersonalPlayerNotes.db = freshDb() + resetGlobals() + _G.ShitlistDB = {} + local reasonsBefore = #PersonalPlayerNotes:GetReasons() + + PersonalPlayerNotes:GetOldConfigData() + + check("GetOldConfigData is a no-op for an empty ShitlistDB", #PersonalPlayerNotes:GetReasons(), reasonsBefore) +end + +-- Pre-2.0 legacy data: a new reason and a new listed player should both be +-- migrated in, and the legacy tables cleared afterwards. +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ { id = 1, reason = "AFK", color = { r = 1, g = 1, b = 1 }, alert = false } }) + PersonalPlayerNotes.db.profile.listedPlayers = {} + resetGlobals() + _G.ShitlistDB = { + Reasons = { "unused, only presence is checked" }, + ListedPlayers = { + ["Thrall-Frostmourne"] = { "Ninja looter", "Stole the raid loot" }, + }, + } + + PersonalPlayerNotes:GetOldConfigData() + + check("GetOldConfigData migrates a new legacy reason", #PersonalPlayerNotes:GetReasons(), 2) + check("GetOldConfigData migrates a new legacy listed player", #PersonalPlayerNotes:GetListedPlayers(), 1) + + local migrated = PersonalPlayerNotes:GetListedPlayer("Thrall", "Frostmourne") + check("GetOldConfigData migrates the legacy player's name/realm", migrated ~= nil, true) + check("GetOldConfigData migrates the legacy player's description", migrated.description, "Stole the raid loot") + check( + "GetOldConfigData assigns the newly created reason's id", + migrated.reason, + PersonalPlayerNotes:GetReasons()[2].id + ) + check( + "GetOldConfigData's migrated reason keeps the legacy reason text", + PersonalPlayerNotes:GetReasons()[2].reason, + "Ninja looter" + ) + check("GetOldConfigData clears the legacy ListedPlayers table", ShitlistDB.ListedPlayers, nil) + check("GetOldConfigData clears the legacy Reasons table", ShitlistDB.Reasons, nil) +end + +-- Pre-2.0 legacy data that reuses an existing reason's text: no duplicate +-- reason should be created, the existing reason id should be reused. +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ { id = 1, reason = "AFK", color = { r = 1, g = 1, b = 1 }, alert = false } }) + PersonalPlayerNotes.db.profile.listedPlayers = {} + resetGlobals() + _G.ShitlistDB = { + Reasons = { "unused" }, + ListedPlayers = { + ["Jaina-Theramore"] = { "AFK", "" }, + }, + } + + PersonalPlayerNotes:GetOldConfigData() + + check("GetOldConfigData does not duplicate an existing reason", #PersonalPlayerNotes:GetReasons(), 1) + local migrated = PersonalPlayerNotes:GetListedPlayer("Jaina", "Theramore") + check("GetOldConfigData reuses the existing reason's id", migrated.reason, 1) +end + +-- Pre-2.0 legacy data for a player that's already listed: should be skipped +-- (not duplicated). +do + PersonalPlayerNotes.db = freshDb() + PersonalPlayerNotes:SetReasons({ { id = 1, reason = "AFK", color = { r = 1, g = 1, b = 1 }, alert = false } }) + PersonalPlayerNotes.db.profile.listedPlayers = { + { + id = 1, + name = "Thrall", + realm = "Frostmourne", + reason = 1, + description = "", + color = { r = 1, g = 1, b = 1 }, + alert = true, + }, + } + resetGlobals() + _G.ShitlistDB = { + Reasons = { "unused" }, + ListedPlayers = { + ["Thrall-Frostmourne"] = { "AFK", "Already listed" }, + }, + } + + PersonalPlayerNotes:GetOldConfigData() + + check("GetOldConfigData skips a legacy player that's already listed", #PersonalPlayerNotes:GetListedPlayers(), 1) +end + +-- ShitlistDB.profiles present: should prompt a migration popup, without +-- actually running its OnAccept side effects (disabling Shitlist, reloading +-- the UI, etc. - those aren't invoked unless a real user confirms the popup). +do + PersonalPlayerNotes.db = freshDb() + resetGlobals() + local shownKey + _G.StaticPopup_Show = function(key) + shownKey = key + end + _G.ShitlistDB = { + profiles = { Default = { reasons = {}, listedPlayers = {} } }, + } + + PersonalPlayerNotes:GetOldConfigData() + + check( + "GetOldConfigData registers a MIGRATE_PROFILES popup when ShitlistDB.profiles exists", + type(StaticPopupDialogs["MIGRATE_PROFILES"]), + "table" + ) + check("GetOldConfigData shows the MIGRATE_PROFILES popup", shownKey, "MIGRATE_PROFILES") +end + +if failures > 0 then + print(string.format("\n%d test(s) failed.", failures)) + os.exit(1) +end + +print("\nAll tests passed.") diff --git a/Tests/test_utils.lua b/Tests/test_utils.lua new file mode 100644 index 0000000..51a7926 --- /dev/null +++ b/Tests/test_utils.lua @@ -0,0 +1,247 @@ +--[[ + Minimal, dependency-free Lua test for the pure-logic helpers in + PersonalPlayerNotesUtils.lua (legacy Shitlist key parsing and client + feature detection). No WoW client or Ace3 libraries are required. + + Run with: lua5.1 Tests/test_utils.lua +]] + +-- Minimal stubs so PersonalPlayerNotesUtils.lua can be loaded outside of WoW. +_G.LibStub = function(major) + if major == "AceLocale-3.0" then + return { + GetLocale = function() + return setmetatable({}, { + __index = function() + return "" + end, + }) + end, + } + end + return setmetatable({}, { + __index = function() + return function() end + end, + }) +end + +_G.PersonalPlayerNotes = {} + +local chunk = assert(loadfile("PersonalPlayerNotesUtils.lua")) +chunk("PersonalPlayerNotes") + +local PersonalPlayerNotes = _G.PersonalPlayerNotes + +local failures = 0 + +local function check(description, actual, expected) + if actual ~= expected then + failures = failures + 1 + print(string.format("FAIL: %s (expected %s, got %s)", description, tostring(expected), tostring(actual))) + else + print(string.format("PASS: %s", description)) + end +end + +-- ParseLegacyPlayerKey +do + local name, realm = PersonalPlayerNotes:ParseLegacyPlayerKey("Thrall-Frostmourne") + check("parses a simple Name-Realm key (name)", name, "Thrall") + check("parses a simple Name-Realm key (realm)", realm, "Frostmourne") +end + +do + local name, realm = PersonalPlayerNotes:ParseLegacyPlayerKey("NoHyphenKey") + check("returns nil for a key without a hyphen (name)", name, nil) + check("returns nil for a key without a hyphen (realm)", realm, nil) +end + +do + local name, realm = PersonalPlayerNotes:ParseLegacyPlayerKey(nil) + check("returns nil for a non-string key (name)", name, nil) + check("returns nil for a non-string key (realm)", realm, nil) +end + +-- Feature detection: must not error and must default to false when the +-- corresponding WoW globals don't exist. +check("HasModernMenuAPI is false without a Menu global", PersonalPlayerNotes:HasModernMenuAPI(), false) +check( + "HasModernTooltipAPI is false without TooltipDataProcessor/Enum", + PersonalPlayerNotes:HasModernTooltipAPI(), + false +) +check("HasModernSettingsAPI is false without a Settings global", PersonalPlayerNotes:HasModernSettingsAPI(), false) +check("HasModernAddOnAPI is false without a C_AddOns global", PersonalPlayerNotes:HasModernAddOnAPI(), false) +check("HasModernUIReloadAPI is false without a C_UI global", PersonalPlayerNotes:HasModernUIReloadAPI(), false) + +_G.Menu = { ModifyMenu = function() end } +check("HasModernMenuAPI is true once Menu.ModifyMenu exists", PersonalPlayerNotes:HasModernMenuAPI(), true) +_G.Menu = nil + +_G.TooltipDataProcessor = { AddTooltipPostCall = function() end } +_G.Enum = { TooltipDataType = { Unit = 1 } } +check( + "HasModernTooltipAPI is true once TooltipDataProcessor/Enum exist", + PersonalPlayerNotes:HasModernTooltipAPI(), + true +) +_G.TooltipDataProcessor = nil +_G.Enum = nil + +_G.Settings = { OpenToCategory = function() end } +check( + "HasModernSettingsAPI is true once Settings.OpenToCategory exists", + PersonalPlayerNotes:HasModernSettingsAPI(), + true +) +_G.Settings = nil + +_G.C_AddOns = { LoadAddOn = function() end, DisableAddOn = function() end } +check( + "HasModernAddOnAPI is true once C_AddOns.LoadAddOn/DisableAddOn exist", + PersonalPlayerNotes:HasModernAddOnAPI(), + true +) +_G.C_AddOns = nil + +_G.C_UI = { Reload = function() end } +check("HasModernUIReloadAPI is true once C_UI.Reload exists", PersonalPlayerNotes:HasModernUIReloadAPI(), true) +_G.C_UI = nil + +-- Metadata getters: thin wrappers around C_AddOns.GetAddOnMetadata(), each +-- falling back to L["PPN_NA"] ("" with this file's locale stub) when the +-- requested field is missing. +do + _G.C_AddOns = { + GetAddOnMetadata = function(_, field) + local fixture = { + Title = "Personal Player Notes", + Author = "Some Author", + Notes = "Some notes", + ["X-Localizations"] = "enUS, zhCN", + ["X-Category"] = "Combat", + ["X-Website"] = "https://example.invalid", + ["X-License"] = "MIT", + } + return fixture[field] + end, + } + + check("GetTitle reads the Title metadata field", PersonalPlayerNotes:GetTitle(), "Personal Player Notes") + check("GetAuthor reads the Author metadata field", PersonalPlayerNotes:GetAuthor(), "Some Author") + check("GetNotes reads the Notes metadata field", PersonalPlayerNotes:GetNotes(), "Some notes") + check( + "GetLocalizations reads the X-Localizations metadata field", + PersonalPlayerNotes:GetLocalizations(), + "enUS, zhCN" + ) + check("GetCategory reads the X-Category metadata field", PersonalPlayerNotes:GetCategory(), "Combat") + check("GetWebsite reads the X-Website metadata field", PersonalPlayerNotes:GetWebsite(), "https://example.invalid") + check("GetLicense reads the X-License metadata field", PersonalPlayerNotes:GetLicense(), "MIT") + + -- GetVersion wraps the result in tostring(), so even a missing/nil + -- metadata field becomes the *string* "nil" rather than falling back to + -- L["PPN_NA"] - the `or` fallback in GetVersion() is effectively dead + -- code. This test documents that actual (if surprising) behavior. + check("GetVersion tostring()-wraps a present Version field", PersonalPlayerNotes:GetVersion(), "nil") + + _G.C_AddOns.GetAddOnMetadata = function(_, field) + if field == "Version" then + return "1.2.3" + end + return nil + end + check("GetVersion reads a present Version metadata field", PersonalPlayerNotes:GetVersion(), "1.2.3") + check('GetTitle falls back to L["PPN_NA"] when the field is missing', PersonalPlayerNotes:GetTitle(), "") + + _G.C_AddOns = nil +end + +-- MigrateSavedVariablesSchema +do + PersonalPlayerNotes.db = { profile = { schemaVersion = nil } } + PersonalPlayerNotes:MigrateSavedVariablesSchema() + check( + "MigrateSavedVariablesSchema stamps a fresh profile with SCHEMA_VERSION", + PersonalPlayerNotes.db.profile.schemaVersion, + PersonalPlayerNotes.SCHEMA_VERSION + ) + + PersonalPlayerNotes.db = { profile = { schemaVersion = PersonalPlayerNotes.SCHEMA_VERSION } } + PersonalPlayerNotes:MigrateSavedVariablesSchema() + check( + "MigrateSavedVariablesSchema leaves an already-current profile at SCHEMA_VERSION", + PersonalPlayerNotes.db.profile.schemaVersion, + PersonalPlayerNotes.SCHEMA_VERSION + ) +end + +-- OpenBlizzardOptions +do + local capturedCategoryID + _G.Settings = { + OpenToCategory = function(categoryID) + capturedCategoryID = categoryID + end, + } + PersonalPlayerNotes.blizOptionsCategoryID = 42 + PersonalPlayerNotes:OpenBlizzardOptions() + check( + "OpenBlizzardOptions uses Settings.OpenToCategory when the modern Settings API is available", + capturedCategoryID, + 42 + ) + _G.Settings = nil +end + +do + -- Reload the module with a spy standing in for AceConfigDialog-3.0, so + -- OpenBlizzardOptions()'s fallback branch (used on clients without the + -- modern Settings API, or before blizOptionsCategoryID is known) can be + -- verified to actually call AceConfigDialog:Open(...). + local openedKey + _G.LibStub = function(major) + if major == "AceLocale-3.0" then + return { + GetLocale = function() + return setmetatable({}, { + __index = function() + return "" + end, + }) + end, + } + end + if major == "AceConfigDialog-3.0" then + return { + Open = function(_, key) + openedKey = key + end, + } + end + return setmetatable({}, { + __index = function() + return function() end + end, + }) + end + + local reloadChunk = assert(loadfile("PersonalPlayerNotesUtils.lua")) + reloadChunk("PersonalPlayerNotes") + + PersonalPlayerNotes.blizOptionsCategoryID = nil + PersonalPlayerNotes:OpenBlizzardOptions() + check( + "OpenBlizzardOptions falls back to AceConfigDialog:Open without a bliz options category", + openedKey, + "PersonalPlayerNotesSettings Info" + ) +end + +if failures > 0 then + print(string.format("\n%d test(s) failed.", failures)) + os.exit(1) +end + +print("\nAll tests passed.") diff --git a/Tools/Install-LocalAddon.ps1 b/Tools/Install-LocalAddon.ps1 new file mode 100644 index 0000000..ae6c32c --- /dev/null +++ b/Tools/Install-LocalAddon.ps1 @@ -0,0 +1,221 @@ +<# +.SYNOPSIS + Sets up this repo checkout as a locally loadable WoW addon for in-game testing. + +.DESCRIPTION + Replaces the old "clone the repo directly inside the AddOns folder" workflow. + Instead, this script: + 1. Creates a directory junction from each detected WoW AddOns folder to this + repo, so the client loads the addon straight from your working copy. + 2. Fetches the Ace3/CallbackHandler/LibStub/LibDataBroker libraries declared + in .pkgmeta into Libs/, so the addon actually has its dependencies. + + No SVN client is installed or required: the svn:// externals are fetched with + `git svn`, which ships as part of Git for Windows. The only external tool used + is `git`, which this repo already requires. + + This is a lighter-weight alternative to running the full BigWigsMods packager + (https://github.com/BigWigsMods/packager) locally, which is still an option - + see CONTRIBUTING.md - if you want an actual packaged zip instead of a live junction. + +.PARAMETER AddonsPath + One or more "Interface\AddOns" folders to link into. Defaults to auto-detecting + every "_retail_", "_beta_", "_anniversary_", "_classic_", "_classic_era_" and + "_classic_ptr_" install under + "C:\Program Files (x86)\World of Warcraft" and "C:\Program Files\World of Warcraft". + +.PARAMETER SkipLibs + Skip fetching/updating the Libs/ folder. + +.PARAMETER SkipLink + Skip creating the AddOns folder junction(s). + +.PARAMETER UpdateLibs + Re-fetch libraries even if Libs/ already exists. + +.PARAMETER Uninstall + Remove the junction(s) created by a previous run (only removes actual + junctions/reparse points, never a real folder) and exit. Libs/ is left alone. + +.EXAMPLE + .\Tools\Install-LocalAddon.ps1 + +.EXAMPLE + .\Tools\Install-LocalAddon.ps1 -AddonsPath 'D:\Games\World of Warcraft\_retail_\Interface\AddOns' + +.EXAMPLE + .\Tools\Install-LocalAddon.ps1 -Uninstall +#> +[CmdletBinding()] +param( + [string[]] $AddonsPath, + [switch] $SkipLibs, + [switch] $SkipLink, + [switch] $UpdateLibs, + [switch] $Uninstall +) + +$ErrorActionPreference = 'Continue' +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$AddonName = 'PersonalPlayerNotes' + +function Find-AddonsFolders { + $roots = @( + 'C:\Program Files (x86)\World of Warcraft', + 'C:\Program Files\World of Warcraft' + ) + $flavors = @('_retail_', '_beta_', '_anniversary_', '_classic_', '_classic_era_', '_classic_ptr_') + $found = @() + foreach ($root in $roots) { + foreach ($flavor in $flavors) { + $candidate = Join-Path $root "$flavor\Interface\AddOns" + if (Test-Path $candidate) { + $found += (Resolve-Path $candidate).Path + } + } + } + return $found +} + +function Test-IsReparsePoint([string] $Path) { + if (-not (Test-Path $Path)) { return $false } + $item = Get-Item -LiteralPath $Path -Force + return [bool]($item.Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Remove-AddonLinks { + param([string[]] $Targets) + foreach ($addonsFolder in $Targets) { + $link = Join-Path $addonsFolder $AddonName + if (Test-IsReparsePoint $link) { + Write-Host "Removing junction: $link" + (Get-Item -LiteralPath $link -Force).Delete() + } + elseif (Test-Path $link) { + Write-Warning "Skipping $link - it's a real folder, not a junction created by this script. Leaving it alone." + } + } +} + +function New-AddonLinks { + param([string[]] $Targets) + foreach ($addonsFolder in $Targets) { + if (-not (Test-Path $addonsFolder)) { + Write-Warning "AddOns folder not found, skipping: $addonsFolder" + continue + } + $link = Join-Path $addonsFolder $AddonName + if (Test-IsReparsePoint $link) { + $existingTarget = (Get-Item -LiteralPath $link -Force).Target + if ($existingTarget -contains $RepoRoot -or $existingTarget -eq $RepoRoot) { + Write-Host "Already linked: $link" + continue + } + Write-Warning "$link is a junction pointing elsewhere ($existingTarget). Remove it manually (or run -Uninstall first) if you want to relink it." + continue + } + if (Test-Path $link) { + Write-Warning "Skipping $link - a real folder already exists there (not a junction). Move/remove it manually first if you want this repo linked instead." + continue + } + Write-Host "Linking $link -> $RepoRoot" + New-Item -ItemType Junction -Path $link -Target $RepoRoot | Out-Null + } +} + +function Get-PkgmetaExternals { + $pkgmetaPath = Join-Path $RepoRoot '.pkgmeta' + $lines = Get-Content -LiteralPath $pkgmetaPath + $externals = [ordered]@{} + $inExternals = $false + $currentKey = $null + foreach ($line in $lines) { + if ($line -match '^externals:\s*$') { + $inExternals = $true + continue + } + if (-not $inExternals) { continue } + if ($line -match '^\S') { + # a new top-level key ends the externals block + break + } + if ($line -match '^\s{2}(\S+):\s*(\S+)\s*$') { + # single-line form, e.g. " Libs/AceAddon-3.0: svn://..." + $externals[$matches[1]] = @{ Url = $matches[2] } + $currentKey = $null + } + elseif ($line -match '^\s{2}(\S+):\s*$') { + # multi-line block form, e.g. " Libs/LibDataBroker-1.1:" + $currentKey = $matches[1] + $externals[$currentKey] = @{} + } + elseif ($currentKey -and $line -match '^\s{4}(\S+):\s*(\S+)\s*$') { + $externals[$currentKey][$matches[1]] = $matches[2] + } + } + return $externals +} + +function Install-Libs { + $externals = Get-PkgmetaExternals + foreach ($key in $externals.Keys) { + $dest = Join-Path $RepoRoot $key + if ((Test-Path $dest) -and -not $UpdateLibs) { + Write-Host "Already present, skipping: $key" + continue + } + if (Test-Path $dest) { + Remove-Item -LiteralPath $dest -Recurse -Force + } + + $url = $externals[$key].Url + Write-Host "Fetching $key" + if ($url -like 'svn://*' -or $url -like '*repos.wowace.com*' -or $url -like '*repos.curseforge.net*') { + # WowAce/CurseForge host these as plain Subversion repositories (dumb HTTP or + # svn:// protocol). git svn ships with Git for Windows, so no separate svn + # client install is required. git/git-svn write normal progress to stderr, so + # capture it as plain text (not an error record) and check the exit code instead. + $output = git svn clone --quiet -r HEAD "$url" "$dest" *>&1 | Out-String + } + elseif ($url) { + $output = git clone --quiet --depth 1 "$url" "$dest" *>&1 | Out-String + } + else { + Write-Warning "Could not determine URL for $key, skipping." + continue + } + + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to fetch $key`: $output" + continue + } + Write-Verbose $output + + $gitMeta = Join-Path $dest '.git' + if (Test-Path $gitMeta) { + Remove-Item -LiteralPath $gitMeta -Recurse -Force + } + } +} + +if ($Uninstall) { + $targets = if ($AddonsPath) { $AddonsPath } else { Find-AddonsFolders } + Remove-AddonLinks -Targets $targets + return +} + +if (-not $SkipLink) { + $targets = if ($AddonsPath) { $AddonsPath } else { Find-AddonsFolders } + if (-not $targets) { + Write-Warning 'No WoW AddOns folders found/specified. Pass -AddonsPath explicitly.' + } + else { + New-AddonLinks -Targets $targets + } +} + +if (-not $SkipLibs) { + Install-Libs +} + +Write-Host "`nDone. Reload/restart WoW (or /reload) to pick up the linked addon." diff --git a/Tools/validate-toc.lua b/Tools/validate-toc.lua new file mode 100644 index 0000000..169fafa --- /dev/null +++ b/Tools/validate-toc.lua @@ -0,0 +1,170 @@ +--[[ + Validates every *.toc file in the repo root. + + Pure Lua 5.1, no WoW client or Ace3 libraries required, so it can run + identically locally (`lua5.1 Tools/validate-toc.lua`) and in CI. + + Checks performed per .toc file: + 1. Required metadata fields are present (Title, Version, Interface, + SavedVariables). + 2. The `## Version` field is the packager placeholder `@project-version@` + (this repo's single source of truth for versioning is the git tag, + substituted in by BigWigsMods/packager at build time - a hardcoded + version here would silently stop being the real version). + 3. Every `## Interface*` value is a plausible numeric interface number. + 4. Every non-comment, non-blank line (a file/script reference) points + at a file that actually exists on disk. + 5. No file is listed more than once (duplicate loads). + + Exits with status 0 if every .toc file is valid, or 1 (printing every + problem found, not just the first) otherwise. +]] + +local REQUIRED_FIELDS = { "Title", "Version", "Interface", "SavedVariables" } +local VERSION_PLACEHOLDER = "@project-version@" + +local function scriptDir() + local source = debug.getinfo(1, "S").source:sub(2) + return source:match("^(.*)[/\\][^/\\]+$") or "." +end + +-- Repo root is the parent of this script's directory (scripts/../). +local repoRoot = scriptDir() .. "/.." + +local function fileExists(path) + local f = io.open(path, "rb") + if f then + f:close() + return true + end + return false +end + +local function findTocFiles() + local files = {} + -- io.popen isn't guaranteed everywhere, but is available on the + -- GitHub Actions runners and typical local dev machines this targets. + local list = io.popen('dir /b "' .. repoRoot .. '\\*.toc" 2>nul') -- Windows + local ok = list and list:read("*l") + if list then + if ok then + files[#files + 1] = ok + for line in list:lines() do + files[#files + 1] = line + end + end + list:close() + end + if #files == 0 then + -- Fall back to a POSIX shell (Linux/macOS runners). + local posixList = io.popen('ls "' .. repoRoot .. '" 2>/dev/null | grep "\\.toc$"') + if posixList then + for line in posixList:lines() do + files[#files + 1] = line + end + posixList:close() + end + end + return files +end + +local errors = {} + +local function fail(tocName, fmt, ...) + errors[#errors + 1] = string.format("%s: " .. fmt, tocName, ...) +end + +local function validateToc(tocName) + local path = repoRoot .. "/" .. tocName + local f = io.open(path, "r") + if not f then + fail(tocName, "could not open file") + return + end + + local fields = {} + local referencedFiles = {} + local seen = {} + local firstLine = true + + for rawLine in f:lines() do + local line = rawLine:gsub("\r$", "") + if firstLine then + -- Strip a leading UTF-8 BOM (EF BB BF), common in files saved by + -- Windows editors, so it doesn't corrupt parsing of line 1. + line = line:gsub("^\239\187\191", "") + firstLine = false + end + + local field, value = line:match("^##%s*([%w%-]+)%s*:%s*(.-)%s*$") + if field then + fields[field] = value + elseif line:match("^%s*$") or line:match("^##") or line:match("^#") then + -- blank line, metadata continuation, or plain comment - skip + else + local relPath = line:gsub("\\", "/"):match("^%s*(.-)%s*$") + if relPath ~= "" then + referencedFiles[#referencedFiles + 1] = relPath + local key = relPath:lower() + if seen[key] then + fail(tocName, "file is listed more than once: %s", relPath) + end + seen[key] = true + end + end + end + f:close() + + for _, required in ipairs(REQUIRED_FIELDS) do + if fields[required] == nil then + fail(tocName, "missing required field: ## %s", required) + end + end + + if fields.Version and fields.Version ~= VERSION_PLACEHOLDER then + fail( + tocName, + "## Version should be the packager placeholder '%s', got '%s' " + .. "(the git tag is the single source of truth for the real version)", + VERSION_PLACEHOLDER, + fields.Version + ) + end + + for field, value in pairs(fields) do + if field == "Interface" or field:match("^Interface%-") then + if not value:match("^%d%d%d%d%d+$") then + fail(tocName, "## %s has a non-numeric/implausible value: '%s'", field, value) + end + end + end + + for _, relPath in ipairs(referencedFiles) do + local fullPath = repoRoot .. "/" .. relPath + if not fileExists(fullPath) then + fail(tocName, "referenced file not found: %s", relPath) + end + end +end + +local tocFiles = findTocFiles() + +if #tocFiles == 0 then + print("No .toc files found in " .. repoRoot) + os.exit(1) +end + +for _, tocName in ipairs(tocFiles) do + print("Validating " .. tocName) + validateToc(tocName) +end + +if #errors > 0 then + print(string.format("\n%d problem(s) found:\n", #errors)) + for _, err in ipairs(errors) do + print(" - " .. err) + end + os.exit(1) +end + +print("\nAll .toc files are valid.") diff --git a/changelog.txt b/changelog.txt index ee1f7af..5bd52d7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,2 +1,13 @@ v1.0.0 - Re-released with a new name and minor code base changes to not violate curse forge ToS. \ No newline at end of file + Re-released with a new name and minor code base changes to not violate curse forge ToS. + +Unreleased + - Updated Interface versions: Retail 12.0.7, Classic Era 1.15.9, Mists of Pandaria Classic 5.5.4 (replacing the retired Cataclysm Classic flavor). + - Replaced hardcoded Retail/Classic branching for the unit menu and tooltip hookups with runtime feature detection. + - Guarded Blizzard Settings panel and legacy Shitlist migration lookups against clients/accounts where the relevant globals or SavedVariables don't exist. + - Added a SavedVariables schema version field and migration hook for future data format changes. + - Added Lua unit tests for the legacy Shitlist key-parsing logic and feature detection helpers, plus new CI workflows for tests and addon/TOC validation. + - Restored a fix (previously only tested live on Wrath Classic, never merged) for clients where the `C_AddOns`/`C_UI` namespaces don't exist yet: legacy Shitlist load/disable/reload now falls back to the older global `LoadAddOn`/`DisableAddOn`/`ReloadUI` functions via feature detection. + - Fixed a right-click minimap error ("bad argument #1 to 'OpenSettingsPanel'") by using the registered category ID returned from AddToBlizOptions() instead of the addon name when opening the Blizzard options panel. + - Added `_beta_`, `_anniversary_` flavor detection to Tools/Install-LocalAddon.ps1. + - Rebuilt the GitHub Actions pipeline: added StyLua formatting (CI-enforced), a stricter Luacheck config with explicit globals instead of blanket-ignored warnings, a pure-Lua TOC/addon validator (Tools/validate-toc.lua), and a local `make format/lint/test/validate/build/check` developer workflow. Replaced the old luacheck/tests/validate-toc/build/tag_and_release workflows with a single non-publishing `ci.yml` and a `release.yml` that only tags/publishes on a merged pull request, with pinned action versions (BigWigsMods/packager@v2.5.1, K-Phoen/semver-release-action@v1.3.2, JohnnyMorganz/stylua-action@v5). \ No newline at end of file