1# ZnoteX plugins
2
3A plugin is a folder in `plugins/`. It can add public pages, admin pages,
4database tables and behaviour **without editing a single ZnoteX file** — which
5is the whole point: the next update replaces ZnoteX and leaves your work alone.
6
7Install, update, enable and disable them in **Admin Panel → Plugins**.
8
9**Installing one:** download it, unzip the folder into `plugins/`, reload the
10panel, press *Install*. That runs its `install.sql` and switches it on. ZnoteX
11never downloads a plugin by itself — a plugin is PHP that runs on every page of
12your site, so putting the files there stays a deliberate act.
13
14**Updating one:** replace the folder with the newer version. If its
15`plugin.json` carries a higher `version` than the one recorded at install time,
16an *Update* button appears and re-runs `install.sql`. That is why the file has
17to be idempotent — see below.
18
19---
20
21## The folder
22
23```
24plugins/my_plugin/
25 plugin.json name, version, author, description [required]
26 plugin.php registers hooks and helpers [optional]
27 pages/<page>.php public page at page.php?plugin=my_plugin&p=<page>
28 admin/<mod>.php admin page, listed in the sidebar
29 install.sql tables, created when the plugin is enabled
30 assets/ css, js, images
31```
32
33Only `plugin.json` is required. A plugin that just reacts to a hook is a
34`plugin.json` and a `plugin.php`, nothing else.
35
36The folder name is the plugin's identity: lowercase letters, digits, `-` and
37`_`. A folder starting with `_` is ignored, which is how you park one.
38
39### plugin.json
40
41```json
42{
43 "name": "My Plugin",
44 "version": "1.0.0",
45 "author": "You",
46 "description": "One or two sentences shown in the admin panel.",
47 "url": "https://example.com",
48 "requires": {
49 "znotex": ">=2.0.0 <3.0.0",
50 "php": ">=8.1",
51 "api": "^1.0",
52 "extensions": ["json"]
53 }
54}
55```
56
57The plugin version is independent from theme and ZnoteX versions. The
58`requires` object declares which host environment the plugin supports.
59ZnoteX refuses to install, enable or load an incompatible plugin. The legacy
60string form, such as `"requires": "2.0.0"`, remains supported and means
61ZnoteX 2.0.0 or newer.
62
63---
64
65## plugin.php
66
67Loaded on **every request** while the plugin is enabled, right after the
68database and the settings are up. So:
69
70- register hooks and declare functions here — that is all it is for;
71- never print anything;
72- keep it cheap. A query here is a query on every page of the site. Do the work
73 inside the hook, where it only runs when it is needed.
74
75A `plugin.php` that throws is skipped and logged. One broken plugin does not
76take the site down.
77
78### Stable extension API
79
80```php
81$api = znote_plugin_api('my_plugin');
82
83$api->on('shop.purchased', function (array $data): void {
84});
85
86$value = $api->setting('enabled', '1');
87$cache = $api->cache('catalogue', 300);
88$url = $api->url('shop');
89$asset = $api->asset('style.css');
90$db = $api->database();
91```
92
93The API also exposes `config()`, `setSetting()`, `dispatch()`,
94`filter()`, `collect()`, `allows()`, `apiVersion()` and
95`znoteVersion()`. Plugin settings and cache keys are automatically isolated
96under the plugin name.
97
98---
99
100## Pages
101
102Drop `pages/shop.php` into your plugin and it is live at
103`page.php?plugin=my_plugin&p=shop`. Nothing to register.
104
105The file is a **fragment**: `page.php` has already run `engine/init.php` and
106opened the theme, so `$config`, `$user_data` and the `mysql_*` helpers are all
107there, and the active theme wraps whatever you print. Do not include
108`init.php`, and do not print a header or a footer.
109
110`?p=` is matched against the files that actually exist, so it can never reach
111anything outside `pages/`. A page of a plugin that is not installed and enabled
112returns 404.
113
114`plugins/.htaccess` blocks direct requests to anything but `assets/`, so nobody
115can run one of your files outside `page.php` or read your `install.sql`. Guard
116your pages anyway — someone will run ZnoteX on a server that ignores
117`.htaccess`:
118
119```php
120if (!isset($config)) { http_response_code(403); die('Direct access denied.'); }
121```
122
123Link to one with `znote_plugin_url('my_plugin', 'shop')`, and to a file in
124`assets/` with `znote_plugin_asset('my_plugin', 'style.css')`.
125
126The `<body>` gets a `page_my_plugin_shop` class, so a theme can style your page
127from CSS alone.
128
129---
130
131## settings.json
132
133Ship one and the plugin gets a configuration page for free - a **Settings**
134button next to it in Admin Panel > Plugins - instead of hand-coding a form:
135
136```json
137{
138 "fields": [
139 {"key": "api_key", "label": "API key", "type": "text", "default": ""},
140 {"key": "enabled", "label": "Enabled", "type": "bool", "default": "1"},
141 {"key": "mode", "label": "Mode", "type": "select", "default": "test",
142 "options": {"test": "Test", "live": "Live"}},
143 {"key": "max_items", "label": "Max items", "type": "int", "default": "10", "min": 1, "max": 100},
144 {"key": "notes", "label": "Notes", "type": "textarea", "default": ""},
145 {"key": "webhook_secret", "label": "Webhook secret", "type": "password", "default": ""}
146 ]
147}
148```
149
150Types: `text`, `textarea`, `password`, `bool`, `int` (with optional `min`/`max`),
151`select` and `checklist` (both need `options`). Every field is optional except
152`key` and `type`.
153
154Saved values live under the same namespace `$api->setting()` already reads, so
155`plugin.php` sees exactly what the generated form saved:
156
157```php
158$mode = $api->setting('mode', 'test');
159```
160
161---
162
163## Admin pages
164
165Drop `admin/orders.php` into your plugin and it appears in the admin sidebar.
166It is written exactly like a built-in module — see `admin/modules/_template.php`
167— with the same docblock header and the same `acp_*` helpers:
168
169```php
170<?php
171/**
172 * Title: Orders
173 * Icon: fa-shopping-cart
174 * Group: Economy
175 * Order: 60
176 * Description: One line under the page title.
177 */
178```
179
180Its key is namespaced `my_plugin__orders`, so a plugin can never shadow a core
181module by picking the same filename. Use that key with `acp_redirect()`.
182
183---
184
185## install.sql
186
187Run on *Install* and again on every *Update*, statement by statement.
188
189**Every statement must be idempotent** — `CREATE TABLE IF NOT EXISTS` and the
190like. ZnoteX does not track which statements already ran, so an update simply
191runs the whole file again: whatever the new version added gets created, and what
192was already there is left alone with its data intact.
193
194Neither *Disable* nor *Uninstall* **ever drops a table**. Losing a player's data
195because someone clicked a button would be the wrong default. Removing a plugin
196for good is deleting its folder and dropping its tables yourself.
197
198### Versioning
199
200The version in `plugin.json` is what the update check compares, with PHP's
201`version_compare()`. Raise it whenever you ship a change that needs
202`install.sql` re-run, and keep it plain: `1.0.0`, `1.1.0`, `2.0.0`.
203
204---
205
206## Hooks
207
208```php
209// React to something. Return value ignored.
210znote_hook_register('shop.purchased', function (array $data) { ... });
211
212// Change a value. Gets the current value, returns the new one.
213znote_hook_register('shop.price', function ($price, array $data) { return $price - 5; });
214
215// Add markup. Whatever you return is inserted into the page.
216znote_hook_register('page.footer', function () { return '<div>...</div>'; });
217```
218
219An optional third argument is the priority, default `10`, lowest first.
220
221A callback that throws is caught and logged, and the site carries on. That is
222the difference between an extension point and a landmine.
223
224### The hooks ZnoteX fires
225
226| Hook | Kind | When | `$data` |
227|---|---|---|---|
228| `plugins.loaded` | notify | every plugin is loaded | — |
229| `page.head` | collect | before `</head>` | — |
230| `page.footer` | collect | before `</body>` | — |
231| `shop.price` | filter | before a purchase is priced | `account_id`, `offer_id`, `offer` |
232| `shop.purchased` | notify | after the points are taken | `account_id`, `offer_id`, `type`, `itemid`, `count`, `points` |
233| `account.registered` | notify | after an account is created | `name`, `email` |
234| `character.created` | notify | after a character is created | `name`, `account_id`, `vocation` |
235| `character.renamed` | notify | after an admin renames a character | `player_id`, `old_name`, `new_name` |
236| `payment.completed` | notify | after a real-money payment is recorded | `provider`, `reference`, `provider_reference`, `account_id`, `price`, `currency` |
237
238`shop.price` is the one to reach for when you want to change what something
239costs. Its result is used for all three of the affordability check, the points
240actually deducted and the shop log, so they cannot disagree.
241
242`page.head` and `page.footer` are injected into the theme's own output, so they
243work with themes written long before your plugin existed — including ones that
244never call a plugin function.
245
246### Your own hooks
247
248Publish one and other plugins can extend yours:
249
250```php
251znote_hook('coupon.redeemed', array('code' => $code, 'account_id' => $id));
252```
253
254### Need a hook that isn't there?
255
256Adding one is two lines in the core file, and hooks with no listeners cost
257almost nothing. Open an issue rather than forking.
258
259---
260
261## The example
262
263`plugins/shop_coupons/` is a working plugin that uses every one of these:
264a public page, an admin page, its own tables, a filter hook that discounts a
265purchase, a notify hook that consumes the discount afterwards, and a collect
266hook that puts a banner in the footer. Read it top to bottom — it is commented
267as a tutorial rather than as production code.
268
269---
270
271## Checklist
272
273- [ ] Folder name is lowercase, unique, and matches nothing in ZnoteX.
274- [ ] `plugin.json` is valid JSON.
275- [ ] `plugin.php` prints nothing and runs no queries at load.
276- [ ] `install.sql` is idempotent.
277- [ ] Tables and functions are prefixed with the plugin name.
278- [ ] Every form has `<?= acp_csrf_field() ?>` (admin) or `Token::create()` (public).
279- [ ] Every value that reaches SQL goes through `esc()` or `(int)`.
280- [ ] Every value that reaches the page goes through `h()` or `htmlspecialchars()`.
281- [ ] It still works when it is disabled — that is, nothing else references it.
282