From b38f03c0e7218762d90fa5092696b127f24f36db Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 25 Jan 2026 07:07:26 +0000
Subject: [PATCH] =Some logical flaws in Queue.php, Queue.js, ContentExecutor.php, UploadExecutor.php - particularly with timeline ordering, frontend queue updates, etc
---
inc/managers/SchemaManager.php | 6
src/summary/render.php | 22
inc/blocks/SummaryBlock.php | 8
inc/managers/Cache.php | 652 ++++++
jvb.php | 6
inc/helpers/formatting.php | 33
inc/rest/routes/TermRoutes.php | 10
inc/managers/OperationQueue.php | 16
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 49
inc/rest/routes/ContentRoutes.php | 175 +
inc/rest/routes/FeedRoutes.php | 200
inc/meta/MetaFormOld.php | 4
inc/rest/routes/ApprovalRoutes.php | 4
inc/managers/_setup.php | 16
assets/js/concise/CRUD.js | 66
inc/helpers/crud.php | 4
inc/rest/routes/ResponseRoutes.php | 26
JVBase.php | 7
build/summary/render.php | 22
inc/managers/LoginManager.php | 4
inc/integrations/Square.php | 2
inc/meta/MetaManager.php | 3
assets/js/concise/Queue.js | 129 +
inc/integrations/Integrations.php | 8
inc/rest/routes/SEORoutes.php | 8
src/feed/style.scss | 8
inc/rest/routes/NewsRoutes.php | 4
build/drawer-menu/render.php | 5
inc/helpers/breadcrumbs.php | 2
build/feed/style-index.css | 2
assets/js/concise/UploadManager.js | 46
src/feed/view.js | 42
activate.php | 2
inc/rest/routes/ReferralRoutes.php | 12
inc/blocks/FAQBlock.php | 10
inc/managers/SEO/_edmonotonink.php | 4
inc/managers/MagicLinkManager.php | 12
inc/managers/queue/Storage.php | 13
inc/rest/routes/UploadRoutes.php | 3
assets/js/min/populate.min.js | 2
inc/managers/SEO/SEOAdminPage.php | 1
inc/managers/SEO/SchemaOutputManager.php | 16
inc/integrations/GoogleMyBusiness.php | 5
inc/blocks/GlossaryBlock.php | 9
checks.php | 47
inc/managers/CacheManagerOld.php | 930 ++++++--
inc/managers/queue/executors/UploadExecutor.php | 28
assets/js/concise/TaxonomySelector.js | 59
inc/managers/ScriptLoader.php | 2
inc/utility/Image.php | 33
inc/rest/routes/Invitations.php | 2
inc/rest/routes/SettingsRoutes.php | 12
assets/js/concise/DataStore.js | 133
src/drawer-menu/render.php | 5
assets/js/min/dataStore.min.js | 2
build/feed/view.js | 2
inc/rest/routes/LoginRoutes.php | 4
inc/integrations/Helcim.php | 2
inc/users/UserSettings.php | 2
build/list/render.php | 4
inc/rest/routes/NotificationsRoutes.php | 12
inc/helpers/ui.php | 2
inc/forms/TaxonomySelector.php | 6
inc/registry/TaxonomyRegistrar.php | 33
inc/managers/CRUDManager.php | 6
assets/js/concise/UtilityFunctions.js | 41
assets/js/min/queue.min.js | 2
inc/blocks/CustomBlocks.php | 15
inc/rest/routes/OptionsRoutes.php | 6
assets/js/min/utility.min.js | 2
inc/managers/SEO/TemplateResolver.php | 4
inc/blocks/TimelineBlock.php | 16
inc/managers/queue/executors/ContentExecutor.php | 66
inc/managers/NewsRelationships.php | 8
inc/managers/TaxonomyRelationships.php | 11
inc/managers/NotificationManager.php | 248 +-
inc/managers/IconsManager.php | 48
build/feed/style-index-rtl.css | 2
inc/helpers/time.php | 14
assets/js/min/selector.min.js | 2
assets/js/concise/FormController.js | 43
inc/managers/DirectoryManager.php | 26
inc/integrations/Umami.php | 4
inc/rest/routes/FormRoutes.php | 6
inc/blocks/FeedBlock.php | 63
inc/managers/SEO/BreadcrumbManager.php | 33
inc/registry/PostTypeRegistrar.php | 1
inc/managers/UserTermsManager.php | 70
assets/js/min/crud.min.js | 2
inc/EmbedGenerator.php | 2
assets/js/concise/PopulateForm.js | 7
inc/forms/PostSelector.php | 9
inc/helpers/renderFields.php | 16
inc/rest/routes/FavouritesRoutes.php | 212 +
assets/js/concise/TaxonomyCreator.js | 7
inc/managers/ReferralManager.php | 154
inc/managers/queue/Queue.php | 30
inc/helpers/members.php | 87
inc/blocks/MenuBlock.php | 24
inc/rest/routes/QueueRoutes.php | 9
inc/managers/AdminPages.php | 147
inc/managers/SEOMetaManager.php | 34
assets/js/min/uploader.min.js | 2
assets/js/min/creator.min.js | 2
build/feed/view.asset.php | 2
templates/dashboard/sections/news.php | 6
/dev/null | 1206 -----------
src/list/render.php | 4
inc/rest/RestRouteManager.php | 472 ++--
inc/rest/routes/AdminRoutes.php | 2
inc/blocks/FormBlock.php | 24
112 files changed, 3,190 insertions(+), 3,007 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index d2b0ad9..b6b64a5 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -3,7 +3,7 @@
use JVBase\blocks\CustomBlocks;
use JVBase\integrations\BlueSky;
-use JVBase\managers\CacheManager;
+use JVBase\managers\cache\Cache;
use JVBase\managers\EmailManager;
use JVBase\managers\ErrorHandler;
use JVBase\managers\LoginManager;
@@ -217,10 +217,7 @@
{
return $this->managers['file'];
}
- public function cache():CacheManager
- {
- return $this->managers['cache'];
- }
+
public function queue():Queue
{
return $this->managers['queue'];
diff --git a/activate.php b/activate.php
index 0dad565..83ce7d5 100644
--- a/activate.php
+++ b/activate.php
@@ -1,6 +1,7 @@
<?php
use JVBase\integrations\Umami;
+use JVBase\managers\Cache;
use JVBase\managers\ReferralManager;
use JVBase\managers\SEO\SEOAdminPage;
use JVBase\utility\Features;
@@ -203,6 +204,7 @@
jvbDeleteOptions();
jvbDeleteDashboard();
jvbDeleteDirectories();
+ Cache::flushAll();
do_action('jvbDeactivate');
}
diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index 6eee959..8d92f41 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -42,8 +42,8 @@
const baseSetup = (el, refs, data) => {
el.dataset.itemId = data.id;
-
- window.prefixInput(refs.checkbox, `select-${data.id}`, true);
+ let wrapper = refs.checkbox.closest('.preview');
+ window.prefixInput(refs.checkbox, `select-${data.id}`, wrapper, true);
refs.checkbox.value = data.id;
refs.checkbox.checked = crud.selected.has(parseInt(data.id));
if (refs.selectLabel) refs.selectLabel.htmlFor = `select-${data.id}`;
@@ -131,7 +131,8 @@
baseSetup(el, refs, data);
manyRefs?.inputs?.forEach(el => {
- window.prefixInput(el, `${data.id}-`);
+ let wrapper = el.closest('[data-field]');
+ window.prefixInput(el, `${data.id}-`, wrapper);
});
manyRefs?.status?.forEach(el => {
@@ -143,7 +144,8 @@
if (crud.isTimeline) {
if (refs.sharedRow) {
refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => {
- window.prefixInput(input, `${data.id}-`);
+ let wrapper = input.closest('[data-field]');
+ window.prefixInput(input, `${data.id}-`, wrapper);
});
crud.populate.populate(refs.sharedRow, data);
@@ -164,7 +166,8 @@
point.dataset.itemId = timeline.id;
point.querySelectorAll('input,select,textarea').forEach(input => {
- window.prefixInput(input, `${timeline.id}-`);
+ let wrapper = input.closest('[data-field]');
+ window.prefixInput(input, `${timeline.id}-`, wrapper);
});
crud.populate.populate(point, {
@@ -185,7 +188,8 @@
if (crud.ui.table.form?.dataset.edit !== undefined) {
// Non-timeline: prefix all inputs normally
manyRefs?.inputs?.forEach(input => {
- window.prefixInput(input, `${data.id}-`);
+ let wrapper = input.closest('[data-field]');
+ window.prefixInput(input, `${data.id}-`, wrapper);
});
manyRefs?.status?.forEach(el => {
@@ -480,37 +484,35 @@
}
if (event === 'operation-status'
&& data.status === 'completed'
- && data.endpoint === 'content'
- && Object.keys(data.data?.posts??{}).length > 0) {
+ && data.endpoint === 'uploads/groups') {
+ console.log('Cleared local cache. Refresh to see changes');
this.store.clearCache();
- let ids = Object.keys(data.data.posts);
- let storedChanges = this.changesStore.getMany(ids);
+ }
+ if (event === 'operation-status'
+ && data.status === 'completed'
+ && data.type === 'content_update') {
+ console.log('Cleared local cache. Refresh to see changes');
+ this.store.clearCache();
- this.changesStore.deleteMany(ids);
-
- for (let id of ids) {
- let stored = storedChanges.filter(change => change.id === id)[0]??false;
-
- let sentChanges = data.data.posts[id];
- let remainingChanges = {};
-
- for (let [key, value] of Object.entries(sentChanges)) {
- if (stored && !Object.hasOwn(stored, key)) continue;
- if (stored[key] === value) {
- delete stored[key];
- }
- remainingChanges[key] = value;
- }
- if (Object.keys(remainingChanges).length > 0) {
- remainingChanges['id'] = id;
- remainingChanges['content'] = this.content;
- this.changes.set(id, remainingChanges);
- }
+ // Check for result data (from ContentExecutor)
+ if (!data.result || !data.result.posts) {
+ console.warn('Content update completed but no result.posts', data);
+ return;
}
- if (Object.values(this.changes).length > 0) {
- this.scheduleBackup();
+
+ // Get successfully processed post IDs
+ const successfulIds = Object.keys(data.result.posts).filter(id => {
+ return data.result.posts[id]?.success === true;
+ });
+
+ if (successfulIds.length === 0) {
+ return;
}
+
+ // Clear from both persistent and in-memory storage
+ this.changesStore.deleteMany(successfulIds);
+ successfulIds.forEach(id => this.changes.delete(id));
}
});
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 58ec874..29dc9c7 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -45,7 +45,7 @@
* @param {object|array} configs An object defining the store, or an array of objects defining the stores
* @param {number} version the database version
*/
- register(name, configs = [], version = 1.2) {
+ register(name, configs = [], version = 1.25) {
if (!Array.isArray(configs)) configs = [configs];
if (configs.length === 0) return;
@@ -108,6 +108,7 @@
store.ignoreFilters = new Set([
... ['search', 'page', 'per_page', 'orderby', 'order'],
+ ... ['context', 'source'],
... store.config.ignore
]);
@@ -517,9 +518,10 @@
const cached = store.cache.get(cacheKey);
if (cached && this.isCacheValid(cached, store.config.TTL)) {
+ let items = cached.items.map(itemId => this.get(name, itemId));
this.notify(name, 'data-loaded', {
cached: true,
- items: cached.items || []
+ items: items??[]
});
return cached;
}
@@ -1074,42 +1076,57 @@
// First check if we have cached results for exact filters
if (cacheEntry?.items) {
- return this.applyOrdering(
- cacheEntry.items.reduce((acc, id) => {
- const item = store.data.get(id);
- if (item) acc.push(item);
- return acc;
- }, []),
- store
- );
+ const items = cacheEntry.items.reduce((acc, id) => {
+ const item = store.data.get(id);
+ if (item) acc.push(item);
+ return acc;
+ }, []);
+ return this.applyOrdering(items, store);
}
const allItems = Array.from(store.data.values());
+
const searchQuery = store.filters.search?.toLowerCase().trim() || '';
const filterPredicates = [];
+
+ // Handle taxonomy filters separately
+ if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
+ Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
+ const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
+
+ filterPredicates.push(item => {
+ if (!item.taxonomies || !item.taxonomies[taxonomy]) {
+ return false;
+ }
+ const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
+ const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
+ return matches;
+ });
+ });
+ }
+
+ // Handle other filters
for (const [key, value] of Object.entries(store.filters)) {
- if (store.ignoreFilters.has(key)) continue;
+ if (key === 'taxonomy') continue;
+ if (store.ignoreFilters.has(key)) {
+ continue;
+ }
if (value === null || value === undefined || value === '') continue;
if (value === 'all') continue;
- // Comma-separated values
if (typeof value === 'string' && value.includes(',')) {
const accepted = value.split(',').map(v => v.trim());
filterPredicates.push(item => accepted.includes(String(item[key])));
- continue;
+ } else {
+ filterPredicates.push(item => String(item[key]) === String(value));
}
-
- filterPredicates.push(item => String(item[key]) === String(value));
}
const filtered = allItems.filter(item => {
- // Apply all non-search filters
for (const predicate of filterPredicates) {
if (!predicate(item)) return false;
}
-
- // Apply search if present
return !(searchQuery && !this.searchObject(item, searchQuery));
});
@@ -1120,37 +1137,50 @@
if (!Array.isArray(items)) items = Array.from(items);
if (items.length === 0) return items;
- if (store.filters.orderby || store.filters.order) {
- const orderby = store.filters.orderby || 'date';
- const order = (store.filters.order || 'desc').toLowerCase();
+ const orderby = store.filters.orderby || 'date';
+ const order = (store.filters.order || 'desc').toLowerCase();
- items.sort((a, b) => {
- let aVal, bVal;
-
- switch (orderby) {
- case 'alphabetical':
- case 'title':
- aVal = (a.fields?.post_title || a.title || a.name || '').toLowerCase();
- bVal = (b.fields?.post_title || b.title || b.name || '').toLowerCase();
- break;
- case 'modified':
- aVal = new Date(a.modified || 0);
- bVal = new Date(b.modified || 0);
- break;
- case 'date':
- default:
- aVal = new Date(a.date || 0);
- bVal = new Date(b.date || 0);
- }
-
- if (aVal < bVal) return order === 'asc' ? -1 : 1;
- if (aVal > bVal) return order === 'asc' ? 1 : -1;
- return 0;
- });
+ // Handle random ordering
+ if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
+ return this.shuffle(items);
}
+
+ items.sort((a, b) => {
+ let aVal, bVal;
+
+ switch (orderby) {
+ case 'alphabetical':
+ case 'title':
+ aVal = (a.title || a.name || '').toLowerCase();
+ bVal = (b.title || b.name || '').toLowerCase();
+ break;
+ case 'modified':
+ aVal = new Date(a.modified || a.date || 0);
+ bVal = new Date(b.modified || b.date || 0);
+ break;
+ case 'date':
+ default:
+ aVal = new Date(a.date || a.modified || 0);
+ bVal = new Date(b.date || b.modified || 0);
+ }
+
+ if (aVal < bVal) return order === 'asc' ? -1 : 1;
+ if (aVal > bVal) return order === 'asc' ? 1 : -1;
+ return 0;
+ });
+
return items;
}
+ shuffle(items) {
+ const array = items.slice();
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+ return array;
+ }
+
searchObject(obj, search) {
if (!obj || typeof obj !== 'object') {
return typeof obj === 'string' && obj.toLowerCase().includes(search);
@@ -1201,23 +1231,22 @@
store.filters[key] = value;
}
});
-
this.notify(name, 'filters-changed', {
oldFilters,
filters: store.filters,
updates
});
- this.notify(name, 'data-loaded', {
- cached: true,
- items: this.getFiltered(name)
- });
-
const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
if (store.config.endpoint && shouldFetch) {
await this.fetch(name);
- } else if (store.config.endpoint) {
- this.notify(name, 'data-loaded');
+ } else {
+ const filtered = this.getFiltered(name);
+ this.notify(name, 'data-loaded', {
+ cached: true,
+ items: filtered
+ });
}
}
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 9a3f632..0d7e7ca 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -378,11 +378,14 @@
if (this.subscribers.size > 0) {
e.preventDefault();
console.log('Cancelling scheduled backup and manually backing up');
- this.cancelBackup();
- await this.backup();
- const storedData = await this.store.get(form.id);
+
+
if (form.options.cache) {
+ this.cancelBackup();
+ await this.backup();
+ const storedData = await this.store.get(form.id);
+
this.notify('form-submit', {
config: form,
data: storedData.changes
@@ -812,7 +815,8 @@
let index = config.ui.items?.children?.length??0;
el.dataset.index = index;
manyRefs.inputs?.forEach(input => {
- window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+ let wrapper = el.closest('[data-field]');
+ window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper);
});
}
},
@@ -883,7 +887,8 @@
let index = config.ui.items?.children?.length??0;
el.dataset.index = index;
manyRefs.inputs?.forEach(input => {
- window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+ let wrapper = window.closest('.tag-item');
+ window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
});
if (refs.label) {
@@ -1179,19 +1184,29 @@
* @param {HTMLElement} container
*/
reindexList(container) {
+ const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+
Array.from(container.children).forEach((item, index) => {
item.dataset.index = `${index}`;
- Array.from(item.children).forEach(child => {
- if (child.type === 'hidden') {
- window.prefixInput(
- child,
- `${container.dataset.field}:${index}:${child.dataset.field}`
- );
- }
+
+ // Find ALL inputs within this item, not just direct children
+ const inputs = item.querySelectorAll('input, select, textarea');
+
+ inputs.forEach(input => {
+ // Skip inputs that shouldn't be re-indexed (like file inputs)
+ if (input.type === 'file') return;
+
+ // Get the field name from the input's data-field or name
+ const inputField = input.dataset.field || input.name.split(':').pop();
+
+ // Re-prefix with the new index, passing item as wrapper
+ window.prefixInput(
+ input,
+ `${fieldName}:${index}:`,
+ item // Pass the item as wrapper for label lookup
+ );
});
});
-
- //schedule save
}
/**********************************************************************
VALIDATION
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 16d3460..d1c3f2e 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -345,7 +345,8 @@
el.dataset.itemId = data.id;
if (refs.select) {
- window.prefixInput(refs.select, `${data.id}-`);
+ let wrapper = refs.select.closest('.preview');
+ window.prefixInput(refs.select, `${data.id}-`, wrapper);
}
if (refs.video) refs.video.remove();
if (refs.file) refs.file.remove();
@@ -385,9 +386,9 @@
if (!p.isEmptyValue(value)) {
p.populateField(field, name, value);
}
- const input = field.querySelector('input:not([type="file"]), textarea');
+ const input = field.querySelector('input:not([type="file"])');
if (!input) continue;
- window.prefixInput(input, `[${data.id}]`);
+ window.prefixInput(input, `[${data.id}]`, field);
}
}
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index 4c9adbc..6c097c1 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -134,6 +134,7 @@
actions: {
cancel: 'button.cancel',
retry: 'button.retry',
+ refresh: 'button.refresh',
dismiss: 'button.dismiss',
}
},
@@ -198,6 +199,13 @@
return;
}
+
+ const refreshPage = window.targetCheck(e, this.selectors.actions.refresh);
+ if (refreshPage) {
+ this.handleRefresh(opId);
+ return;
+ }
+
const clear = window.targetCheck(e, this.selectors.actions.clear);
if (clear) {
this.opActions('completed', 'dismiss').then(()=>{});
@@ -295,22 +303,90 @@
this.store.subscribe((event, data) => {
switch (event) {
case 'data-loaded':
+ const serverOps = this.store.getAll();
+
+ serverOps.forEach(serverOp => {
+ const localOp = this.queue.get(serverOp.id);
+ const mapped = this.mapServerOperation(serverOp);
+
+ this.queue.set(mapped.id, mapped);
+
+ // Notify if changed
+ if (localOp && localOp.status !== mapped.status) {
+ this.notify('operation-status', mapped);
+ }
+ });
+
+ this.maybeStartPolling();
+ this.updateUI();
+ break;
+
case 'items-save':
this.maybeStartPolling();
this.updateUI();
break;
+
case 'item-saved':
- if (data.previousItem && data.previousItem.status !== data.item.status) {
- this.updateOperationStatus(data.item.id, data.item.status);
+ if (data.item) {
+ this.queue.set(data.item.id, data.item);
+ if (data.previousItem?.status !== data.item.status) {
+ this.notify('operation-status', data.item);
+ }
}
this.maybeStartPolling();
break;
- default:
-
- break;
}
});
}
+
+ /**
+ * Handle refresh button click - clears cache for the relevant store
+ */
+ handleRefresh(opId) {
+ const op = this.getQueue(opId);
+ if (!op) return;
+
+ // Determine which store to refresh based on operation type
+ let storeName = null;
+
+ // Map operation types to store names
+ const typeToStore = {
+ 'content_update': op.data?.posts ? Object.values(op.data.posts)[0]?.content : null,
+ 'batch_creation': op.data?.content,
+ 'image_upload': 'uploads',
+ 'video_upload': 'uploads',
+ 'document_upload': 'uploads',
+ };
+
+ storeName = typeToStore[op.type];
+
+ // If we found a store name, clear its cache
+ if (storeName && window.jvbStore) {
+ const store = window.jvbStore.stores.get(storeName);
+ if (store) {
+ window.jvbStore.clearCache(storeName);
+ window.jvbStore.fetch(storeName);
+
+ // Give visual feedback
+ const button = this.items.get(opId)?.ui?.actions?.refresh;
+ if (button) {
+ const originalText = button.querySelector('span').textContent;
+ button.querySelector('span').textContent = 'Refreshed!';
+ button.disabled = true;
+
+ setTimeout(() => {
+ button.querySelector('span').textContent = originalText;
+ button.disabled = false;
+ }, 2000);
+ }
+ }
+ } else {
+ // Fallback: just reload the page if we can't determine the store
+ if (confirm('Refresh the page to see changes?')) {
+ window.location.reload();
+ }
+ }
+ }
/****************************************************************************
OPERATIONS
****************************************************************************/
@@ -356,14 +432,16 @@
const existingOps = Array.from(this.getAllQueue()).filter(op=> {
return op.status === 'queued' &&
- op.endpoint === item.endpoint &&
- op.canMerge
+ op.endpoint === item.endpoint &&
+ op.canMerge
});
if (existingOps.length > 0) {
const existing = existingOps[0];
existing.data = window.deepMerge(existing.data, item.data);
existing.timestamp = Date.now();
+ this.setQueue(existing);
+
this.updateOperationStatus(existing.id, existing.status);
this.updateUI();
this.trackActivity();
@@ -844,6 +922,9 @@
item.ui.actions['retry'].hidden = op.status !=='failed';
}
if (item.ui.actions.dismiss) item.ui.actions.dismiss.hidden = this.pendingStatuses.includes(op.status);
+ if (item.ui.actions.refresh) {
+ item.ui.actions.refresh.hidden = op.status !== 'completed';
+ }
}
getProgress(op) {
if (op.progress) return op.progress;
@@ -901,7 +982,7 @@
case 'processing':
return item.progress ? `${item.progress}% complete` : 'Processing...';
case 'completed':
- return 'Successfully completed';
+ return 'Successfully completed. Refresh to see changes.';
case 'failed':
return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`;
case 'failed_permanent':
@@ -919,6 +1000,38 @@
this.isProcessing = on;
this.ui.toggle.button.classList.toggle('saving', on);
}
+
+ /**
+ * Map server operation format to frontend format
+ * Server uses: type, data (requestData), status (from state/outcome)
+ * Frontend uses: endpoint, data, status, headers, method, etc.
+ */
+ mapServerOperation(serverOp) {
+ const localOp = this.queue.get(serverOp.id);
+
+ // If we have local operation data, preserve it
+ if (localOp && localOp.endpoint) {
+ return {
+ ...localOp,
+ ...serverOp,
+ endpoint: localOp.endpoint,
+ method: localOp.method,
+ headers: localOp.headers,
+ };
+ }
+
+ // Minimal mapping for server-only operations
+ // Extract endpoint from type if possible, otherwise use type
+ const endpoint = serverOp.type ? serverOp.type.replace('_update', '').replace('_', '/') : 'unknown';
+
+ return {
+ ...serverOp,
+ endpoint: endpoint,
+ method: 'POST',
+ headers: { ...this.headers },
+ };
+ }
+
/****************************************************************************
SUBSCRIPTION
****************************************************************************/
diff --git a/assets/js/concise/TaxonomyCreator.js b/assets/js/concise/TaxonomyCreator.js
index 4a5d595..922c3a4 100644
--- a/assets/js/concise/TaxonomyCreator.js
+++ b/assets/js/concise/TaxonomyCreator.js
@@ -82,21 +82,22 @@
if (!data.name || data.name.length < 2) return false;
try {
const response = await this.createTerm(data);
-
+ let currentField = this.selector.currentField();
if (!response.success) {
// Term already exists - still add it
if (response.term && response.term.id) {
- this.selector.setMessage(true, `Using existing "${response.term.name}"`);
+ this.selector.setMessage(currentField,true, `Using existing "${response.term.name}"`);
return response.term;
}
// Other failure
- this.selector.setMessage(true, response.message || 'Creation failed', false);
+ this.selector.setMessage(currentField,true, response.message || 'Creation failed', false);
return false;
}
if (response.term?.pending) {
// Term requires approval
this.selector.setMessage(
+ currentField,
true,
`"${data.name}" submitted for approval`,
false
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 2f58c05..222545d 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -239,6 +239,13 @@
const field = this.fields.get(fieldId);
if (!fieldId || !field) return;
+ if (this.creator) {
+ let button = window.targetCheck(e, this.selectors.create.button);
+ if (button) {
+ this.maybeCreateTerm(e).then(()=>{});
+ }
+ }
+
const autocomplete = window.targetCheck(e, '.item.autocomplete');
if (autocomplete) {
@@ -320,14 +327,6 @@
this.ui.search.input.value = '';
}
}
-
- if (this.creator) {
- let button = window.targetCheck(e, this.selectors.create.button);
- if (button) {
- this.maybeCreateTerm(e).then(()=>{});
- }
- }
-
}
handleChange(e) {
if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
@@ -365,7 +364,7 @@
}
let query = e.target.value.trim();
- this.setMessage(true, `Searching for "${query}" in ${field.plural??'items'}`);
+ this.setMessage(field,true, `Searching for "${query}" in ${field.plural??'items'}`);
window.debouncer.schedule(
`${fieldId}-search`,
async () => {
@@ -390,7 +389,7 @@
return;
}
this.activeField = fieldId;
- this.setMessage(true, `Loading ${field.plural}...`);
+ this.setMessage(field,true, `Loading ${field.plural}...`);
this.resetFilters({taxonomy: field.taxonomy});
}
@@ -899,7 +898,7 @@
if (this.store.filters.page??1 === 1) {
window.removeChildren(this.ui.terms.list);
}
- this.setMessage(true, this.store.filters.search === ''
+ this.setMessage(field,true, this.store.filters.search === ''
? `No matching ${field.plural}.`
: `No ${field.plural} found.`,
false);
@@ -909,7 +908,7 @@
return;
}
- this.setCreateButton(true);
+ this.setCreateButton(field,true);
if (this.ui.terms.sentinel) {
if (this.store.lastResponse?.has_more) {
@@ -930,7 +929,7 @@
).then(()=>{});
if (terms.length > 0) {
- this.setMessage(false);
+ this.setMessage(field,false);
}
}
createTermElement(term) {
@@ -946,7 +945,7 @@
window.removeChildren(dropdown);
if (terms.length === 0) {
- this.setMessage(true, `No ${field.plural} found.`, false);
+ this.setMessage(field,true, `No ${field.plural} found.`, false);
} else {
window.chunkIt(
terms,
@@ -954,9 +953,9 @@
(fragment) => dropdown.append(fragment)
).then(()=>{});
- this.setMessage(false);
+ this.setMessage(field,false);
}
- this.setCreateButton(true);
+ this.setCreateButton(field,true);
if (field.ui.dropdown.wrapper) {
field.ui.dropdown.wrapper.hidden = false;
@@ -1084,7 +1083,6 @@
handlers[event]?.(data);
} catch (error) {
console.error(`Error handling store event "${event}":`, error);
- this.setMessage(true, 'An error occurred loading data', false);
}
}
handleDataLoaded() {
@@ -1103,11 +1101,9 @@
this.showResults(true);
return;
}
- this.setMessage(false);
}
showResults(isAutoComplete = false) {
- this.setMessage(false);
const terms = this.store.getFiltered();
const filters = this.store.filters;
const isSearch = filters.search && filters.search.length > 0;
@@ -1120,7 +1116,7 @@
if (!this.activeField && isAutoComplete) {
return;
}
-
+ this.setMessage(this.currentField(), false);
if (isAutoComplete) {
this.showAutocompleteTerms();
} else {
@@ -1140,7 +1136,7 @@
? `Failed to load ${field.plural}`
: 'Failed to load data';
- this.setMessage(true, message, false);
+ this.setMessage(field,true, message, false);
console.error('Store fetch error:', error);
}
async batchFetchTaxonomies() {
@@ -1171,14 +1167,14 @@
/**************************************************
LOADING
**************************************************/
- setCreateButton(show = true) {
- const field = this.currentField();
- if (!field || !field.canCreate || !this.creator) return;
+ setCreateButton(field, show = true) {
+ if (!field.canCreate || !this.creator) return;
const conf = (this.container.open) ? this.ui : field.ui;
if (!conf.create?.button || !conf.create?.span) return;
const createButton = conf.create.button;
+ createButton.hidden = !show;
const buttonSpan = conf.create.span;
const input = (this.container.open) ? conf.search.input : conf.search;
if (!input) return;
@@ -1212,8 +1208,8 @@
}
if (data.parent !== undefined && data.name) {
- this.setMessage(true, `Creating "${data.name}"...`);
- this.setCreateButton(false);
+ this.setMessage(field,true, `Creating "${data.name}"...`);
+ this.setCreateButton(field,false);
if (this.container.open) {
window.removeChildren(this.ui.terms.list);
@@ -1228,7 +1224,7 @@
if (term) {
// Stop any typeLoop animation and show success message WITHOUT typeLoop
- this.setMessage(true, `"${term.name}" created!`, false);
+ this.setMessage(field,true, `"${term.name}" created!`, false);
this.addSelected(term.id, field.id);
this.updateFieldValue(field.id);
@@ -1242,10 +1238,10 @@
}
}
this.scheduleHideDropdown(field.id, 300);
- this.setMessage(false);
+ this.setMessage(field,false);
} else {
// Creation failed - hide immediately
- this.setMessage(false);
+ this.setMessage(field,false);
if (!this.container.open && field.ui.dropdown.wrapper) {
field.ui.dropdown.wrapper.hidden = true;
}
@@ -1257,10 +1253,7 @@
}
}
}
- setMessage(show = true, message = '', type = true) {
- const field = this.currentField();
- if (!field) return;
-
+ setMessage(field, show = true, message = '', type = true) {
const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui);
if (!conf?.message?.message) return;
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index e5f8643..0d57012 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -10,6 +10,7 @@
this.initStores();
this.initWorker();
+
//Maps for DOM references
this.fields = new Map();
this.uploads = new Map();
@@ -138,7 +139,8 @@
if (manyRefs.inputs) {
for (let input of manyRefs.inputs) {
- window.prefixInput(input, `${data.id??data.uploadId}-`);
+ let wrapper = input.closest('[data-field]')??el;
+ window.prefixInput(input, `${data.id??data.uploadId}-`, wrapper);
}
}
}
@@ -154,7 +156,8 @@
setup({el, refs, manyRefs, data}) {
el.dataset.groupId = data.groupId;
if (refs.selectAll) {
- window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, true);
+ let wrapper = refs.selectAll.closest('.field');
+ window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, wrapper,true);
}
let fields = T.create('groupMetadata', {groupId: data.groupId});
if (fields) {
@@ -175,7 +178,8 @@
setup({el, refs, manyRefs, data}) {
if (refs.inputs) {
refs.inputs.forEach(input => {
- window.prefixInput(input, `${data.groupId}-`);
+ let wrapper = input.closest('[data-field]');
+ window.prefixInput(input, `${data.groupId}-`, wrapper);
});
}
}
@@ -535,6 +539,7 @@
// Capture values immediately (before debouncer)
const inputName = input.name;
+ if (!inputName) return;
const inputValue = input.value;
// Extract the field name from the input name
@@ -663,10 +668,15 @@
await this.setBulkUpload(uploads, 'status', 'uploading');
await this.setBulkGroup(fieldId, 'operationId', operationId);
this.fields.set(field.id, field);
+
+
+ this.notify('sent-to-queue', {
+ field: field,
+ operation: operationId,
+ });
} else {
await this.setBulkUpload(uploads, 'status', 'failed');
}
- this.notify('sent-to-queue', fieldId);
return operationId;
}
@@ -707,7 +717,12 @@
let uploadMap = [];
let files = [];
- for (const group of groups) {
+ const validGroups = groups.filter(group => {
+ const groupUploads = this.getGroupUploadsInOrder(group);
+ return groupUploads.length > 0 && groupUploads.some(u => this.formatFile(u));
+ });
+
+ for (const group of validGroups) {
const groupElement = this.groups.get(group.id)?.element;
const fields = this.collectGroupFieldsFromDOM(groupElement, group.id);
@@ -716,7 +731,6 @@
fields: fields
};
- // Use helper to get uploads in stored order
const groupUploads = this.getGroupUploadsInOrder(group);
for (const upload of groupUploads) {
@@ -738,7 +752,10 @@
uploadMap.push(upload.id);
}
}
- posts.push(post);
+
+ if (post.images.length > 0) {
+ posts.push(post);
+ }
}
// Handle remaining uploads not in any group
@@ -759,7 +776,10 @@
post.images.push(imageData);
uploadMap.push(upload.id);
}
- posts.push(post);
+
+ if (post.images.length > 0) {
+ posts.push(post);
+ }
}
return {posts, uploadMap, files};
@@ -1167,6 +1187,13 @@
*************************************************************/
async checkRecovery() {
const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']});
+ const allGroups = Array.from(this.stores.groups.data.values());
+ for (const group of allGroups) {
+ const hasUploads = this.stores.uploads.filterByIndex({group: group.id}).length > 0;
+ if (!hasUploads) {
+ await this.stores.groups.delete(group.id);
+ }
+ }
if (pendingUploads.length === 0) return;
// Group by source page
@@ -1746,6 +1773,7 @@
avoidImplicitDeselect: true,
group: { name: fieldId, pull: true, put: true },
dragClass: 'dragging',
+ ignore: '.empty-group',
onStart: (evt) => {
// Get the dragged item's ID
@@ -1777,6 +1805,7 @@
emptyZone.addEventListener('dragover', (e) => {
e.preventDefault();
+ e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
emptyZone.classList.add('drag-over');
});
@@ -1789,6 +1818,7 @@
emptyZone.addEventListener('drop', async (e) => {
e.preventDefault();
+ e.stopPropagation();
emptyZone.classList.remove('drag-over');
// Get selected items from our tracking
diff --git a/assets/js/concise/UtilityFunctions.js b/assets/js/concise/UtilityFunctions.js
index df3c75d..27944aa 100644
--- a/assets/js/concise/UtilityFunctions.js
+++ b/assets/js/concise/UtilityFunctions.js
@@ -413,27 +413,34 @@
}
}
-window.prefixInput = function(input, prefix, replace = false) {
+window.prefixInput = function(input, prefix, wrapper = null, replace = false) {
if (!input) {
console.warn('prefixInput called with null/undefined input');
return;
}
- let newId = replace ? prefix : `${prefix}${input.name}`;
- if (input.labels && input.labels.length > 0) {
- input.labels?.forEach(label => {
- label.htmlFor = newId;
- });
- } else if (input.previousElementSibling?.tagName === 'label') {
- let label = input.previousElementSibling;
- if (label) label.htmlFor = newId;
- } else if (input.nextElementSibling?.tagName === 'label') {
- let label = input.nextElementSibling;
- if (label) label.htmlFor = newId;
- }else {
- let label = input.closest('[data-field]')?.querySelector(`label[for="${input.id}"]`);
- if (label) {
- label.htmlFor = newId;
- }
+ const oldId = input.id;
+ const newId = replace ? prefix : `${prefix}${input.name}`;
+
+ // Search for label within wrapper if provided, otherwise use existing logic
+ let label = null;
+
+ if (wrapper) {
+ // Most reliable: search within wrapper by old ID
+ label = wrapper.querySelector(`label[for="${oldId}"]`);
+ } else if (input.labels && input.labels.length > 0) {
+ // Fallback to input.labels if no wrapper provided
+ label = input.labels[0];
+ } else if (input.previousElementSibling?.tagName === 'LABEL') {
+ label = input.previousElementSibling;
+ } else if (input.nextElementSibling?.tagName === 'LABEL') {
+ label = input.nextElementSibling;
+ } else {
+ // Final fallback: search up the tree
+ label = input.closest('[data-field]')?.querySelector(`label[for="${oldId}"]`);
+ }
+
+ if (label) {
+ label.htmlFor = newId;
}
input.id = newId;
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index 23ecd8d..794be84 100644
--- a/assets/js/min/creator.min.js
+++ b/assets/js/min/creator.min.js
@@ -1 +1 @@
-window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);return t.success?t.term?.pending?(this.selector.setMessage(!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){const i={id:e.id,name:e.name,path:e.path||e.name,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-"),parent:t.parent||0,taxonomy:t.taxonomy,count:0,hasChildren:!1};this.selector.store.data.set(e.id,i),this.ui.details&&(this.ui.details.open=!1),this.selector.store.clearCache(),this.selector.store.fetch().catch((e=>{console.warn("Background fetch after term creation failed:",e)}))}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);let i=this.selector.currentField();return t.success?t.term?.pending?(this.selector.setMessage(i,!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(i,!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(i,!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){const i={id:e.id,name:e.name,path:e.path||e.name,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-"),parent:t.parent||0,taxonomy:t.taxonomy,count:0,hasChildren:!1};this.selector.store.data.set(e.id,i),this.ui.details&&(this.ui.details.open=!1),this.selector.store.clearCache(),this.selector.store.fetch().catch((e=>{console.warn("Background fetch after term creation failed:",e)}))}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
diff --git a/assets/js/min/crud.min.js b/assets/js/min/crud.min.js
index 2ad3e61..8ac70f0 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id,window.prefixInput(i.checkbox,`select-${s.id}`,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${l.id}-`)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${a.id}-`)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"content"===t.endpoint&&Object.keys(t.data?.posts??{}).length>0){this.store.clearCache();let e=Object.keys(t.data.posts),i=this.changesStore.getMany(e);this.changesStore.deleteMany(e);for(let s of e){let e=i.filter((e=>e.id===s))[0]??!1,a=t.data.posts[s],l={};for(let[t,i]of Object.entries(a))e&&!Object.hasOwn(e,t)||(e[t]===i&&delete e[t],l[t]=i);Object.keys(l).length>0&&(l.id=s,l.content=this.content,this.changes.set(s,l))}Object.values(this.changes).length>0&&this.scheduleBackup()}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.cancelBackup(),this.handleBackup().then((()=>{})),this.savePosts(i,!1).then((()=>{}))}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const i=this.store.get(t);if(!i)return;const s=(i.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...s,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){await this.changesStore.saveMany(this.changes),this.changes.clear()}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const i=await this.changesStore.getAll();if(0===i.length)return;""===e&&(e=`Saving ${i.length} ${1===i.length?this.singular:this.plural}`);let s={},a=[];i.forEach((e=>{let t=e.id;const{id:i,...l}=e;s[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:s},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),i=this.status;"trash"===i&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===i||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id;let a=i.checkbox.closest(".preview");window.prefixInput(i.checkbox,`select-${s.id}`,a,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const r=l.images?.[a.post_thumbnail];r&&o.querySelector(".field.upload")?.setAttribute("title",r["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.cancelBackup(),this.handleBackup().then((()=>{})),this.savePosts(i,!1).then((()=>{}))}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const i=this.store.get(t);if(!i)return;const s=(i.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...s,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){await this.changesStore.saveMany(this.changes),this.changes.clear()}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const i=await this.changesStore.getAll();if(0===i.length)return;""===e&&(e=`Saving ${i.length} ${1===i.length?this.singular:this.plural}`);let s={},a=[];i.forEach((e=>{let t=e.id;const{id:i,...l}=e;s[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:s},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),i=this.status;"trash"===i&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===i||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/dataStore.min.js b/assets/js/min/dataStore.min.js
index d5d94d1..35242db 100644
--- a/assets/js/min/dataStore.min.js
+++ b/assets/js/min/dataStore.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.2){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:r.items||[]}),r;t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items)return this.applyOrdering(r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]),t);const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];for(const[e,s]of Object.entries(t.filters))if(!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;if(t.filters.orderby||t.filters.order){const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.fields?.post_title||e.title||e.name||"").toLowerCase(),a=(t.fields?.post_title||t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||0),a=new Date(t.modified||0);break;default:i=new Date(e.date||0),a=new Date(t.date||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0}))}return e}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t}),this.notify(e,"data-loaded",{cached:!0,items:this.getFiltered(e)});const a=await this.shouldFetchWithFilters(e,t,i);r.config.endpoint&&a?await this.fetch(e):r.config.endpoint&&this.notify(e,"data-loaded")}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.25){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order","context","source",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL)){let t=r.items.map((t=>this.get(e,t)));return this.notify(e,"data-loaded",{cached:!0,items:t??[]}),r}t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items){const e=r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]);return this.applyOrdering(e,t)}const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];t.filters.taxonomy&&"object"==typeof t.filters.taxonomy&&Object.entries(t.filters.taxonomy).forEach((([e,t])=>{const s=Array.isArray(t)?t:[t];o.push((t=>{if(!t.taxonomies||!t.taxonomies[e])return!1;const r=Object.keys(t.taxonomies[e]).map((e=>parseInt(e)));return s.some((e=>r.includes(parseInt(e))))}))}));for(const[e,s]of Object.entries(t.filters))if("taxonomy"!==e&&!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();return["random","rand"].includes(s)||["random","rand"].includes(r)?this.shuffle(e):(e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.title||e.name||"").toLowerCase(),a=(t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||e.date||0),a=new Date(t.modified||t.date||0);break;default:i=new Date(e.date||e.modified||0),a=new Date(t.date||t.modified||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0})),e)}shuffle(e){const t=e.slice();for(let e=t.length-1;e>0;e--){const s=Math.floor(Math.random()*(e+1));[t[e],t[s]]=[t[s],t[e]]}return t}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t});const a=await this.shouldFetchWithFilters(e,t,i);if(r.config.endpoint&&a)await this.fetch(e);else{const t=this.getFiltered(e);this.notify(e,"data-loaded",{cached:!0,items:t})}}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 1c13797..7789077 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),this.cancelBackup(),await this.backup();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=e.closest("[data-field]");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index ced1583..db023ec 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const r=["image-title","image-alt-text","image-caption"];for(const e of r){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){e.dataset.itemId=l.id,i.select&&window.prefixInput(i.select,`${l.id}-`),i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[l.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const r=e.querySelector('input:not([type="file"]), textarea');r&&window.prefixInput(r,`[${l.id}]`)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const r=["image-title","image-alt-text","image-caption"];for(const e of r){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[l.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const r=e.querySelector('input:not([type="file"])');r&&window.prefixInput(r,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index 73c402b..3186a42 100644
--- a/assets/js/min/queue.min.js
+++ b/assets/js/min/queue.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",refresh:"button.refresh",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.refresh))return void this.handleRefresh(opId);if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":this.store.getAll().forEach((e=>{const t=this.queue.get(e.id),s=this.mapServerOperation(e);this.queue.set(s.id,s),t&&t.status!==s.status&&this.notify("operation-status",s)})),this.maybeStartPolling(),this.updateUI();break;case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.item&&(this.queue.set(t.item.id,t.item),t.previousItem?.status!==t.item.status&&this.notify("operation-status",t.item)),this.maybeStartPolling()}}))}handleRefresh(e){const t=this.getQueue(e);if(!t)return;let s=null;if(s={content_update:t.data?.posts?Object.values(t.data.posts)[0]?.content:null,batch_creation:t.data?.content,image_upload:"uploads",video_upload:"uploads",document_upload:"uploads"}[t.type],s&&window.jvbStore){if(window.jvbStore.stores.get(s)){window.jvbStore.clearCache(s),window.jvbStore.fetch(s);const t=this.items.get(e)?.ui?.actions?.refresh;if(t){const e=t.querySelector("span").textContent;t.querySelector("span").textContent="Refreshed!",t.disabled=!0,setTimeout((()=>{t.querySelector("span").textContent=e,t.disabled=!1}),2e3)}}}else confirm("Refresh the page to see changes?")&&window.location.reload()}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.setQueue(e),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status)),t.ui.actions.refresh&&(t.ui.actions.refresh.hidden="completed"!==s.status)}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed. Refresh to see changes.";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}mapServerOperation(e){const t=this.queue.get(e.id);if(t&&t.endpoint)return{...t,...e,endpoint:t.endpoint,method:t.method,headers:t.headers};const s=e.type?e.type.replace("_update","").replace("_","/"):"unknown";return{...e,endpoint:s,method:"POST",headers:{...this.headers}}}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index 92d967a..7c70e08 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(!1)),this.setCreateButton(!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t),this.setMessage(!0,"An error occurred loading data",!1)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField?this.showResults(!0):this.setMessage(!1)}showResults(e=!1){this.setMessage(!1);const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e=!0){const t=this.currentField();if(!t||!t.canCreate||!this.creator)return;const s=this.container.open?this.ui:t.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button,r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=e&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(!0,`Creating "${s.name}"...`),this.setCreateButton(!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(!1)}else this.setMessage(!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e=!0,t="",s=!0){const i=this.currentField();if(!i)return;const r=this.container.open||i.isFilter?this.ui:i.isFilter?null:i.ui;if(!r?.message?.message)return;t=""===t?`No ${i.plural??"items"} found.`:t;const a=r.message.message,n=r.message.text;a.hidden=!e,e?t&&n&&(s&&window.typeLoop&&n?(this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id]),this.messageText[i.id]=window.typeLoop(n,t)):n.textContent=t):this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index cba5853..268f720 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let e of i.inputs)window.prefixInput(e,`${r.id??r.uploadId}-`)}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){t.dataset.groupId=r.groupId,s.selectAll&&window.prefixInput(s.selectAll,`select-all-${r.groupId}`,!0);let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{window.prefixInput(e,`${i.groupId}-`)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name,i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i)):await this.setBulkUpload(r,"status","failed"),this.notify("sent-to-queue",t),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e}),i=[],r=[],a=[];for(const e of s){const t=this.groups.get(e.id)?.element,s={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const i=this.formatFile(t);if(i){a.push(i);const o={upload_id:t.id,index:r.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(s.fields.featured=t.id),s.images.push(o),r.push(t.id)}}i.push(s)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},s=this.formatFile(e);if(s){a.push(s);const i={upload_id:e.id,index:r.length};t.images.push(i),r.push(e.id)}i.push(t)}return{posts:i,uploadMap:r,files:a}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift();i.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>e.file)));for(let t=0;t<o.length;t++){const{uploadId:s,upload:a}=o[t];a.blob=d[t],a.fields.size=d[t].size,a.status="queued",await this.setUpload(s,a),r++,this.updateFieldProgress(e,r,i,"Processing files...")}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]});if(0===e.length)return;const t=new Map;e.forEach((e=>{const s=e.src||"unknown";t.has(s)||t.set(s,[]),t.get(s).push(e)}));let s={bySource:t,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",s));let i=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(i),this.restoreSelection=new window.jvbHandleSelection(i,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift();i.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>e.file)));for(let t=0;t<o.length;t++){const{uploadId:s,upload:a}=o[t];a.blob=d[t],a.fields.size=d[t].size,a.status="queued",await this.setUpload(s,a),r++,this.updateFieldProgress(e,r,i,"Processing files...")}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/utility.min.js b/assets/js/min/utility.min.js
index 81f4cbe..16ef657 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");let i=n?t:`${t}${e.name}`;if(e.labels&&e.labels.length>0)e.labels?.forEach((e=>{e.htmlFor=i}));else if("label"===e.previousElementSibling?.tagName){let t=e.previousElementSibling;t&&(t.htmlFor=i)}else if("label"===e.nextElementSibling?.tagName){let t=e.nextElementSibling;t&&(t.htmlFor=i)}else{let t=e.closest("[data-field]")?.querySelector(`label[for="${e.id}"]`);t&&(t.htmlFor=i)}e.id=i},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
+(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=null,i=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const o=e.id,r=i?t:`${t}${e.name}`;let a=null;a=n?n.querySelector(`label[for="${o}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${o}"]`),a&&(a.htmlFor=r),e.id=r},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
diff --git a/build/drawer-menu/render.php b/build/drawer-menu/render.php
index d0826a3..05da41a 100644
--- a/build/drawer-menu/render.php
+++ b/build/drawer-menu/render.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\ui\Navigation;
$menu_id = $attributes['menuId'] ?? '';
@@ -13,8 +13,7 @@
return '<p>Please configure the drawer menu in block settings.</p>';
}
-$cache = CacheManager::for('drawer');
-$cache->clear();
+$cache = Cache::for('drawer');
if (!is_front_page()) {
$menu_items[] = [
diff --git a/build/feed/style-index-rtl.css b/build/feed/style-index-rtl.css
index c7ef499..20c3e06 100644
--- a/build/feed/style-index-rtl.css
+++ b/build/feed/style-index-rtl.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{right:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{right:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-left:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-right:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{right:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{right:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-left:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-right:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/style-index.css b/build/feed/style-index.css
index d520da0..6c76b3e 100644
--- a/build/feed/style-index.css
+++ b/build/feed/style-index.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{left:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{left:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-right:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-left:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{left:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{left:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-right:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-left:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index aad0cb3..90ed37f 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '22ba781dbd65270a003b');
+<?php return array('dependencies' => array(), 'version' => '1589cfb61e8639162b4c');
diff --git a/build/feed/view.js b/build/feed/view.js
index d261b31..4245a34 100644
--- a/build/feed/view.js
+++ b/build/feed/view.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.content=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.content=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.content=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.content=!1),this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch())}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){const e=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=e.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(){let e=this.store.getFiltered();this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.fields.number-1} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map((e=>e.value)):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){let e=this.orderbyFilters.filter((e=>!["date","modified","title","random"].includes(e))),t=[];e.forEach((e=>{t.push({name:e,keyPath:e})}));const i=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=null!=e?e:this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
diff --git a/build/list/render.php b/build/list/render.php
index f8ef568..489c379 100644
--- a/build/list/render.php
+++ b/build/list/render.php
@@ -60,7 +60,7 @@
if ($terms && !is_wp_error($terms)) {
$term = $terms[0];
$extra[] = [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'url' => get_term_link($term->term_id, $item),
'id' => $term->term_id,
'type' => $item,
@@ -90,7 +90,7 @@
$extra = false;
$list = jvbAlphabetizeMe(
$list,
- $term->name,
+ html_entity_decode($term->name),
get_term_link($term->term_id, $selected_type['slug']),
$term->term_id,
$extra
diff --git a/build/summary/render.php b/build/summary/render.php
index 4c22c7a..ca5cde1 100644
--- a/build/summary/render.php
+++ b/build/summary/render.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -31,10 +31,9 @@
function jvbRenderArtistSummary():string
{
$current = get_queried_object();
- $cache = CacheManager::for('artists', WEEK_IN_SECONDS);
- $key = 'artist-bio-'.$current->ID;
+ $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
+ $key = $current->ID;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
@@ -97,8 +96,8 @@
$link = get_term_link((int)$style, BASE.'style');
?>
<li>
- <a href="<?=$link?>" title="Learn more about <?=$term->name?>">
- <?=strtolower($term->name)?>
+ <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
+ <?=strtolower(html_entity_decode($term->name))?>
</a>
</li>
<?php
@@ -158,8 +157,8 @@
{
$current = get_queried_object();
- $cache = CacheManager::for('shops', WEEK_IN_SECONDS);
- $key = 'shop-bio-'.$current->term_id;
+ $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
+ $key = $current->term_id;
$cached = $cache->get($key);
$cached = false;
if ($cached !== false) {
@@ -167,8 +166,6 @@
}
ob_start();
- $handler = JVB()->getContent('shop');
-
$meta = new JVBase\meta\MetaManager($current->term_id, 'term');
$rating = $meta->getValue('average_rating');
@@ -281,7 +278,7 @@
<?php
$finished = ob_get_clean();
-// $cache->set($key, $finished);
+ $cache->set($key, $finished);
return $finished;
}
@@ -289,10 +286,9 @@
function jvbRenderTermSummary()
{
$current = get_queried_object();
- $cache = CacheManager::for(jvbNoBase($current->taxonomy), WEEK_IN_SECONDS);
+ $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
$key = $current->ID;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
diff --git a/checks.php b/checks.php
index 3c6ee74..86c7526 100644
--- a/checks.php
+++ b/checks.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\utility\Features;
if (!defined('ABSPATH')) {
@@ -261,27 +261,26 @@
function jvbTermHasPosts(int $termID, string $taxonomy):bool
{
- $cache = CacheManager::for('terms', 30*60)->connectTo('taxonomy');
- $key = $termID.$taxonomy;
- $cached = $cache->get($key);
- if ($cached) {
- return ($cached === 'true');
- }
- $taxonomy = jvbCheckBase($taxonomy);
- $tax = get_taxonomy($taxonomy);
- $query = new WP_Query([
- 'post_type' => $tax->object_type,
- 'posts_per_page' => 1,
- 'fields' => 'id',
- 'tax_query' => [
- [
- 'taxonomy' => $taxonomy,
- 'terms' => $termID
- ]
- ]
- ]);
- $result = ($query->have_posts()) ? 'true': 'false';
- wp_reset_postdata();
- $cache->set($key, $result);
- return $result === 'true';
+ $cache = Cache::for('termUtility', 30*60)->connect('taxonomy');
+ return $cache->remember(
+ $termID,
+ function() use($taxonomy, $termID) {
+ $taxonomy = jvbCheckBase($taxonomy);
+ $tax = get_taxonomy($taxonomy);
+ $query = new WP_Query([
+ 'post_type' => $tax->object_type,
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'tax_query' => [
+ [
+ 'taxonomy' => $taxonomy,
+ 'terms' => $termID
+ ]
+ ]
+ ]);
+ $result = ($query->have_posts()) ? 'true' : 'false';
+ wp_reset_postdata();
+ return $result;
+ }
+ );
}
diff --git a/inc/EmbedGenerator.php b/inc/EmbedGenerator.php
index faac311..63c1f11 100644
--- a/inc/EmbedGenerator.php
+++ b/inc/EmbedGenerator.php
@@ -176,7 +176,7 @@
foreach ($style_ids as $style_id) {
$term = get_term((int) $style_id, BASE . 'style');
if ($term && !is_wp_error($term)) {
- $styles[] = $term->name;
+ $styles[] = html_entity_decode($term->name);
}
}
}
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index 00613e1..1822fb5 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/inc/blocks/CustomBlocks.php
@@ -3,7 +3,7 @@
use DateTime;
use DOMDocument;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use WP_Block;
use WP_Query;
@@ -13,10 +13,11 @@
class CustomBlocks
{
- protected CacheManager $cache;
+ protected Cache $cache;
public function __construct()
{
- $this->cache = CacheManager::for('blocks', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
+ $this->cache->connect('post')->connect('taxonomy');
add_filter('render_block', [$this, 'render'], 999, 3);
add_action('init', [$this, 'registerBlockStyles']);
@@ -72,16 +73,16 @@
if (function_exists($function)) {
return $function($block, $content);
// return $this->cache->remember(
-// $block,
+// get_the_ID(),
// function () use ($function, $block, $content) {
// return $function($block, $content);
// }
// );
} else if (method_exists($this, $method)) {
return $this->$method($block, $content);
-
+//
// return $this->cache->remember(
-// $block,
+// get_the_ID(),
// function () use ($method, $block, $content) {
// return $this->$method($block, $content);
// }
@@ -654,7 +655,7 @@
$out .= '<li>'.$block['attrs']['prefix'].'</li>';
}
foreach($terms as $term) {
- $out .= '<li><a href="'.get_term_link($term).'" rel="tag">'.$term->name.'</a></li>';
+ $out .= '<li><a href="'.get_term_link($term).'" rel="tag">'.html_entity_decode($term->name).'</a></li>';
}
if (array_key_exists('suffix', $block['attrs'])) {
$out .= '<li>'.$block['attrs']['suffix'].'</li>';
diff --git a/inc/blocks/FAQBlock.php b/inc/blocks/FAQBlock.php
index b27af52..6697669 100644
--- a/inc/blocks/FAQBlock.php
+++ b/inc/blocks/FAQBlock.php
@@ -1,17 +1,17 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\forms\TaxonomySelector;
use JVBase\meta\MetaManager;
use WP_Block;
use WP_Query;
class FAQBlock {
- protected CacheManager $cache;
+ protected Cache $cache;
public function __construct()
{
- $this->cache = CacheManager::for('faq_block', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('faq_block', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
add_action('init', [ $this, 'registerBlock' ]);
add_action('enqueue_block_editor_assets', [$this, 'localizeData']);
}
@@ -89,7 +89,7 @@
foreach ($sections as $term) {
$sections_data[] = [
'id' => $term->term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'slug' => $term->slug,
];
}
@@ -256,7 +256,7 @@
$term = get_term($term_id, $section_taxonomy);
if ($term && !is_wp_error($term)) {
$url = (!$is_tax_archive) ? "#{$term->slug}" : get_term_link($term);
- $nav .= '<li><a href="'.$url.'">'.$term->name.'</a></li>';
+ $nav .= '<li><a href="'.$url.'">'.html_entity_decode($term->name).'</a></li>';
}
}
$seeAll = ($is_tax_archive) ? '<p><a href="'.get_post_type_archive_link(BASE.'faq').'">'.__('See All FAQs', 'jvb').'</a></p>' : '';
diff --git a/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 45a0959..1a23d95 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\utility\Features;
use JVBase\utility\Checker;
use JVBase\forms\TaxonomySelector;
@@ -13,41 +13,22 @@
class FeedBlock
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected array $config;
protected string $path = JVB_DIR.'/build/feed';
public function __construct()
{
// Initialize cache with connections
- $this->cache = CacheManager::for('feed_block', WEEK_IN_SECONDS);
- // Set up cache connections for all feed content types
- $this->setupCacheConnections();
+ $this->cache = Cache::for('feed_block', WEEK_IN_SECONDS);
+
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
add_action('init', [$this, 'registerBlock']);
}
- /**
- * Set up cache connections for feed content
- */
- protected function setupCacheConnections(): void
- {
- // Connect to all content types that show in feed
- $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
- foreach ($contentTypes as $type) {
- CacheManager::for('feed_content')->connectTo('post', $type);
- }
-
- // Connect to all taxonomies that show in feed
- $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
- foreach ($taxonomies as $tax) {
- CacheManager::for('feed_taxonomy')->connectTo('taxonomy', $tax);
- }
- }
-
public function registerBlock()
{
register_block_type($this->path, [
@@ -131,7 +112,7 @@
$this->config = $this->buildParams($attributes);
return $this->cache->remember(
- $this->config,
+ $this->cache->generateKey($this->config),
function() {
return $this->renderBlock();
}
@@ -317,19 +298,45 @@
</label>
<input type="radio" id="order-date" class="btn" name="orderby" value="date" data-filter="orderby" checked>
- <label for="order-date" title="Order by Date" class="row">
+ <label for="order-date" title="Order by Date Created" class="row">
<?= jvbIcon('calendar', ['title' => 'Date']) ?>
- <span class="label">Date</span>
+ <span class="label">Date Created</span>
</label>
+ <input type="radio" id="order-modified" class="btn" name="orderby" value="modified" data-filter="orderby">
+ <label for="order-modified" title="Order by Date Modified" class="row">
+ <?= jvbIcon('clock-clockwise') ?>
+ <span class="label">Date Modified</span>
+ </label>
+
+ <?php
+ $custom = [];
+ foreach ($this->getContent() as $content) {
+ $config = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??false;
+ if ($config && array_key_exists('custom_order', $config)) {
+ $custom = array_merge_recursive($custom, $config['custom_order']);
+ }
+ }
+ foreach ($custom as $slug => $conf) {
+ ?>
+ <input type="radio" id="order-<?=$slug?>" class="btn" name="orderby" value="<?=$slug?>" data-for="<?=$conf['for']?>" data-filter="orderby">
+ <label for="order-<?=$slug?>" title="<?= $conf['label']?>" class="row">
+ <?= jvbIcon($conf['icon']) ?>
+ <span class="label"><?=$conf['label']?></span>
+ </label>
+ <?php
+ }
+ $custom = implode(',', array_keys($custom));
+ ?>
<input type="radio" id="order-random" class="btn" name="orderby" value="random" data-filter="orderby">
<label for="order-random" title="Random Order" class="row">
<?= jvbIcon('shuffle') ?>
<span class="label">Random</span>
</label>
+
</div>
- <div class="order-direction filter-group row start w-full" data-for-order="date,title">
+ <div class="order-direction filter-group row start w-full" data-for-order="date,modified,title<?= $custom === '' ? '' : ','.$custom?>">
<span class="label">ORDER:</span>
<input type="radio" id="order-desc" class="btn" name="order" value="desc" data-filter="order" checked>
<label for="order-desc" title="Sort Descending (A-Z, 1-10)" class="row">
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index fd5fe29..3e8a892 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use Exception;
@@ -19,8 +19,8 @@
*/
class FormBlock
{
- protected static FormBlock|null $instance = null;
- protected CacheManager $cache;
+ protected static ?FormBlock $instance = null;
+ protected Cache $cache;
protected array $forms;
protected string $form_contact;
@@ -36,7 +36,7 @@
public function __construct()
{
- $this->cache = CacheManager::for('form_blocks', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('forms', WEEK_IN_SECONDS);
// Initialize forms from filter
$this->forms = $this->registerForms();
$this->form_contact = apply_filters('jvb_form_contact', '');
@@ -131,16 +131,12 @@
}
$cache_key = $this->cache->generateKey($block);
- $cached = $this->cache->get($cache_key);
- $cached = false;
- if ($cached) {
- return $cached;
- }
-
- $rendered = $this->renderForm($form_type, $block);
-
- $this->cache->set($cache_key, $rendered);
- return $rendered;
+ return $this->cache->remember(
+ $cache_key,
+ function() use ($form_type, $block) {
+ return $this->renderForm($form_type, $block);
+ }
+ );
}
/**
diff --git a/inc/blocks/GlossaryBlock.php b/inc/blocks/GlossaryBlock.php
index 5681ce9..696c21d 100644
--- a/inc/blocks/GlossaryBlock.php
+++ b/inc/blocks/GlossaryBlock.php
@@ -1,9 +1,7 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
-use JVBase\forms\TaxonomySelector;
-use JVBase\meta\MetaManager;
+use JVBase\managers\Cache;
use WP_Block;
use WP_Query;
@@ -13,7 +11,7 @@
class GlossaryBlock
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $config;
protected string $type;
protected string $path = JVB_DIR . '/build/glossary';
@@ -24,7 +22,7 @@
public function __construct()
{
- $this->cache = CacheManager::for('glossary_terms', WEEK_IN_SECONDS)->connectTo('post', 'terms');
+ $this->cache = Cache::for('glossary_terms', WEEK_IN_SECONDS)->connect('post', true);
add_action('init', [ $this, 'registerBlock' ]);
}
@@ -89,7 +87,6 @@
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'asc',
-// 'fields' => 'ids'
]);
$glossary = [];
if ($posts->have_posts()) {
diff --git a/inc/blocks/MenuBlock.php b/inc/blocks/MenuBlock.php
index e52f2a2..88fd26a 100644
--- a/inc/blocks/MenuBlock.php
+++ b/inc/blocks/MenuBlock.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\forms\TaxonomySelector;
use JVBase\meta\MetaManager;
use WP_Block;
@@ -13,7 +13,7 @@
class MenuBlock
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $config;
protected string $type;
protected string $path = JVB_DIR . '/build/menu';
@@ -27,7 +27,7 @@
public function __construct()
{
- $this->cache = CacheManager::for('menu', WEEK_IN_SECONDS)->connectTo('post', 'menu_item');
+ $this->cache = Cache::for('menu', WEEK_IN_SECONDS)->connectTo('post', 'menu_item');
add_action('init', [ $this, 'registerBlock' ]);
}
@@ -57,16 +57,14 @@
return '';
}
$key = $this->cache->generateKey($this->params);
- $cache = $this->cache->get($key);
- if ($cache) {
- return $cache;
- }
-
- ob_start();
- $this->renderBlock();
- $content = ob_get_clean();
- $this->cache->set($key, $content);
- return $content;
+ return $this->cache->remember(
+ $key,
+ function() {
+ ob_start();
+ $this->renderBlock();
+ return ob_get_clean();
+ }
+ );
}
protected function renderBlock():void
diff --git a/inc/blocks/SummaryBlock.php b/inc/blocks/SummaryBlock.php
index 84d99b3..c236a7c 100644
--- a/inc/blocks/SummaryBlock.php
+++ b/inc/blocks/SummaryBlock.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\forms\TaxonomySelector;
use WP_Block;
@@ -11,7 +11,7 @@
class SummaryBlock
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $config;
protected string $type;
protected string $path = JVB_DIR . '/build/summary';
@@ -23,10 +23,10 @@
public function __construct()
{
- $this->cache = CacheManager::for('summary_block', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('summary_block', WEEK_IN_SECONDS);
add_action('init', [ $this, 'registerBlock' ]);
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
index 90cf469..5c21fdd 100644
--- a/inc/blocks/TimelineBlock.php
+++ b/inc/blocks/TimelineBlock.php
@@ -1,12 +1,10 @@
<?php
namespace JVBase\blocks;
-use JVBase\managers\CacheManager;
-use JVBase\forms\TaxonomySelector;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use WP_Block;
-use WP_Query;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -14,7 +12,7 @@
class TimelineBlock
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $config;
protected string $type;
protected string $path = JVB_DIR . '/build/timeline';
@@ -31,9 +29,9 @@
public function __construct()
{
- $this->cache = CacheManager::for('timelines', WEEK_IN_SECONDS)->connectTo('post', 'timeline');
+ $this->cache = Cache::for('timelines', WEEK_IN_SECONDS)->connect('post');
if (JVB_TESTING){
- $this->cache->clear();
+ $this->cache->flush();
}
add_action('init', [ $this, 'registerBlock' ]);
add_action('wp_footer', 'jvbRenderGallery');
@@ -122,7 +120,7 @@
?>
<?= $open ?>
- <a href="<?=$link?>" rel="tag"><?=$term->name?></a>
+ <a href="<?=$link?>" rel="tag"><?=html_entity_decode($term->name)?></a>
<?= $close ?>
<?php }
if ($many) { echo '</ul>'; }
@@ -140,8 +138,10 @@
<?= jvbFormatImage(get_post_thumbnail_id($this->parentID), 'tiny', 'large', false) ?>
</div>
<div class="after">
+ <?php if (!empty($this->children)) :?>
<h3>After <?=$this->total?> Treatment<?= $this->total > 1 ? 's' : '' ?></h3>
<?= jvbFormatImage(get_post_thumbnail_id($this->children[count($this->children)-1]), 'tiny', 'large', false) ?>
+ <?php endif; ?>
</div>
</section>
<section id="info">
@@ -221,7 +221,7 @@
$out = '<ul class="term-list">';
foreach ($timeline as $term) {
$link = get_term_link($term->term_id, BASE.'timeline');
- $out .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').$term->name.'</a><small>from the last treatment</small></li>';
+ $out .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').html_entity_decode($term->name).'</a><small>after the treatment</small></li>';
}
$out .='</ul>';
return $out;
diff --git a/inc/forms/PostSelector.php b/inc/forms/PostSelector.php
index 0df418f..6f4ed7c 100644
--- a/inc/forms/PostSelector.php
+++ b/inc/forms/PostSelector.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\forms;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use WP_REST_Request;
use WP_REST_Response;
use WP_Query;
@@ -18,12 +18,12 @@
{
protected string $post_type;
protected array $config;
- protected CacheManager $cache;
+ protected Cache $cache;
public function __construct(string $post_type, array $config = [])
{
$this->post_type = $post_type;
- $this->cache = CacheManager::for(jvbNoBase($post_type), WEEK_IN_SECONDS);
+ $this->cache = Cache::for(jvbNoBase($post_type), WEEK_IN_SECONDS)->connect('post', true);
$this->config = wp_parse_args($config, [
'multiple' => true,
@@ -48,9 +48,6 @@
*/
public function render(array $selected = [], string $containerId = ''): string
{
- // Mark that selectors are present for footer output
- TaxonomySelector::markSelectorsPresent();
-
// Process selected posts
$processedSelected = $this->processSelectedPosts($selected);
diff --git a/inc/forms/PostSelectorOld.php b/inc/forms/PostSelectorOld.php
deleted file mode 100644
index 333ecec..0000000
--- a/inc/forms/PostSelectorOld.php
+++ /dev/null
@@ -1,213 +0,0 @@
-<?php
-namespace JVBase\forms;
-
-use JVBase\forms\TaxonomySelector;
-use WP_REST_Request;
-use WP_REST_Response;
-use WP_Term;
-use WP_Query;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-class PostSelectorOld extends TaxonomySelector
-{
- protected string $post_type;
-
- public function __construct(int $id, string $post_type, array $config = [])
- {
- $this->post_type = $post_type;
- parent::__construct($id, '', $config); // Empty taxonomy as we're using post type
- }
-
- protected function getAvailableTerms():array
- {
- $args = [
- 'post_type' => $this->post_type,
- 'posts_per_page' => 20, // Initial load amount
- 'orderby' => 'title',
- 'order' => 'ASC',
- 'fields' => 'id=>name'
- ];
-
- // Add shop exclusion if shop_id is set
- if (!empty($this->config['shop_id'])) {
- $args['tax_query'] = [[
- 'taxonomy' => BASE.'shop',
- 'terms' => $this->config['shop_id'],
- 'operator' => 'NOT IN'
- ]];
- }
-
- $posts = get_posts($args);
- return is_wp_error($posts) ? [] : array_combine(
- array_map(function ($post) {
- return $post->ID;
- }, $posts),
- array_map(function ($post) {
- return $post->post_title;
- }, $posts)
- );
- }
-
- public function render(array $selected = []):string
- {
- $wrapper_classes = $this->getWrapperClasses();
-
- ob_start();
- ?>
- <div class="<?= esc_attr($wrapper_classes); ?>"
- id="<?= esc_attr($this->id); ?>"
- data-post-type="<?= esc_attr($this->post_type); ?>"
- data-config='<?= esc_attr(wp_json_encode($this->getFrontendConfig())); ?>'>
-
- <div class="selector-wrapper">
- <?php $this->renderSelectedItems($selected); ?>
-
- <dialog class="selector-modal">
- <div class="wrap col">
- <header class="modal-header">
- <h3><?= esc_html($this->config['title'] ?? 'Search Artists'); ?></h3>
- <button type="button" class="cancel" aria-label="Close">×</button>
- </header>
-
-
- <div class="items-container">
- <?php $this->renderSelectableItems(); ?>
- <div class="scroll-sentinel"></div>
- </div>
-
- <?php if ($this->config['modal']) : ?>
- <div class="pending-section" hidden>
- <h4>Pending Approvals</h4>
- <div class="pending-list" role="list"></div>
- </div>
- <?php endif; ?>
- </div>
- <?= jvbSearch() ?>
- </dialog>
- </div>
- </div>
- <?php
- return ob_get_clean();
- }
-
- protected function renderSelectedItems(array $selected = []):void
- {
- if (empty($selected)) {
- echo '<div class="selected-items"></div>';
- return;
- }
-
- echo '<div class="selected-items">';
- foreach ($selected as $id => $title) {
- printf(
- '<div class="selected-item" data-id="%s">
- <span class="item-name">%s</span>
- <button type="button"
- class="remove-item"
- aria-label="Remove %s">×</button>
- </div>',
- esc_attr($id),
- esc_html($title),
- esc_attr($title)
- );
- }
- echo '</div>';
- }
-
- public function renderSelectableItems():void
- {
- $posts = $this->getAvailableTerms();
-
- echo '<div class="wrap"><ul class="flat items">';
- foreach ($posts as $ID => $name) {
- echo '<li>';
- ?>
- <input id="<?= $this->id.'-'.esc_attr($ID); ?>"
- type="<?= $this->config['multiple'] ? 'checkbox' : 'radio'; ?>"
- name="<?= esc_attr($this->id); ?>"
- value="<?= esc_attr($ID); ?>">
- <label class="selectable-item" for="<?= $this->id.'-'.esc_attr($ID); ?>">
- <?= esc_html($name); ?>
- </label>
- <?php
- echo '</li>';
- }
-
- echo '</ul></div>';
- }
-
- public function handleArtistSearch(WP_REST_Request $request):WP_REST_Response
- {
- $query = sanitize_text_field($request->get_param('query'));
- $page = (int)$request->get_param('page') ?: 1;
- $per_page = 30;
-
- $args = [
- 'post_type' => BASE.'artist',
- 'posts_per_page' => $per_page,
- 'paged' => $page,
- 'orderby' => 'title',
- 'order' => 'ASC',
- 's' => $query
- ];
-
- // Add shop exclusion if shop_id is set
- if (!empty($this->config['shop_id'])) {
- $args['tax_query'] = [[
- 'taxonomy' => BASE.'shop',
- 'terms' => $this->config['shop_id'],
- 'operator' => 'NOT IN'
- ]];
- }
-
- $key = $this->cache->generateKey($args);
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $posts = new WP_Query($args);
- $results = [];
-
- foreach ($posts->posts as $post) {
- $city_terms = wp_get_object_terms($post->ID, BASE.'city');
- $city = !empty($city_terms) ? $city_terms[0]->name : '';
-
- $results[] = [
- 'id' => $post->ID,
- 'title' => $post->post_title,
- 'thumbnail' => get_the_post_thumbnail_url($post->ID, 'thumbnail'),
- 'city' => $city,
- 'url' => get_permalink($post->ID)
- ];
- }
-
- $return = [
- 'results' => $results,
- 'hasMore' => $posts->max_num_pages > $page,
- 'total' => $posts->found_posts
- ];
- $this->cache->set($key, $return);
- return new WP_REST_Response($return);
- }
-
- protected function getWrapperClasses():string
- {
- $classes = [
- 'jvb-selector',
- 'selector-' . $this->post_type,
- 'post-selector'
- ];
-
- if ($this->config['multiple'] ?? false) {
- $classes[] = 'multiple';
- }
- if ($this->config['modal'] ?? false) {
- $classes[] = 'has-modal';
- }
-
- return implode(' ', $classes);
- }
-}
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index 9409b61..bcc5a3b 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -64,14 +64,14 @@
*/
public static function getTermPath(WP_Term $term, bool $returnArray = false): string|array {
if (!is_taxonomy_hierarchical($term->taxonomy)) {
- return $term->name;
+ return html_entity_decode($term->name);
}
$path = [];
$currentTerm = $term;
while ($currentTerm) {
- array_unshift($path, $currentTerm->name);
+ array_unshift($path, html_entity_decode($currentTerm->name));
if ($currentTerm->parent) {
$currentTerm = get_term($currentTerm->parent);
@@ -342,7 +342,7 @@
<span><?= esc_html($termPath) ?></span>
<button type="button"
class="remove-term row"
- aria-label="Remove <?= esc_attr($term->name) ?>">
+ aria-label="Remove <?= html_entity_decode($term->name) ?>">
<?= jvbIcon('x') ?>
</button>
</div>
diff --git a/inc/forms/TaxonomySelectorOld.php b/inc/forms/TaxonomySelectorOld.php
deleted file mode 100644
index 7da3336..0000000
--- a/inc/forms/TaxonomySelectorOld.php
+++ /dev/null
@@ -1,733 +0,0 @@
-<?php
-namespace JVBase\forms;
-
-use JVBase\managers\CacheManager;
-use JVBase\managers\IconsManager;
-use WP_REST_Request;
-use WP_REST_Response;
-use WP_Term;
-use WP_Query;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-/**
- * Base class for taxonomy selector components
- * Provides foundation for StyleSelector, ThemeSelector, etc.
- */
-class TaxonomySelectorOld
-{
- protected string $id;
- protected string $name;
- protected string $taxonomyName;
- protected IconsManager $icon;
- protected string $plural;
-
- protected string $taxonomy;
- protected string $base;
-
- protected array $selected = [];
- protected CacheManager $cache;
-
- /**
- * @var array Configuration options
- */
- protected array $config;
-
- /**
- * Initialize selector
- */
- public function __construct(string $id, string $taxonomy, array $config = [])
- {
- $this->id = sanitize_key($id);
- $this->taxonomy = jvbCheckBase($taxonomy);
- $this->name = str_replace(BASE, '', $taxonomy);
- $this->icon = IconsManager::getInstance();
- $this->cache = CacheManager::for(jvbNoBase($taxonomy), WEEK_IN_SECONDS);
-
- $this->base = $config['base'] ?? '';
-
- $this->config = wp_parse_args($config, [
- 'name' => $id,
- 'multiple' => true,
- 'max_selections'=> 0,
- 'hierarchical' => false,
- 'search' => true,
- 'createNew' => false,
- 'taxonomy' => $this->taxonomy,
- 'required' => false,
- 'group_by_family'=> false,
- 'placeholder' => '',
- 'show_examples' => false,
- 'show_breadcrumbs'=> true,
- 'expand_siblings'=> true,
- 'show_popularity'=> false,
- 'no_results' => 'Nothing here.',
- 'base' => $this->base,
-// 'association' => array(),
- 'common' => array(),
- 'types' => '', //for feed block implementation
- 'hidden' => false,
- 'label' => '',
- 'renderTemplates' => true,
- ]);
-
-
- add_action('wp_footer', [$this, 'outputDialog']);
-
-
- $tax = get_taxonomy($this->taxonomy);
-
- $this->plural = JVB_TAXONOMY[$taxonomy]['plural'];
- $this->taxonomyName = JVB_TAXONOMY[$taxonomy]['singular'];
- }
-
- /**
- * Render selector component
- * @param array $selected
- *
- * @return string
- */
- public function render(array $selected = []):string
- {
- $this->selected = $selected;
-
- $wrapper_classes = $this->getWrapperClasses();
-
- ob_start();
- ?>
- <div class="<?= esc_attr($wrapper_classes); ?>"
- id="<?= esc_attr($this->id); ?>"
- data-taxonomy="<?= esc_attr($this->name); ?>"
- data-config='<?= esc_attr(wp_json_encode($this->getFrontendConfig($selected))); ?>'>
-
- <div class="selector-wrapper">
- <?php $this->renderSelectedItems($selected); ?>
- </div>
- <input type="hidden" name="<?= $this->name ?>">
- </div>
- <?php
- return ob_get_clean();
- }
-
- /**
- * @param array $selected
- *
- * @return string
- */
- public function renderFeed(array $selected = []):string
- {
-
- $this->selected = $selected;
-
- $wrapper_classes = $this->getWrapperClasses();
- $icon = new JVBIcons();
- ob_start();
- ?>
- <div class="<?= esc_attr($wrapper_classes); ?> type-filter taxonomy-filter"
- id="<?= esc_attr($this->id); ?>"
- data-taxonomy="<?= $this->name ?>"
- data-for="<?= implode(',', $this->config['types']) ?>"
- <?= $this->config['hidden'] ?>>
-
- <button type="button" class="filter-toggle row" title="Filter by <?= $this->config['label'] ?>"
- aria-expanded="false"
- aria-controls="filter-dropdown-<?= $this->name ?>">
- <?= $icon->getIcon($this->name, ['title'=>$this->config['label']]) ?>
- <?= $this->config['label'] ?>
- </button>
- <dialog id="selector-modal filter-dropdown-<?= $this->name ?>"
- class="filter-dropdown"
- data-taxonomy="<?= $this->name ?>">
- <?php $this->renderModalContent(); ?>
- </dialog>
-
- <form class="selected-terms" hidden></form>
- </div>
- <input type="hidden" name="<?= $this->name ?>">
- <?php
- return ob_get_clean();
- }
-
- /**
- * Handle artist search requests
- */
- public function handleArtistSearch(WP_REST_Request $request):WP_REST_Response
- {
- $query = sanitize_text_field($request->get_param('query'));
- $page = (int)$request->get_param('page') ?: 1;
- $per_page = 10;
-
- $args = [
- 'post_type' => BASE.'artist',
- 'posts_per_page' => $per_page,
- 'paged' => $page,
- 'orderby' => 'title',
- 'order' => 'ASC',
- 'post_status' => 'publish',
- 's' => $query
- ];
-
- // If shop_id provided, exclude artists already in shop
- $shop_id = $request->get_param('shop_id');
- if ($shop_id) {
- $existing_artists = get_posts([
- 'post_type' => BASE.'artist',
- 'posts_per_page' => -1,
- 'fields' => 'ids',
- 'tax_query' => [[
- 'taxonomy' => BASE.'shop',
- 'terms' => $shop_id
- ]]
- ]);
-
- if (!empty($existing_artists)) {
- $args['post__not_in'] = $existing_artists;
- }
- }
-
- $key = $this->cache->generateKey($args);
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $posts = get_posts($args);
- $results = [];
-
- foreach ($posts as $post) {
- $city_terms = wp_get_object_terms($post->ID, BASE.'city');
- $city = !empty($city_terms) ? $city_terms[0]->name : '';
-
- $results[] = [
- 'id' => $post->ID,
- 'title' => $post->post_title,
- 'thumbnail' => get_the_post_thumbnail_url($post->ID, 'thumbnail'),
- 'city' => $city,
- 'url' => get_permalink($post->ID)
- ];
- }
-
- $result = [
- 'results' => $results,
- 'hasMore' => count($posts) === $per_page
- ];
- $this->cache->set($key, $result);
- return new WP_REST_Response($result);
- }
-
-
- /**
- * Get wrapper CSS classes
- * @return string
- */
- protected function getWrapperClasses():string
- {
- $classes = [
- 'jvb-selector',
- 'selector-' . strtolower($this->name)
- ];
-
- if ($this->config['multiple']) {
- $classes[] = 'multiple';
- }
- if ($this->config['hierarchical']) {
- $classes[] = 'hierarchical';
- }
- if ($this->config['required']) {
- $classes[] = 'required';
- }
-
- return implode(' ', $classes);
- }
-
- /**
- * Get configuration for frontend JavaScript
- * @return array
- */
- protected function getFrontendConfig(array $selected = []):array
- {
- $out = [
-// 'name' => $this->config['name'],
- 'multiple' => $this->config['multiple'],
- 'maxSelections' => $this->config['max_selections'],
-// 'hierarchical' => $this->config['hierarchical'],
- 'search' => $this->config['search'],
- 'createNew' => (bool)$this->config['createNew'],
- 'required' => $this->config['required'],
-// 'placeholder' => $this->config['placeholder'],
- 'noResults' => $this->config['no_results'],
-// 'group_by_family' => $this->config['group_by_family'],
-// 'labels' => $this->getLabels(),
- 'base' => $this->config['base'],
- 'selected' => $this->getSelectedData($selected),
-// 'values' => $this->getAvailableTerms(),
-// 'hierarchy' => [],
-// 'breadcrumbs' => [],
-// 'association' => $this->config['association']
-// 'common' => $this->config['common']
- ];
- if ($this->config['hierarchical']) {
- $out['hierarchy'] = $this->getTermHierarchy();
- $out['breadcrumbs'] = $this->getBreadcrumbData();
- }
- return $out;
- }
-
-
- /**
- * Get theme hierarchy data
- * @param int $parent ID of parent
- *
- * @return array
- */
- protected function getTermHierarchy(int $parent = 0):array
- {
- $terms = get_terms([
- 'taxonomy' => $this->taxonomy,
- 'parent' => $parent,
- 'hide_empty' => false
- ]);
-
- if (is_wp_error($terms)) {
- return [];
- }
-
- $hierarchy = [];
- foreach ($terms as $term) {
- $children = $this->getTermHierarchy($term->term_id);
- $hierarchy[] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'parent' => $term->parent,
- 'children' => $children
- ];
- }
-
- return $hierarchy;
- }
- /**
- * Get theme breadcrumb data
- * @return array
- */
- protected function getBreadcrumbData():array
- {
- $breadcrumbs = [];
- foreach ($this->selected as $term_id => $title) {
- $breadcrumbs[$term_id] = $this->getTermAncestors($term_id);
- }
- return $breadcrumbs;
- }
-
- /**
- * Get term ancestors with names
- * @param int $term_id
- *
- * @return array
- */
- protected function getTermAncestors(int $term_id):array
- {
- $cache = $this->cache->get('term-ancestors-'.$term_id);
- if ($cache) {
- return $cache;
- }
- $ancestors = get_ancestors($term_id, $this->taxonomy, 'taxonomy');
- $path = [];
- foreach (array_reverse($ancestors) as $ancestor_id) {
- $term = get_term($ancestor_id, $this->taxonomy);
- if ($term && !is_wp_error($term)) {
- $path[] = [
- 'id' => $term->term_id,
- 'name' => $term->name
- ];
- }
- }
- $this->cache->set('term-ancestors-'.$term_id, $path);
- return $path;
- }
-
- /**
- * @return array
- */
- protected function getAvailableTerms():array
- {
- $cache = $this->cache->get($this->taxonomy . '-terms');
- if ($cache) {
- return $cache;
- }
- $terms = get_terms([
- 'taxonomy' => $this->taxonomy,
- 'hide_empty' => false,
- 'fields' => 'id=>name'
- ]);
-
- $result = is_wp_error($terms) ? [] : $terms;
- $this->cache->set($this->taxonomy . '-terms', $result);
- return $result;
- }
-
- /**
- * Get text labels
- * @return array
- */
- protected function getLabels():array
- {
- return [
- 'search' => __('Search...', 'jvb'),
- 'select' => __('Select', 'jvb'),
- 'selected' => __('Selected', 'jvb'),
- 'remove' => __('Remove', 'jvb'),
- 'clear' => __('Clear', 'jvb'),
- 'createNew' => __('Create New', 'jvb'),
- 'loading' => __('Loading...', 'jvb'),
- 'saving' => __('Saving...', 'jvb'),
- 'parent_themes' => __('Parent Themes', 'jvb'),
- 'sub_themes' => __('Sub-themes', 'jvb'),
- 'related_themes' => __('Related Themes', 'jvb'),
- 'popular_combinations' => __('Popular Combinations', 'jvb'),
- 'view_examples' => __('View Examples', 'jvb'),
- 'back_to_parent' => __('Back to Parent', 'jvb')
- ];
- }
-
- /**
- * Render selected items
- * @return void
- */
- protected function renderSelectedItems(array $selected = []):void
- {
- if (empty($selected) && empty($this->selected)) {
- echo '<div class="selected-items"></div>';
- return;
- }
-
- $selected = empty($selected) ? $this->selected : $selected;
-
- $out = '<div class="selected-items">';
- foreach ($selected as $ID) {
- $term = get_term((int)$ID, $this->taxonomy);
-
- if (!$term || is_wp_error($term)) {
- continue;
- }
-
- $out .= $this->renderSelectedItem($term);
- }
- $out .= '</div>';
- echo $out;
- }
-
- /**
- * Render single selected item
- * @param WP_Term $term
- *
- * @return string
- */
- protected function renderSelectedItem(WP_Term $term):string
- {
-
- return '<div class="selected-item" data-id="'.$term->term_id.'">
- <span class="item-name">'.$term->name.'</span>
- <button type="button"
- class="remove-item"
- aria-label="Remove" title="Remove '.$term->name.'">×</button>
- </div>';
- }
-
- /**
- * Render modal content
- * @return void
- */
- protected function renderModalContent():void
- {
- ?>
- <div class="modal-content">
- <header class="modal-header">
- <h3><?= esc_html($this->getModalTitle()); ?></h3>
- </header>
- <div class="actions row">
- <button type="button" class="cancel" aria-label="Close">×</button>
- </div>
- <div class="selected-items">
- </div>
-
- <div class="items-wrap">
- <?php if (!empty($this->config['common'])) : ?>
- <details class="favourite-terms" open>
- <summary class="title row btw">Your Go Tos: </summary>
- <ul></ul>
- </details>
- <?php endif; ?>
- <p class="pagination-info"></p>
- <nav class="term-navigation row"><button type="button" class="back-to-parent" hidden><?=$this->icon->getIcon('back')?></button></nav>
- <ul class="items-container"></ul>
- <p class="loading"> { <span>loading items</span> } </p>
- <div class="scroll-sentinel"></div>
- </div>
-
-
-
- <?php if ($this->config['search'] || $this->config['createNew']) : ?>
- <div class="search-wrapper">
- <div class="search-bar">
-
- <?php if ($this->config['search']) :
- echo jvbSearch('Search '.$this->plural.'...');
- endif; ?>
- </div>
- <?php if ($this->config['createNew']) : ?>
- <details class="create-new-term">
- <summary class="row btw">Add new <?= $this->taxonomyName ?></summary>
- <div class="loader"></div>
- <div class="loading-message create-term" hidden>
- <span id="typed-text"></span>
- <span class="cursor">|</span>
- </div>
- <div class="create-new-term-section">
- <?= (jvbSiteVerifiesUsers()) ? '<p class="suggestion-prompt">
- Not finding what you\'re looking for?<br>
- Suggest a new <span>'.strtolower($this->taxonomyName).'</span>.</p>' : '' ?>
-
-
- <div class="form-row name-row">
- <input type="text"
- name="term_name"
- placeholder="Enter new <?=$this->taxonomyName?> name"
- required>
- </div>
-
- <div class="form-row parent-row toggle">
- <label for="select_parent">Nest new <?= $this->taxonomyName ?> under one of these?</label>
- <select id="select_parent" name="select_parent">
- <option value="0"> . . . </option>
- </select>
- </div>
-
- <button type="button" class="submit-term">
- <?= $this->icon->getIcon('add')?>
- <span><?= (jvbSiteHasTermApproval()) ? 'Suggest '.$this->taxonomyName : 'Create '.$this->taxonomyName?></span>
- </button>
- </div>
- </details>
- <?php endif; ?>
- </div>
- <?php endif; ?>
- </div>
- <?php if (jvbCheck('renderTemplates', $this->config)): ?>
- <template class="loadingItems">
- <p>{ <span>loading items</span> }</p>
- </template>
- <template class="noResults">
- <p>{ <span>nothing found</span> }</p>
- </template>
- <template class="termListItem">
- <li>
- <input type ="<?=($this->config['multiple']) ? 'checkbox' : 'radio'?>">
- <label>
- <span class="term-name"></span>
- </label>
- </li>
- </template>
- <template class="termChildrenToggle">
- <button type="button" class="toggle-children" aria-expanded="false">
- <?=$this->icon->getIcon('add')?>
- </button>
- </template>
- <template class="selectedTerm">
- <div class="selected-item">
- <span class="item-name"></span>
- <button type="button" class="remove-item"><?=$this->icon->getIcon('close')?></button>
- </div>
- </template>
- <template class="termBreadcrumb">
- <button type="button" class="path-level"></button>
- </template>
- <?php
- endif;
- }
-
- /**
- * Get modal title
- * @return string
- */
- protected function getModalTitle():string
- {
- $tax_obj = get_taxonomy($this->taxonomy);
- return sprintf(
- __('Select %s', 'jvb'),
- $tax_obj ? $tax_obj->labels->name : __('Items', 'jvb')
- );
- }
-
-
-
-
- /**
- * Get popularity label
- * @param int $count
- *
- * @return string
- */
- protected function getPopularityLabel(int $count):string
- {
- switch (true) {
- case $count > 100:
- return __('Very Popular', 'jvb');
- case $count > 50:
- return __('Popular', 'jvb');
- case $count > 10:
- return __('Common', 'jvb');
- default:
- return __('Unique', 'jvb');
- }
- }
- /**
- * Render single selectable item
- * @param WP_Term $term
- * @param bool $selected
- *
- * @return void
- */
- protected function renderSelectableItem(WP_Term $term, bool $selected = false):void
- {
- ?>
- <input id="<?=$this->base?><?= strtolower($this->taxonomyName).'-'.esc_attr($term->term_id); ?>"
- type="<?= $this->config['multiple'] ? 'checkbox' : 'radio'; ?>"
- name="<?=$this->base?><?=strtolower($this->taxonomyName)?><?= esc_attr($this->id); ?>"
- value="<?= esc_attr($term->term_id); ?>"
- <?php checked($selected); ?>>
- <label class="selectable-item" for="<?=$this->base?><?= strtolower($this->taxonomyName).'-'.esc_attr($term->term_id); ?>">
- <span><?= esc_html($term->name); ?></span>
- </label>
- <?php
- }
- /**
- * Get term metadata
- */
-// protected function get_term_meta($term) {
-// if (!function_exists('carbon_get_term_meta')) {
-// return [];
-// }
-//
-// return [
-// 'examples' => $this->get_term_examples($term->term_id),
-// 'popular_combinations' => $this->get_term_combinations($term->term_id),
-// 'related_themes' => carbon_get_term_meta($term->term_id, 'jvb_related_themes') ?: []
-// ];
-// }
-
- /**
- * Get theme examples
- */
-// protected function get_term_examples($term_id, $limit = 4) {
-// $examples = [];
-// $posts = get_posts([
-// 'post_type' => "e_{$this->context}",
-// 'posts_per_page' => $limit,
-// 'tax_query' => [[
-// 'taxonomy' => $this->taxonomy,
-// 'terms' => $term_id
-// ]],
-// 'orderby' => 'rand'
-// ]);
-//
-// foreach ($posts as $post) {
-// if (has_post_thumbnail($post)) {
-// $examples[] = [
-// 'title' => get_the_title($post),
-// 'thumbnail' => get_the_post_thumbnail_url($post, 'thumbnail'),
-// 'full' => get_the_post_thumbnail_url($post, 'full')
-// ];
-// }
-// }
-//
-// return $examples;
-// }
- /**
- * Render hidden inputs for form submission
- * @return void
- */
- protected function renderHiddenInputs():void
- {
- if (empty($this->selected)) {
- return;
- }
-
- foreach ($this->selected as $term_id => $name) {
- printf(
- '<input type="hidden" name="%s" value="%s">',
- esc_attr($this->id),
- esc_attr($term_id)
- );
- }
- }
-
- /**
- * Get selected terms data for frontend
- * @return array
- */
- protected function getSelectedData(array $selected = []):array
- {
- $data = [];
- $selected = (empty($selected)) ? $this->selected : $selected;
- foreach ($selected as $ID) {
- $term = get_term((int) $ID, $this->taxonomy);
- if (!$term || is_wp_error($term)) {
- continue;
- }
-
- $data[] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'parent' => $term->parent
- ];
- }
- return $data;
- }
-
- /**
- * @var array Loading state messages
- */
- protected $loading_quips = [
- "Making you look good...",
- "Processing perfection...",
- "Converting ink to pixels...",
- "Teaching robots about art..."
- ];
-
- /**
- * Get loading state HTML
- * @return string
- */
- protected function getLoadingState():string
- {
- ob_start();
- ?>
- <div class="loading-overlay">
- <div class="loading-spinner"></div>
- <div class="loading-message">
- <?= esc_html($this->getRandomQuip()); ?>
- </div>
- </div>
- <?php
- return ob_get_clean();
- }
-
- /**
- * Get random loading message
- * @return string
- */
- protected function getRandomQuip():string
- {
- return $this->loading_quips[array_rand($this->loading_quips)];
- }
-
- public function outputDialog() {
- ?>
- <dialog class="selector-modal">
- <?php $this->renderModalContent(); ?>
- </dialog>
- <?php
- }
-}
diff --git a/inc/helpers/breadcrumbs.php b/inc/helpers/breadcrumbs.php
index 6c6e1f9..262a4d4 100644
--- a/inc/helpers/breadcrumbs.php
+++ b/inc/helpers/breadcrumbs.php
@@ -79,7 +79,7 @@
$url = get_term_link($term->term_id);
array_unshift($crumbs, [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'url' => $url,
'id' => $term->term_id,
]);
diff --git a/inc/helpers/crud.php b/inc/helpers/crud.php
index fa0bdbb..7f4e8c8 100644
--- a/inc/helpers/crud.php
+++ b/inc/helpers/crud.php
@@ -4,6 +4,8 @@
exit;
}
+use JVBase\managers\Cache;
+
/**
* Outputs the blocks of a CRUD management in backend
* Mainly used in news.php so far
@@ -166,7 +168,7 @@
*/
function jvbRenderDateFilter(string $content):string
{
- $cache = new JVBase\Managers\CacheManager('date_filter');
+ $cache = Cache::for('date_filter')->connect('post', true);
$check = $cache->get($content);
if ($check) {
return $check;
diff --git a/inc/helpers/formatting.php b/inc/helpers/formatting.php
index ec0c40f..6f7f077 100644
--- a/inc/helpers/formatting.php
+++ b/inc/helpers/formatting.php
@@ -1,6 +1,7 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
+use JVBase\utility\Image;
if (!defined('ABSPATH')) {
exit;
@@ -73,10 +74,9 @@
*/
function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('rating', WEEK_IN_SECONDS)->connectTo('post')->connectTo('term');
+ $cache = Cache::for('rating', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
$cached = $cache->get($ID);
- $cached = false;
if ($cached) {
return $cached;
}
@@ -137,26 +137,8 @@
*/
function jvbImageData(int $imgID):array
{
- $cache = CacheManager::for('imageData', WEEK_IN_SECONDS)->connectTo('post');
- $cached = $cache->get($imgID);
- if ($cached) {
- return $cached;
- }
-
- if (!wp_get_attachment_image($imgID, 'tiny')) {
- return [];
- }
- $image = [
- 'tiny' => wp_get_attachment_image_src($imgID, 'tiny')[0],
- 'small' => wp_get_attachment_image_src($imgID, 'medium')[0],
- 'medium' => wp_get_attachment_image_src($imgID, 'large')[0],
- 'large' => wp_get_attachment_image_src($imgID, 'full')[0],
- 'image-alt-text'=> get_post_meta($imgID, '_wp_attachment_image_alt', true),
- 'image-title' => get_the_title($imgID),
- 'image-caption' => get_the_excerpt($imgID),
- ];
- $cache->set($imgID, $image);
- return $image;
+ $image = new Image();
+ return $image->getImageData($imgID);
}
@@ -233,3 +215,8 @@
}
return 'tel:+1'.$phoneNumber;
}
+
+function jvbFormatString(string $string):string
+{
+ return html_entity_decode($string);
+}
diff --git a/inc/helpers/members.php b/inc/helpers/members.php
index c24ff84..26c7fa2 100644
--- a/inc/helpers/members.php
+++ b/inc/helpers/members.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
if (!defined('ABSPATH')) {
@@ -16,16 +16,14 @@
*/
function jvbShareName(int $userID):string
{
- $cache = CacheManager::for('usernames');
- $cached = $cache->get($userID);
- if ($cached) {
- return $cached;
- }
- $check = get_user_meta($userID, BASE.'notify', true);
- $name = ($check) ? get_userdata($userID)->display_name : 'Someone';
- $cache->set($userID, $name);
-
- return $name;
+ $cache = Cache::for('usernames')->connect('user');
+ return $cache->remember(
+ $userID,
+ function() use ($userID) {
+ $check = get_user_meta($userID, BASE.'notify', true);
+ return ($check) ? get_userdata($userID)->display_name : 'Someone';
+ }
+ );
}
/**
@@ -35,38 +33,37 @@
*/
function jvbGetUserByFirstName(string $first_name):WP_User|false
{
- $cache = CacheManager::for('userFirstname')->connectTo('user');
- $cached = $cache->get($first_name)??false;
- if ($cached) {
- return get_userdata($cached);
- }
- $args = [
- 'post_type' => BASE . 'artist',
- 'posts_per_page' => 1,
- 'fields' => 'ids',
- 'meta_query' => [
- [
- 'key' => BASE . 'first_name',
- 'value' => $first_name,
- 'compare' => '='
- ]
- ]
- ];
+ $cache = Cache::for('userFirstname')->connect('user', true);
+ return $cache->remember(
+ $first_name,
+ function() use ($first_name) {
+ $args = [
+ 'post_type' => BASE . 'artist',
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'meta_query' => [
+ [
+ 'key' => BASE . 'first_name',
+ 'value' => $first_name,
+ 'compare' => '='
+ ]
+ ]
+ ];
+ $query = new WP_Query($args);
- $query = new WP_Query($args);
-
- if ($query->have_posts()) {
- $post_id = $query->posts[0];
- $user_id = get_post_meta($post_id, BASE . 'link', true);
- $user = get_userdata($user_id)?:false;
- $cached[$user_id] = $first_name;
- $cache->set('user_first_names', $cached);
- wp_reset_postdata();
- return $user;
- }
- wp_reset_postdata();
-
- return false;
+ if ($query->have_posts()) {
+ $post_id = $query->posts[0];
+ $user_id = get_post_meta($post_id, BASE . 'link', true);
+ $user = get_userdata($user_id)?:false;
+ if ($user) {
+ wp_reset_postdata();
+ return $user;
+ }
+ }
+ wp_reset_postdata();
+ return false;
+ }
+ );
}
/**
@@ -76,7 +73,7 @@
*/
function jvbGetUserByDisplayName(string $display_name):WP_User|false
{
- $cache = CacheManager::for('user_displaynames')->connectTo('user');
+ $cache = Cache::for('displayNames')->connect('user', true);
$cached = $cache->get($display_name)??false;
if ($cached && is_int($cached)) {
@@ -115,7 +112,7 @@
function jvbGetUsername(int $user_id):string
{
$key = 'user_display_names';
- $cache = CacheManager::for('userNames', WEEK_IN_SECONDS)->connectTo('user');
+ $cache = Cache::for('userNames', WEEK_IN_SECONDS)->connect('user');
$cached = $cache->get($user_id);
if ($cached) {
@@ -156,7 +153,7 @@
return false;
}
- $cache = CacheManager::for('artist', 3600)->connectTo('post');
+ $cache = Cache::for('artist', 3600)->connect('post');
$cached = $cache->get($userID);
if ($cached) {
return match ($return) {
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 67ade56..4930917 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -5,7 +5,7 @@
}
use JVBase\forms\TaxonomySelector;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
@@ -65,7 +65,7 @@
*/
function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('user_links', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+ $cache = Cache::for('user_links', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
$cached = $cache->get($ID);
if ($cached) {
return $cached;
@@ -141,7 +141,7 @@
*/
function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('contact', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+ $cache = Cache::for('contact', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy');
$cached = $cache->get($ID);
if($cached){
@@ -332,13 +332,13 @@
return '';
}
}
- $cache = CacheManager::for($term->taxonomy);
- $key = $term->term_id.'-link';
+ $cache = Cache::for($term->taxonomy.'_link')->connect('taxonomy');
+ $key = $term->term_id;
return $cache->remember(
$key,
function() use ($term) {
- return '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="'.$term->name.'">'.
- $term->name.
+ return '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="'.html_entity_decode($term->name).'">'.
+ html_entity_decode($term->name).
'</a>';
}
);
@@ -590,7 +590,7 @@
return '';
}
- $cache = CacheManager::for('locations')->connectTo('taxonomy');
+ $cache = Cache::for('locations')->connect('taxonomy');
$key = $cache->generateKey($location);
$cached = false;
diff --git a/inc/helpers/time.php b/inc/helpers/time.php
index 6f94c49..7670726 100644
--- a/inc/helpers/time.php
+++ b/inc/helpers/time.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
exit;
@@ -141,18 +141,18 @@
*/
function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string
{
- $cache = CacheManager::for('hours-'.$ID, WEEK_IN_SECONDS)->connectTo('taxonomy');
- $key = 'hours_display';
- $cached = $cache->get($key);
+ $cache = Cache::for('hours_display', WEEK_IN_SECONDS)->connect('taxonomy')->connect('post')->connect('user');
+
+ $cached = $cache->get($ID);
if ($cached !== false) {
return $cached;
}
if (!$meta) {
- if (term_exists((int)$ID)) {
+ if (term_exists($ID)) {
$type = 'term';
- } elseif (get_post_status((int)$ID)) {
+ } elseif (get_post_status($ID)) {
$type = 'post';
} else {
$type = 'user';
@@ -185,7 +185,7 @@
$out .= '<p class="hours-notes"><small>' . implode(' • ', $notes) . '</small></p>';
}
- $cache->set($key, $out);
+ $cache->set($ID, $out);
return $out;
}
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index 8dac9d4..e97bbfa 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -86,8 +86,8 @@
<div class="actions row end">
<button class="retry" data-action="retry"><span>Retry</span><?= jvbIcon('arrows-clockwise')?></button>
<button class="cancel" data-action="cancel"><span>Cancel</span><?= jvbIcon('x-square')?></button>
+ <button class="refresh" data-action="refresh" title="Refresh to see changes"><span>Refresh</span><?= jvbIcon('arrows-clockwise')?></button>
<button class="dismiss" data-action="dismiss"><span>Dismiss</span><?= jvbIcon('eye-closed')?></button>
-
</div>
</div>
</template>
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index 6e823b2..2e97d01 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -2,7 +2,6 @@
namespace JVBase\integrations;
use JVBase\meta\MetaManager;
-use JVBase\managers\CacheManager;
use WP_Error;
if (!defined('ABSPATH')) {
exit;
@@ -130,7 +129,7 @@
);
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
@@ -2276,7 +2275,7 @@
{
try {
// Use the static method to clear the entire cache group
- $this->cache->clear();
+ $this->cache->flush();
return true;
} catch (\Exception $e) {
diff --git a/inc/integrations/Helcim.php b/inc/integrations/Helcim.php
index b0b037d..ca486cd 100644
--- a/inc/integrations/Helcim.php
+++ b/inc/integrations/Helcim.php
@@ -1491,7 +1491,7 @@
update_user_meta($user->ID, BASE . '_helcim_customer_updated', current_time('mysql'));
// Clear cached customer data
- $this->cache->delete('helcim_customer_' . $user->ID);
+ $this->cache->forget('helcim_customer_' . $user->ID);
}
return true;
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 9f7f851..08c375f 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -2,7 +2,7 @@
namespace JVBase\integrations;
use Exception;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\managers\UploadManager;
use JVBase\meta\MetaManager;
use JVBase\managers\ErrorHandler;
@@ -96,7 +96,7 @@
* Caching Configuration
*/
protected ?string $cacheName = null;
- protected CacheManager $cache;
+ protected Cache $cache;
protected array $cacheStrategy = [
'aggressive' => 3600, // 1 hour for stable data (e.g., profile info)
'moderate' => 300, // 5 minutes for semi-dynamic data (e.g., posts)
@@ -167,7 +167,7 @@
{
$this->cacheName = $this->cacheName ?: $this->service_name;
$this->userID = $userID;
- $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
+ $this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl);
// Load error stats from cache
$this->loadErrorStats();
@@ -669,7 +669,7 @@
protected function clearCache():array
{
- $success = $this->cache->clear();
+ $success = $this->cache->flush();
return [
'success' => $success,
];
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index 121a74f..4b9dd2a 100644
--- a/inc/integrations/Square.php
+++ b/inc/integrations/Square.php
@@ -2572,7 +2572,7 @@
update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql'));
// Clear cached customer data
- $this->cache->delete('square_customer_' . $user->ID);
+ $this->cache->forget('square_customer_' . $user->ID);
}
return true;
diff --git a/inc/integrations/Umami.php b/inc/integrations/Umami.php
index 0fa280b..3c89b76 100644
--- a/inc/integrations/Umami.php
+++ b/inc/integrations/Umami.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\integrations;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use WP_Error;
use WP_Post;
use Exception;
@@ -677,7 +677,7 @@
if ($data) {
// Clear cache for today
$cache_key = md5("analytics_{$today}_{$today}");
- $this->cache->delete($cache_key);
+ $this->cache->forget($cache_key);
return [
'success' => true,
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index 1e42e8c..cf42870 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -98,10 +98,10 @@
switch ($action) {
case 'flush-all':
- wp_cache_flush();
+ $total = Cache::flushAll();
return new \WP_REST_Response([
'success' => true,
- 'message' => 'All caches flushed successfully'
+ 'message' => $total.' caches flushed successfully'
]);
case 'flush-cache':
@@ -113,7 +113,7 @@
], 400);
}
- \JVBase\managers\CacheManager::invalidateAll($group);
+ Cache::for($group)?->flush();
return new \WP_REST_Response([
'success' => true,
@@ -134,17 +134,32 @@
public function handleIconAction(\WP_REST_Request $request): \WP_REST_Response
{
$action = sanitize_text_field($request->get_param('action'));
- $source = sanitize_text_field($request->get_param('source') ?? 'icons'); // Add source param
- $icons = \JVBase\managers\IconsManager::for($source);
+ $source = sanitize_text_field($request->get_param('source') ?? 'icons');
+ $icons = IconsManager::for($source);
switch ($action) {
case 'refresh-icons':
+ // Force regenerate CSS immediately
$icons->forceRefresh();
+ IconsManager::regenerateAllCSS([$source => true]);
+
return new \WP_REST_Response([
'success' => true,
'message' => "Icon CSS regenerated successfully for '{$source}'"
]);
+ case 'refresh-all-icons':
+ // Regenerate all icon sources
+ foreach (['icons', 'forms', 'dash'] as $src) {
+ IconsManager::for($src)->forceRefresh();
+ }
+ IconsManager::regenerateAllCSS();
+
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => 'All icon CSS files regenerated successfully'
+ ]);
+
case 'restore-icon-version':
$timestamp = (int)$request->get_param('timestamp');
if (empty($timestamp)) {
@@ -186,6 +201,9 @@
}
if ($icons->mergeVersions($timestamps)) {
+ // Regenerate CSS after merge
+ IconsManager::regenerateAllCSS([$source => true]);
+
return new \WP_REST_Response([
'success' => true,
'message' => 'Icon versions merged successfully'
@@ -659,20 +677,22 @@
public function renderCachePage():void
{
- $connections = CacheManager::getAllConnections();
+ $groups = Cache::getAllGroups();
// Separate generic vs. specific caches
$generic_groups = [];
$content_specific = [];
$nonce = wp_create_nonce('wp_rest');
- foreach ($connections as $group => $configs) {
- $is_generic = !$this->isBoundToContentOrTaxonomy($group);
+ // Separate by type
+ $generic = [];
+ $specific = [];
- if ($is_generic) {
- $generic_groups[$group] = $configs;
+ foreach ($groups as $group => $data) {
+ if ($this->isBoundToContentOrTaxonomy($group)) {
+ $specific[$group] = $data;
} else {
- $content_specific[$group] = $configs;
+ $generic[$group] = $data;
}
}
@@ -728,7 +748,7 @@
</tr>
</thead>
<tbody>
- <?php foreach ($content_specific as $group => $configs): ?>
+ <?php foreach ($specific as $group => $configs): ?>
<tr>
<td><strong><?= esc_html($group); ?></strong></td>
<td><?= $this->formatConnections($configs); ?></td>
@@ -739,6 +759,19 @@
</td>
</tr>
<?php endforeach; ?>
+ <?php foreach ($generic as $group => $data): ?>
+ <tr>
+ <td><strong><?= esc_html($group); ?></strong></td>
+ <td><?= $this->formatConnections($data); ?></td>
+ <td>
+ <button type="button" class="button"
+ data-action="flush-cache"
+ data-group="<?= esc_attr($group); ?>">
+ <?= jvbDashIcon('trash'); ?> Flush
+ </button>
+ </td>
+ </tr>
+ <?php endforeach; ?>
</tbody>
</table>
</details>
@@ -820,15 +853,24 @@
return false;
}
- protected function formatConnections(array $configs): string
+ protected function formatConnections(array $data): string
{
- $connections = [];
- foreach ($configs as $config) {
- $parent = $config['parent'] ?? 'unknown';
- $scope = $config['scope'] ?? 'id';
- $connections[] = "{$parent} ({$scope})";
+ $parts = [];
+
+ if (!empty($data['connects_to'])) {
+ $targets = array_map(function($conn) {
+ $flush_text = $conn['flush'] ? ' (flush all)' : '';
+ return $conn['group'] . $flush_text;
+ }, $data['connects_to']);
+ $parts[] = '<strong>Invalidates:</strong> ' . implode(', ', $targets);
}
- return esc_html(implode(', ', $connections));
+
+ if (!empty($data['connected_from'])) {
+ $sources = array_map(fn($conn) => $conn['group'], $data['connected_from']);
+ $parts[] = '<strong>Invalidated by:</strong> ' . implode(', ', $sources);
+ }
+
+ return $parts ? implode('<br>', $parts) : 'No connections';
}
public function handleCacheActions($response, $request, $action):WP_REST_Response
@@ -854,7 +896,8 @@
], 400);
}
- \JVBase\managers\CacheManager::invalidateAll($group);
+ $group = sanitize_text_field($request->get_param('group'));
+ Cache::invalidateGroup($group);
return new WP_REST_Response([
'success' => true,
@@ -862,68 +905,6 @@
]);
}
- if ($action === 'merge-icon-versions') {
- $timestamps = $request->get_param('timestamps');
-
- if (empty($timestamps) || !is_array($timestamps)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No versions selected for merging'
- ], 400);
- }
-
- // Convert to integers
- $timestamps = array_map('intval', $timestamps);
-
- if (count($timestamps) < 2) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Please select at least 2 versions to merge'
- ], 400);
- }
-
- $icons = \JVBase\managers\IconsManager::getInstance();
-
- if ($icons->mergeVersions($timestamps)) {
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Icon versions merged successfully'
- ]);
- }
-
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Failed to merge icon versions'
- ], 500);
- }
-
- if ($action === 'refresh-icons') {
- $icons = \JVBase\managers\IconsManager::getInstance();
- $icons->forceRefresh();
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Icon CSS refresh triggered'
- ]);
- }
-
- if ($action === 'restore-icon-version') {
- $timestamp = (int)$request->get_param('timestamp');
- $icons = \JVBase\managers\IconsManager::getInstance();
-
- if ($icons->restoreVersion($timestamp)) {
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Icon version restored successfully'
- ]);
- }
-
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Failed to restore icon version'
- ], 500);
- }
-
return $response;
}
@@ -936,7 +917,7 @@
// Get all registered icon sources
$all_sources = ['icons', 'forms', 'dash']; // You could get this dynamically if needed
- $icons = \JVBase\managers\IconsManager::for($current_source);
+ $icons = IconsManager::for($current_source);
$versions = $icons->getVersionHistory();
$nonce = wp_create_nonce('wp_rest');
diff --git a/inc/managers/AjaxRateLimiter.php b/inc/managers/AjaxRateLimiter.php
deleted file mode 100644
index 74272af..0000000
--- a/inc/managers/AjaxRateLimiter.php
+++ /dev/null
@@ -1,325 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-/**
- * Simple rate limiter for AJAX requests (non-REST)
- * Includes both hourly limits AND burst protection
- */
-class AjaxRateLimiter
-{
- protected array $limits = [
- 'login' => [
- 'count' => 20, // Hourly limit
- 'window' => 3600, // 1 hour
- 'burst_count' => 5, // Burst limit
- 'burst_window' => 60 // 1 minute
- ],
- 'register' => [
- 'count' => 10,
- 'window' => 3600,
- 'burst_count' => 3,
- 'burst_window' => 60
- ],
- 'lostpassword' => [
- 'count' => 10,
- 'window' => 3600,
- 'burst_count' => 3,
- 'burst_window' => 60
- ],
- 'resetpass' => [
- 'count' => 10,
- 'window' => 3600,
- 'burst_count' => 3,
- 'burst_window' => 60
- ],
- ];
-
- /**
- * Check if action is within rate limits (both hourly and burst)
- *
- * @param string $action The action being performed (login, register, etc.)
- * @return bool True if within limits, false if exceeded
- */
- public function checkLimit(string $action): bool
- {
- // Check burst protection first (stricter, prevents rapid-fire)
- if (!$this->checkBurstLimit($action)) {
- return false;
- }
-
- // Then check hourly limit
- return $this->checkHourlyLimit($action);
- }
-
- /**
- * Check burst protection (prevents rapid-fire attempts)
- *
- * Example: 5 login attempts in 10 seconds = blocked
- *
- * @param string $action The action being performed
- * @return bool True if within burst limits, false if exceeded
- */
- protected function checkBurstLimit(string $action): bool
- {
- $limit = $this->getLimit($action);
-
- // Skip if no burst protection configured
- if (!isset($limit['burst_count'])) {
- return true;
- }
-
- $key = $this->getCacheKey($action) . '_burst';
- $data = get_transient($key);
-
- if (!$data) {
- $data = ['count' => 0, 'first_attempt' => time()];
- }
-
- // Check if burst window expired
- $elapsed = time() - $data['first_attempt'];
- if ($elapsed >= $limit['burst_window']) {
- // Window expired, reset
- $data = ['count' => 0, 'first_attempt' => time()];
- }
-
- // Check if burst limit exceeded
- if ($data['count'] >= $limit['burst_count']) {
- // Log for security monitoring
- error_log(sprintf(
- 'Burst rate limit exceeded for %s from %s: %d attempts in %d seconds',
- $action,
- $this->getClientIp(),
- $data['count'],
- $elapsed
- ));
- return false;
- }
-
- // Increment and save
- $data['count']++;
- set_transient($key, $data, $limit['burst_window']);
-
- return true;
- }
-
- /**
- * Check hourly rate limit
- *
- * @param string $action The action being performed
- * @return bool True if within hourly limits, false if exceeded
- */
- protected function checkHourlyLimit(string $action): bool
- {
- $key = $this->getCacheKey($action);
- $limit = $this->getLimit($action);
-
- // Get current count
- $data = get_transient($key);
- if (!$data) {
- $data = ['count' => 0, 'first_attempt' => time()];
- }
-
- // Check if window has expired
- if (time() - $data['first_attempt'] >= $limit['window']) {
- // Window expired, reset
- $data = ['count' => 0, 'first_attempt' => time()];
- }
-
- // Check if limit exceeded
- if ($data['count'] >= $limit['count']) {
- // Log for security monitoring
- error_log(sprintf(
- 'Hourly rate limit exceeded for %s from %s: %d attempts',
- $action,
- $this->getClientIp(),
- $data['count']
- ));
- return false;
- }
-
- // Increment and save
- $data['count']++;
- set_transient($key, $data, $limit['window']);
-
- return true;
- }
-
- /**
- * Get remaining attempts for an action
- *
- * @param string $action The action being performed
- * @return array ['remaining' => int, 'reset_at' => int, 'burst_remaining' => int, 'burst_reset_at' => int]
- */
- public function getRemaining(string $action): array
- {
- $limit = $this->getLimit($action);
-
- // Hourly remaining
- $key = $this->getCacheKey($action);
- $data = get_transient($key);
-
- $hourly_remaining = $limit['count'];
- $hourly_reset_at = time() + $limit['window'];
-
- if ($data) {
- $hourly_remaining = max(0, $limit['count'] - $data['count']);
- $hourly_reset_at = $data['first_attempt'] + $limit['window'];
- }
-
- // Burst remaining (if configured)
- $burst_remaining = $limit['burst_count'] ?? null;
- $burst_reset_at = null;
-
- if (isset($limit['burst_count'])) {
- $burst_key = $key . '_burst';
- $burst_data = get_transient($burst_key);
-
- if ($burst_data) {
- $burst_remaining = max(0, $limit['burst_count'] - $burst_data['count']);
- $burst_reset_at = $burst_data['first_attempt'] + $limit['burst_window'];
- } else {
- $burst_reset_at = time() + $limit['burst_window'];
- }
- }
-
- return [
- 'remaining' => $hourly_remaining,
- 'reset_at' => $hourly_reset_at,
- 'burst_remaining' => $burst_remaining,
- 'burst_reset_at' => $burst_reset_at
- ];
- }
-
- /**
- * Generate cache key based on IP and action
- *
- * @param string $action The action being performed
- * @return string Cache key
- */
- protected function getCacheKey(string $action): string
- {
- $ip = $this->getClientIp();
- $user_id = get_current_user_id(); // 0 if not logged in
-
- return BASE . 'ajax_rate_limit_' . md5($ip . '_' . $user_id . '_' . $action);
- }
-
- /**
- * Get client IP address (supports proxies)
- *
- * @return string IP address
- */
- protected function getClientIp(): string
- {
- // Check for proxy headers first
- if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
- $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
- // X-Forwarded-For can contain multiple IPs, get the first one
- $ips = explode(',', $ip);
- return trim($ips[0]);
- }
-
- if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
- return $_SERVER['HTTP_CLIENT_IP'];
- }
-
- return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
- }
-
- /**
- * Get limit configuration for an action
- *
- * @param string $action The action being performed
- * @return array Limit configuration
- */
- protected function getLimit(string $action): array
- {
- return $this->limits[$action] ?? $this->limits['login'];
- }
-
- /**
- * Clear rate limit for a specific action (useful for testing)
- *
- * @param string $action The action to clear
- * @return bool True if cleared, false otherwise
- */
- public function clearLimit(string $action): bool
- {
- $key = $this->getCacheKey($action);
- $burst_key = $key . '_burst';
-
- $result1 = delete_transient($key);
- $result2 = delete_transient($burst_key);
-
- return $result1 || $result2;
- }
-
- /**
- * Update limit configuration
- *
- * @param string $action The action to update
- * @param int $count Max attempts per window
- * @param int $window Time window in seconds
- * @param int|null $burst_count Optional burst limit
- * @param int|null $burst_window Optional burst window
- */
- public function setLimit(
- string $action,
- int $count,
- int $window,
- ?int $burst_count = null,
- ?int $burst_window = null
- ): void {
- $this->limits[$action] = [
- 'count' => $count,
- 'window' => $window
- ];
-
- if ($burst_count !== null && $burst_window !== null) {
- $this->limits[$action]['burst_count'] = $burst_count;
- $this->limits[$action]['burst_window'] = $burst_window;
- }
- }
-
- /**
- * Check if IP is currently rate limited
- *
- * @param string $action The action to check
- * @return bool True if rate limited, false otherwise
- */
- public function isRateLimited(string $action): bool
- {
- // Check both burst and hourly without incrementing
- $limit = $this->getLimit($action);
-
- // Check burst
- if (isset($limit['burst_count'])) {
- $burst_key = $this->getCacheKey($action) . '_burst';
- $burst_data = get_transient($burst_key);
-
- if ($burst_data) {
- $elapsed = time() - $burst_data['first_attempt'];
- if ($elapsed < $limit['burst_window'] && $burst_data['count'] >= $limit['burst_count']) {
- return true;
- }
- }
- }
-
- // Check hourly
- $key = $this->getCacheKey($action);
- $data = get_transient($key);
-
- if ($data) {
- $elapsed = time() - $data['first_attempt'];
- if ($elapsed < $limit['window'] && $data['count'] >= $limit['count']) {
- return true;
- }
- }
-
- return false;
- }
-}
diff --git a/inc/managers/CRUDManager.php b/inc/managers/CRUDManager.php
index 2b62d88..6635406 100644
--- a/inc/managers/CRUDManager.php
+++ b/inc/managers/CRUDManager.php
@@ -14,7 +14,7 @@
*/
class CRUD {
protected CRUDSkeleton $skeleton;
- protected CacheManager $cache;
+ protected Cache $cache;
protected array $config;
protected string $content;
protected array $taxonomies = [];
@@ -39,10 +39,10 @@
$this->user_id = get_current_user_id();
$this->config = $this->constant[$content];
$this->content = $content;
- $this->cache = CacheManager::for($content);
+ $this->cache = Cache::for('crud')->connect('post')->connect('taxonomy');
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
// Create and configure skeleton
diff --git a/inc/managers/Cache.php b/inc/managers/Cache.php
new file mode 100644
index 0000000..e7ebc94
--- /dev/null
+++ b/inc/managers/Cache.php
@@ -0,0 +1,652 @@
+<?php
+namespace JVBase\managers;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Cache
+{
+ private string $group;
+ private int $ttl;
+ private static array $timestamps = [];
+ private const TS_GROUP = 'jvb_http_ts';
+
+ private const CONNECTIONS_OPTION = 'jvb_cache_connections';
+ private static ?array $connections = null;
+ protected array $tags = [];
+
+ private static array $instances = [];
+ private bool $hasRedis;
+
+ private function __construct(string $group, int $ttl)
+ {
+ $this->group = $group;
+ $this->ttl = $ttl;
+ $this->hasRedis = (bool) wp_using_ext_object_cache();
+ }
+
+ public static function registerHooks(): void
+ {
+ // Post updates (all post types including core)
+ add_action('save_post', [self::class, 'onPostChange'], 10, 2);
+ add_action('delete_post', [self::class, 'onPostDelete']);
+
+ // Post meta updates
+ add_action('updated_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
+ add_action('added_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
+ add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 2);
+
+ // Term updates (all taxonomies)
+ add_action('edited_term', [self::class, 'onTermChange'], 10, 3);
+ add_action('create_term', [self::class, 'onTermChange'], 10, 3);
+ add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+ // Term meta updates
+ add_action('updated_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
+ add_action('added_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
+ add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 2);
+
+ // User updates
+ add_action('profile_update', [self::class, 'onUserChange'], 10, 2);
+ add_action('user_register', [self::class, 'onUserChange'], 10, 1);
+ add_action('deleted_user', [self::class, 'onUserDelete']);
+
+ // User meta updates
+ add_action('updated_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
+ add_action('added_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
+ add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 2);
+ }
+
+ /* ---------------------------------------------------------------------
+ * Factory
+ * ------------------------------------------------------------------- */
+
+ public static function for(string $group, int $ttl = HOUR_IN_SECONDS): self
+ {
+ $group = sanitize_key($group);
+
+ if (!isset(self::$instances[$group])) {
+ self::$instances[$group] = new self($group, $ttl);
+ }
+
+ return self::$instances[$group];
+ }
+
+ /* ---------------------------------------------------------------------
+ * Core operations
+ * ------------------------------------------------------------------- */
+
+ public function remember(int|string|array $key, callable $callback, ?int $ttl = null): mixed
+ {
+ if (is_array($key)) {
+ $key = $this->generateKey($key);
+ }
+ if (!empty($this->tags)) {
+ return $this->rememberTagged(
+ $key,
+ $this->tags,
+ $callback,
+ $ttl
+ );
+ }
+
+ $value = $this->get($key);
+
+ if ($value !== false) {
+ return $value;
+ }
+
+ $value = $callback();
+
+ if ($value !== null && $value !== false) {
+ $this->set($key, $value);
+ }
+
+ return $value;
+ }
+
+ public function get(int|string|array $id): mixed
+ {
+ if (is_array($id)) {
+ $id = $this->generateKey($id);
+ }
+ if ($this->hasRedis) {
+ $value = wp_cache_get($id, $this->group);
+ } else {
+ $value = get_transient("jvb_{$this->group}_{$id}");
+ }
+
+ return $value;
+ }
+
+ public function set(int|string|array $id, mixed $value, ?int $ttl = null): void
+ {
+ if (is_array($id)) {
+ $id = $this->generateKey($id);
+ }
+ $ttl = $ttl ?? $this->ttl;
+ if ($this->hasRedis) {
+ wp_cache_set($id, $value, $this->group, $ttl);
+ } else {
+ set_transient("jvb_{$this->group}_{$id}", $value, $ttl);
+ }
+ }
+
+ public function forget(int|string|array $id): void
+ {
+ if (is_array($id)) {
+ $id = $this->generateKey($id);
+ }
+ if ($this->hasRedis) {
+ wp_cache_delete($id, $this->group);
+ } else {
+ delete_transient("jvb_{$this->group}_{$id}");
+ }
+ }
+
+ public function flush(): void
+ {
+ if ($this->hasRedis) {
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($this->group);
+ } else {
+ wp_cache_flush();
+ }
+ } else {
+ $this->clearGroupTransients();
+ }
+ }
+
+ /* ---------------------------------------------------------------------
+ * Invalidation
+ * ------------------------------------------------------------------- */
+
+ public static function invalidateItem(string $group, int|string|array $id): void
+ {
+ if (is_array($id)) {
+ $id = self::for($group)->generateKey($id);
+ }
+ $group = sanitize_key($group);
+
+ if (wp_using_ext_object_cache()) {
+ wp_cache_delete($id, $group);
+ } else {
+ delete_transient("jvb_{$group}_{$id}");
+ }
+ self::touch($group);
+
+ foreach (self::connections()[$group] ?? [] as $conn) {
+ $target = $conn['target'] ?? $conn; // Backwards compat if still string
+ $flush = $conn['flush'] ?? false;
+
+ if ($flush) {
+ // Flush entire target group
+ self::invalidateGroup($target);
+ } else {
+ // Just delete this item ID
+ if (wp_using_ext_object_cache()) {
+ wp_cache_delete($id, $target);
+ } else {
+ delete_transient("jvb_{$target}_{$id}");
+ }
+ self::touch($target);
+ }
+ }
+ self::invalidateByTag($group, $id);
+ }
+
+ public static function invalidateGroup(string $group): void
+ {
+ $group = sanitize_key($group);
+
+ if (wp_using_ext_object_cache()) {
+ wp_cache_flush_group($group);
+ } else {
+ $instance = self::for($group);
+ $instance->clearGroupTransients();
+ }
+
+ self::touch($group);
+
+ foreach (self::connections()[$group] ?? [] as $conn) {
+ $target = $conn['target'] ?? $conn; // Backwards compat
+
+ // When flushing entire source group, always flush connected targets
+ // (regardless of flush flag - we don't know which items to delete)
+ if (wp_using_ext_object_cache()) {
+ wp_cache_flush_group($target);
+ } else {
+ $instance = self::for($target);
+ $instance->clearGroupTransients();
+ }
+ self::touch($target);
+ }
+ }
+
+ public static function touch(string $group): int
+ {
+ $group = sanitize_key($group);
+ $time = time();
+
+ if (wp_using_ext_object_cache()) {
+ wp_cache_set($group, $time, self::TS_GROUP, WEEK_IN_SECONDS);
+ } else {
+ set_transient('jvb_ts_' . $group, $time, WEEK_IN_SECONDS);
+ }
+
+ self::$timestamps[$group] = $time;
+ return $time;
+ }
+
+ public static function lastModified(string|array $groups): int
+ {
+ if (is_array($groups)) {
+ return max(array_map([self::class, 'lastModified'], $groups));
+ }
+
+ $group = sanitize_key($groups);
+
+ if (isset(self::$timestamps[$group])) {
+ return self::$timestamps[$group];
+ }
+
+ if (wp_using_ext_object_cache()) {
+ $ts = (int) wp_cache_get($group, self::TS_GROUP);
+ } else {
+ $ts = (int) get_transient('jvb_ts_' . $group);
+ }
+
+ if (!$ts) {
+ $ts = time();
+ if (wp_using_ext_object_cache()) {
+ wp_cache_set($group, $ts, self::TS_GROUP, WEEK_IN_SECONDS);
+ } else {
+ set_transient('jvb_ts_' . $group, $ts, WEEK_IN_SECONDS);
+ }
+ }
+
+ return self::$timestamps[$group] = $ts;
+ }
+
+ public function getLastModifiedForTags(array $tags): ?int
+ {
+ if (!$this->hasRedis) {
+ return null;
+ }
+ $redis = self::redis();
+ if (!$redis) {
+ return null;
+ }
+
+ $lastModified = 0;
+
+ foreach ($tags as $tag) {
+ $ts = $redis->get("jvb:tag:{$tag}:lastModified");
+ if ($ts) {
+ $lastModified = max($lastModified, (int) $ts);
+ }
+ }
+
+ return $lastModified ?: null;
+ }
+
+ /****************************************************
+ * CONNECTIONS
+ ****************************************************/
+ private static function connections(): array
+ {
+ if (self::$connections === null) {
+ self::$connections = get_option(self::CONNECTIONS_OPTION, []);
+ }
+ return self::$connections;
+ }
+
+
+ public function connect(string $source, bool $flush = false): self
+ {
+ $source = sanitize_key($source);
+ $target = $this->group;
+
+ $all = self::connections();
+ $all[$source] ??= [];
+
+ $before = count($all[$source]);
+
+ // Add the connection
+ $all[$source][] = ['target' => $target, 'flush' => $flush];
+
+ // Remove duplicates by serializing for comparison
+ $all[$source] = array_values(array_unique($all[$source], SORT_REGULAR));
+
+ // Only update if something actually changed
+ if (count($all[$source]) !== $before) {
+ update_option(self::CONNECTIONS_OPTION, $all, false);
+ self::$connections = $all;
+ }
+
+ return $this;
+ }
+ /****************************************************
+ * REDIS
+ ****************************************************/
+ private static function redis(): ?\Redis
+ {
+ global $wp_object_cache;
+
+ return $wp_object_cache instanceof \WP_Object_Cache
+ && isset($wp_object_cache->redis)
+ ? $wp_object_cache->redis
+ : null;
+ }
+
+ /**
+ * Remember with tags for complex invalidation scenarios
+ *
+ * Example: Cache user favorites tagged by each post ID
+ * When any post updates, this cache entry auto-invalidates
+ *
+ * @param int|string|array $key Cache key
+ * @param array $tags Array of [group, id] pairs: [['post', 123], ['user', 456]]
+ * @param callable $callback Function to generate value if cache miss
+ * @return mixed Cached or generated value
+ */
+ public function rememberTagged(
+ int|string|array $key,
+ array $tags,
+ callable $callback,
+ ?int $ttl = null
+ ): mixed {
+ if (is_array($key)) {
+ $id = $this->generateKey($key);
+ }
+ $tags = array_unique(array_merge(
+ $this->getTags(),
+ array_map('sanitize_key', $tags)
+ ));
+
+
+ $value = wp_cache_get($key, $this->group);
+ if ($value !== false) {
+ return $value;
+ }
+
+ $value = $callback();
+ if ($value === null || $value === false) {
+ return $value;
+ }
+
+ wp_cache_set($key, $value, $this->group, $this->ttl);
+
+ if ($redis = self::redis()) {
+ foreach ($tags as [$tagGroup, $tagId]) {
+ $redis->sAdd("tag:$tagGroup:$tagId", "{$this->group}:$key");
+ }
+ }
+
+ return $value;
+ }
+
+ private static function invalidateByTag(string $group, int|string|array $id): void
+ {
+ if (is_array($id)) {
+ $id = self::for($group)->generateKey($id);
+ }
+ if (!$redis = self::redis()) {
+ return;
+ }
+
+ $key = "tag:$group:$id";
+ $targets = $redis->sMembers($key);
+
+ foreach ($targets as $target) {
+ [$group, $id] = explode(':', $target, 2);
+ wp_cache_delete($id, $group);
+ }
+
+ $redis->del($key);
+ }
+
+ public function tag(string $tag): static
+ {
+ $this->tags[] = sanitize_key($tag);
+ return $this;
+ }
+
+ public function getTags(): array
+ {
+ return array_unique($this->tags);
+ }
+ /****************************************************
+ * TRANSIENT HELPER
+ ****************************************************/
+ private function clearGroupTransients(): void
+ {
+ global $wpdb;
+
+ $pattern = '_transient_jvb_' . $this->group . '_%';
+ $timeout_pattern = '_transient_timeout_jvb_' . $this->group . '_%';
+
+ // Remove LIMIT to avoid table locks, add retry for deadlocks
+ $attempts = 0;
+ $max_attempts = 3;
+
+ while ($attempts < $max_attempts) {
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM $wpdb->options
+ WHERE option_name LIKE %s OR option_name LIKE %s",
+ $pattern,
+ $timeout_pattern
+ )
+ );
+
+ // Success or non-deadlock error
+ if ($result !== false || !str_contains($wpdb->last_error, 'Deadlock')) {
+ break;
+ }
+
+ $attempts++;
+ if ($attempts < $max_attempts) {
+ usleep(50000); // Wait 50ms before retry
+ }
+ }
+ }
+ /****************************************************
+ * HOOKS
+ ****************************************************/
+ /****************************************************
+ * HOOKS - Posts
+ ****************************************************/
+ public static function onPostChange(int $postId, \WP_Post $post): void
+ {
+ if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
+ return;
+ }
+
+// error_log('[Clearing cache for post change: '.$postId.']');
+ self::invalidateItem('post', $postId);
+ }
+
+ public static function onPostDelete(int $postId): void
+ {
+// error_log('[Clearing cache for post delete: '.$postId.']');
+ self::invalidateItem('post', $postId);
+ }
+
+ public static function onPostMetaChange(int $metaId, int $objectId): void
+ {
+// error_log('[Clearing cache for post meta change: '.$objectId.']');
+ self::invalidateItem('post', $objectId);
+ }
+
+ public static function onPostMetaDelete(array $metaIds, int $objectId): void
+ {
+// error_log('[Clearing cache for post meta delete: '.$objectId.']');
+ self::invalidateItem('post', $objectId);
+ }
+
+ /****************************************************
+ * HOOKS - Terms
+ ****************************************************/
+ public static function onTermChange(int $termId, int $ttId, string $taxonomy): void
+ {
+// error_log('[Clearing cache for term change: '.$termId.']');
+ self::invalidateItem('taxonomy', $termId);
+ }
+
+ public static function onTermDelete(int $termId): void
+ {
+// error_log('[Clearing cache for term delete: '.$termId.']');
+ self::invalidateItem('taxonomy', $termId);
+ }
+
+ public static function onTermMetaChange(int $metaId, int $objectId): void
+ {
+// error_log('[Clearing cache for term meta change: '.$objectId.']');
+ self::invalidateItem('taxonomy', $objectId);
+ }
+
+ public static function onTermMetaDelete(array $metaIds, int $objectId): void
+ {
+// error_log('[Clearing cache for term meta delete: '.$objectId.']');
+ self::invalidateItem('taxonomy', $objectId);
+ }
+
+ /****************************************************
+ * HOOKS - Users
+ ****************************************************/
+ public static function onUserChange(int $userId): void
+ {
+// error_log('[Clearing cache for user change: '.$userId.']');
+ self::invalidateItem('user', $userId);
+ }
+
+ public static function onUserDelete(int $userId): void
+ {
+// error_log('[Clearing cache for user delete: '.$userId.']');
+ self::invalidateItem('user', $userId);
+ }
+
+ public static function onUserMetaChange(int $metaId, int $objectId): void
+ {
+// error_log('[Clearing cache for user meta change: '.$objectId.']');
+ self::invalidateItem('user', $objectId);
+ }
+
+ public static function onUserMetaDelete(array $metaIds, int $objectId): void
+ {
+// error_log('[Clearing cache for user meta delete: '.$objectId.']');
+ self::invalidateItem('user', $objectId);
+ }
+ /***************************************************
+ * UTILITY
+ **************************************************/
+ /**
+ * Generate a cache key from parameters
+ * Useful for caching based on multiple variables
+ *
+ * @param array $params Key-value pairs that uniquely identify this cache entry
+ * @return string MD5 hash of sorted parameters
+ */
+ public function generateKey(array $params): string
+ {
+ ksort($params);
+ return md5(serialize($params));
+ }
+
+ /**
+ * Nuclear option: Flush ALL registered cache groups
+ * Use for debugging or after major updates
+ *
+ * @return int Number of groups flushed
+ */
+ public static function flushAll(): int
+ {
+ $all = self::connections();
+ $groups = [];
+
+ // Collect all unique groups from connections
+ foreach ($all as $source => $targets) {
+ $groups[$source] = true;
+ foreach ($targets as $conn) {
+ $target = $conn['target'] ?? $conn;
+ $groups[$target] = true;
+ }
+ }
+
+ // Add any instantiated groups not in connections
+ foreach (array_keys(self::$instances) as $group) {
+ $groups[$group] = true;
+ }
+
+ // Flush each group
+ $count = 0;
+ foreach (array_keys($groups) as $group) {
+ self::invalidateGroup($group);
+ $count++;
+ }
+
+ // Also flush timestamp cache
+ if (wp_using_ext_object_cache()) {
+ wp_cache_flush_group(self::TS_GROUP);
+ }
+
+ // Clear in-memory caches
+ self::$timestamps = [];
+
+ return $count;
+ }
+
+ /**
+ * Get all cache groups and their connections for admin display
+ *
+ * @return array Format: ['group' => ['connects_to' => [...], 'connected_from' => [...]]]
+ */
+ public static function getAllGroups(): array
+ {
+ $connections = self::connections();
+ $groups = [];
+
+ // Build bidirectional view
+ foreach ($connections as $source => $targets) {
+ if (!isset($groups[$source])) {
+ $groups[$source] = ['connects_to' => [], 'connected_from' => []];
+ }
+
+ foreach ($targets as $conn) {
+ $target = $conn['target'] ?? $conn;
+ $flush = $conn['flush'] ?? false;
+
+ // Source connects to target
+ $groups[$source]['connects_to'][] = [
+ 'group' => $target,
+ 'flush' => $flush
+ ];
+
+ // Target is connected from source
+ if (!isset($groups[$target])) {
+ $groups[$target] = ['connects_to' => [], 'connected_from' => []];
+ }
+ $groups[$target]['connected_from'][] = [
+ 'group' => $source,
+ 'flush' => $flush
+ ];
+ }
+ }
+
+ // Add any instantiated groups not in connections
+ foreach (array_keys(self::$instances) as $group) {
+ if (!isset($groups[$group])) {
+ $groups[$group] = ['connects_to' => [], 'connected_from' => []];
+ }
+ }
+
+ return $groups;
+ }
+
+ public function hasRedis():bool
+ {
+ return $this->hasRedis;
+ }
+}
diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
deleted file mode 100644
index 2583431..0000000
--- a/inc/managers/CacheManager.php
+++ /dev/null
@@ -1,856 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-/**
- * Manages HTTP cache timestamps and relationship-based invalidation
- *
- * Data caching: Use wrapper methods or wp_cache_get/set directly
- * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
- */
-class CacheManager
-{
- private const CONNECTIONS_OPTION = BASE.'cache_connections';
- private static ?array $connections_cache = null; // Cache in memory
- private string $prefix = BASE;
- private string $group;
- private int $cache_ttl;
- private static ?bool $use_object_cache = null;
- private static array $instances = []; // Cache instances per type
- private static array $http_timestamps = []; // Request-level memory cache
- private static ?CacheManager $singleton = null;
-
- /**
- * Private constructor - use for() factory method instead
- */
- private function __construct(string $group, ?int $ttl = null)
- {
- $this->group = jvbNoBase($group);
- $this->cache_ttl = $ttl ?: 3600;
-
- if (is_null(static::$use_object_cache)) {
- static::$use_object_cache = wp_using_ext_object_cache();
- }
-
- add_action('init', [$this, 'registerHooks']);
- }
-
- /**
- * Get singleton instance (for general cache operations)
- * For type-specific operations, use for() or forUser() instead
- */
- public static function getInstance(): self
- {
- if (self::$singleton === null) {
- self::$singleton = new self('global', HOUR_IN_SECONDS);
- }
- return self::$singleton;
- }
-
- /**
- * Get all cache connections (public accessor)
- *
- * @return array Array of cache group connections
- */
- public static function getAllConnections(): array
- {
- return self::getConnections();
- }
-
- /**
- * Get all registered cache groups
- *
- * @return array List of cache group names
- */
- public static function getAllGroups(): array
- {
- $connections = self::getConnections();
- return array_keys($connections);
- }
- /**
- * Register WordPress hooks for automatic cache invalidation
- * Call this once during plugin initialization
- */
- public static function registerHooks(): void
- {
- // Post updates (all post types including core)
- add_action('save_post', [self::class, 'onPostSave'], 10, 2);
- add_action('delete_post', [self::class, 'onPostDelete']);
- // Meta updates (will catch MetaManager updates)
- add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
- add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
- add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
- // transition_post_status?
-
- // Term updates (all taxonomies)
- add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
- add_action('create_term', [self::class, 'onTermSave'], 10, 3);
- add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
-
- // Term meta updates
- add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
- add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
- add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
-
- // User updates
- add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
- add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
- add_action('deleted_user', [self::class, 'onUserDelete']);
-
- // User meta updates
- add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
- add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
- add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
- }
-
- /**
- * Get or create a cache manager instance for a content type
- *
- * @param string $type Content type (tattoo, style, etc.)
- * @param int|null $ttl Optional TTL override
- * @return self Fluent interface
- */
- public static function for(string $type, ?int $ttl = null): self
- {
- $type = jvbNoBase($type);
- $key = $type . ($ttl ? "_{$ttl}" : '');
-
- if (!isset(self::$instances[$key])) {
- self::$instances[$key] = new self($type, $ttl);
- }
-
- return self::$instances[$key];
- }
-
- /**
- * Get cache manager for a specific user
- * Each user gets their own cache group for complete isolation
- *
- * @param int $user_id User ID
- * @param int|null $ttl Optional TTL
- * @return self
- */
- public static function forUser(int $user_id, ?int $ttl = null): self
- {
- return self::for("user_{$user_id}", $ttl);
- }
-
- /**
- * Get HTTP cache timestamp for content type(s)
- * Used for ETag and Last-Modified header generation
- *
- * @param string|array $types Single type or array of types
- * @return int Latest timestamp (Unix time)
- */
- public static function getTimestamp(string|array $types): int
- {
- // Multiple types - return latest
- if (is_array($types)) {
- $latest = 0;
- foreach ($types as $type) {
- $timestamp = self::getTimestamp($type);
- if ($timestamp > $latest) {
- $latest = $timestamp;
- }
- }
- return $latest ?: time();
- }
-
- $type = jvbNoBase($types);
-
- // Check request-level cache
- if (isset(self::$http_timestamps[$type])) {
- return self::$http_timestamps[$type];
- }
-
- // Load from cache (Redis or transient - wp_cache handles it)
- $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
-
- // Cache in memory for this request
- self::$http_timestamps[$type] = $timestamp;
-
- return $timestamp;
- }
-
- /**
- * Update HTTP cache timestamp (marks content as modified)
- *
- * @param string $type Content type
- * @return int The new timestamp
- */
- public static function updateTimestamp(string $type): int
- {
- $type = jvbNoBase($type);
- $timestamp = time();
-
- // Store (Redis or transient - wp_cache handles it)
- wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
-
- // Update request cache
- self::$http_timestamps[$type] = $timestamp;
-
- do_action('jvb_http_timestamp_updated', $type, $timestamp);
-
- return $timestamp;
- }
-
- /**
- * Invalidate cache for a content type
- *
- * @param string $type Content type to invalidate
- * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
- * @param bool $flush_connections Whether to flush connected caches
- * @return void
- */
- public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
- {
- $type = jvbNoBase($type);
-
- // Update HTTP timestamp
- self::updateTimestamp($type);
-
- // If specific keys provided, only delete those
- if ($specific_keys !== null) {
- $instance = self::for($type);
- if (is_array($specific_keys)) {
- foreach ($specific_keys as $key) {
- $instance->delete($key);
- }
- } else {
- $instance->delete($specific_keys);
- }
- } else {
- // Flush the entire group
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($type);
- } else {
- wp_cache_flush();
- }
- }
-
- // Flush connected caches
- if ($flush_connections) {
- self::for($type)->connections();
- }
-
- do_action('jvb_cache_invalidated', $type);
- }
-
- /**
- * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
- * Use this when you want surgical cache invalidation
- *
- * @param string $type Content type
- * @param string|array $keys Key(s) to delete
- * @return void
- */
- public static function invalidateKeys(string $type, string|array $keys): void
- {
- $instance = self::for($type);
-
- if (is_array($keys)) {
- foreach ($keys as $key) {
- $instance->delete($key);
- }
- } else {
- $instance->delete($keys);
- }
- }
-
- /**
- * Fluent instance method to invalidate this cache type
- *
- * @param string|array|null $specific_keys Optional specific key(s)
- * @param bool $flush_connections Whether to flush connected caches
- * @return self For chaining
- */
- public function invalidate($specific_keys = null, bool $flush_connections = true): self
- {
- self::invalidateAll($this->group, $specific_keys, $flush_connections);
- return $this;
- }
-
- /**
- * Get the HTTP timestamp for this instance's type
- *
- * @return int
- */
- public function timestamp(): int
- {
- return self::getTimestamp($this->group);
- }
-
- /**
- * Update the HTTP timestamp for this instance's type
- *
- * @return self For chaining
- */
- public function touch(): self
- {
- self::updateTimestamp($this->group);
- return $this;
- }
-
- /**
- * Get a value from the cache
- * @param string|array $key The key to look up (auto-generates key from array of key=>values)
- * @param string|null $group The group to get from. Defaults to current group
- * @return mixed
- */
- public function get(string|array $key, ?string $group = null): mixed
- {
- $group = $group ?: $this->group;
- $key = $this->normalizeKey($key);
- $cache_key = $this->buildKey($key);
-
- $value = wp_cache_get($cache_key, $group);
-
- // Fallback to transient if no external object cache
- if ($value === false && !wp_using_ext_object_cache()) {
- $value = get_transient($group . '_' . $cache_key);
- }
-
- return $value;
- }
-
- /**
- * Store a value in cache
- * @param string|array $key The key to look up (auto-generates key from array of key=>values)
- * @param mixed $value The Value to set
- * @param int|null $ttl The ttl (defaults to current set ttl)
- * @param string|null $group The group to add cache to (defaults to current group))
- * @return bool
- */
- public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool
- {
- $ttl = $ttl ?: $this->cache_ttl;
- $group = $group ?: $this->group;
- $key = $this->normalizeKey($key);
- $cache_key = $this->buildKey($key);
-
- self::updateTimestamp($this->group);
-
- // Try object cache first
- $result = wp_cache_set($cache_key, $value, $group, $ttl);
-
- // If no external object cache, also store in transient for persistence
- if (!wp_using_ext_object_cache()) {
- set_transient($group . '_' . $cache_key, $value, $ttl);
- }
-
- return $result;
- }
- /**
- * Delete a cached value
- * @param string|array $key The key to look up (auto-generates key from array of key=>values)
- * @param string|null $group The group to delete from (defaults to current group)
- * @return bool
- */
- public function delete(string|array $key, ?string $group = null): bool
- {
- $group = $group ?: $this->group;
- $key = $this->normalizeKey($key);
- $cache_key = $this->buildKey($key);
-
- $result = wp_cache_delete($cache_key, $group);
-
- // Also delete transient if no external object cache
- if (!wp_using_ext_object_cache()) {
- delete_transient($group . '_' . $cache_key);
- }
-
- return $result;
- }
-
-
- /**
- * Clear all cache for this group
- * @return bool
- */
- public function clear(): bool
- {
- try {
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($this->group);
- }
-
- // Clear transients for this group if no external object cache
- if (!wp_using_ext_object_cache()) {
- $this->clearGroupTransients();
- }
-
- self::updateTimestamp($this->group);
- return true;
- } catch (\Exception $e) {
- return false;
- }
- }
-
- /**
- * Clear all transients for this cache group
- */
- private function clearGroupTransients(): void
- {
- global $wpdb;
-
- $pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
- $timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
-
- $wpdb->query(
- $wpdb->prepare(
- "DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
- $pattern,
- $timeout_pattern
- )
- );
- }
-
- /**
- * Helper to generateKey from array if applicable
- * @param string|array $key
- * @return string
- */
- private function normalizeKey(string|array $key): string
- {
- return is_array($key) ? $this->generateKey($key) : $key;
- }
-
- /**
- * Generate a cache key from parameters
- * @param array $params An array of key/values that differentiates this cache item from others
- * @return string
- */
- public function generateKey(array $params): string
- {
- // Sort params for consistent key generation
- ksort($params);
- return md5(serialize($params));
- }
-
- /**
- * The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found.
- * @param string|array $key The key to look up (auto-generates key from array of key=>values)
- * @param callable $callback The callback to generate the value for this key
- * @param int|null $ttl The time-to-live for the cache. Defaults to constructor
- * @param string|null $group The group to save cache to. Defaults to constructor
- * @return mixed
- */
- public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed
- {
- $group = $group ?: $this->group;
- $ttl = $ttl ?: $this->cache_ttl;
- $key = $this->normalizeKey($key);
-
- $value = $this->get($key, $group);
-
- if ($value === false) {
- $value = $callback();
- if ($value !== false && $value !== null) {
- $this->set($key, $value, $ttl, $group);
- }
- }
-
- return $value;
- }
-
- /**
- * Build the cache key
- * @param string $key
- * @return string
- */
- private function buildKey(string $key): string
- {
- return $this->prefix . $key;
- }
-
- /**
- * Get instance group name (for debugging)
- */
- public function getGroup(): string
- {
- return $this->group;
- }
-
-
- /***************************************************************************
- * CONNECTIONS
- * Connect to other caches by instantiating and defining connection
- * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
- * Where: $type = content / taxonomy / user
- * $scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
- * $keyPattern = ??
- ***************************************************************************/
- /**
- * Define a connection between cache groups
- * Connected caches will have their ID-based keys deleted when this cache invalidates
- *
- * @param string $type Grand overview ('post', 'taxonomy', 'user')
- * @param string $scope Type-specific constant, user role, or 'id'
- * @return self For chaining
- */
- public function connectTo(string $type, string $scope = 'id'): self
- {
- //TODO: Handle connect to where $type === 'all'
- $connections = self::getConnections();
-
- if (!isset($connections[$this->group])) {
- $connections[$this->group] = [];
- }
-
- $new_connection = [
- 'parent' => $type,
- 'scope' => $scope
- ];
-
- // Check if already exists
- foreach ($connections[$this->group] as $existing) {
- if ($existing === $new_connection) {
- return $this;
- }
- }
-
- $connections[$this->group][] = $new_connection;
- update_option(self::CONNECTIONS_OPTION, $connections, false);
- self::$connections_cache = $connections;
-
- return $this;
- }
-
- /**
- * Get all registered connections (cached for performance)
- *
- * @param bool $refresh Force refresh from database
- * @return array
- */
- private static function getConnections(bool $refresh = false): array
- {
- if (self::$connections_cache === null || $refresh) {
- self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
- }
-
- return self::$connections_cache;
- }
-
- /**
- * Flush all caches connected to this one
- *
- * @return self For chaining
- */
- public function connections(): self
- {
- $all_connections = self::getConnections();
-
- foreach ($all_connections as $cache_group => $connections) {
- foreach ($connections as $conn) {
- if ($this->matchesConnection($conn)) {
- $this->flushConnection($cache_group, $conn);
- }
- }
- }
-
- return $this;
- }
-
- /**
- * Check if this cache group matches a connection definition
- */
- private function matchesConnection(array $connection): bool
- {
- $parent = $connection['parent'] ?? '';
- $scope = $connection['scope'] ?? 'id';
-
- // Grand overview match
- if ($this->group === $parent) {
- return true;
- }
-
- // Type-specific match
- if ($scope !== 'id') {
- if ($this->group === jvbNoBase($scope)) {
- return true;
- }
-
- // Check constants
- if ($parent === 'post' && defined('JVB_CONTENT')) {
- return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
- }
-
- if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
- return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
- }
- }
-
- // ID-specific match: 'user_123' matches 'user' + 'id'
- if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
- return true;
- }
-
- return false;
- }
-
- /**
- * Flush a connected cache group
- * For ID-specific connections, deletes the specific ID key
- * For type/overview connections, flushes entire group
- */
- private function flushConnection(string $cache_group, array $connection): void
- {
- $scope = $connection['scope'] ?? 'id';
-
- // ID-specific: delete specific key
- if ($scope === 'id') {
- $id = $this->extractIdFromGroup();
-
- if ($id !== null) {
- self::invalidateKeys($cache_group, $id);
- return;
- }
- }
-
- // Type/overview: flush entire group
- self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
- }
-
- /**
- * Extract ID from group name like 'user_123' -> '123'
- *
- * @return string|null
- */
- private function extractIdFromGroup(): ?string
- {
- if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
- return $matches[1];
- }
-
- return null;
- }
-
- /**
- * Register multiple connections at once
- */
- public static function registerConnections(array $connections): void
- {
- $existing = self::getConnections();
- $changed = false;
-
- foreach ($connections as $cache_group => $configs) {
- if (!isset($existing[$cache_group])) {
- $existing[$cache_group] = [];
- }
-
- foreach ($configs as $config) {
- $duplicate = false;
- foreach ($existing[$cache_group] as $existing_config) {
- if ($existing_config === $config) {
- $duplicate = true;
- break;
- }
- }
-
- if (!$duplicate) {
- $existing[$cache_group][] = $config;
- $changed = true;
- }
- }
- }
-
- if ($changed) {
- update_option(self::CONNECTIONS_OPTION, $existing, false);
- self::$connections_cache = $existing;
- }
- }
-
- /**
- * Handle post save/update
- */
- public static function onPostSave(int $post_id, \WP_Post $post): void
- {
- // Skip revisions and autosaves
- if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
- return;
- }
-
- $post_type = jvbNoBase($post->post_type);
-
- // Invalidate post type cache
- self::invalidateAll($post_type);
-
- // Invalidate specific post cache
- self::invalidateAll($post_id);
- // Clear WordPress core post object cache
- clean_post_cache($post_id);
- }
-
- /**
- * Handle post deletion
- */
- public static function onPostDelete(int $post_id): void
- {
- $post = get_post($post_id);
- if (!$post) {
- return;
- }
-
- $post_type = jvbNoBase($post->post_type);
-
- self::invalidateAll($post_type);
- self::invalidateAll($post_id);
- // Clear WordPress core post object cache
- clean_post_cache($post_id);
- }
-
- /**
- * Handle term save/update
- */
- public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
- {
- // Clear WordPress core term cache
- clean_term_cache($term_id, $taxonomy);
- $taxonomy = jvbNoBase($taxonomy);
-
- // Invalidate taxonomy cache
- self::invalidateAll($taxonomy);
-
- // Invalidate specific term cache
- self::invalidateAll($term_id);
- }
-
- /**
- * Handle term deletion
- */
- public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
- {
- // Clear WordPress core term cache
- clean_term_cache($term_id, $taxonomy);
- $taxonomy = jvbNoBase($taxonomy);
-
- self::invalidateAll($taxonomy);
- self::invalidateAll($term_id);
- }
-
- /**
- * Handle user update
- */
- public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
- {
- // Invalidate user-specific cache
- self::invalidateAll($user_id);
-
- // Invalidate user role caches if roles changed
- if ($old_user_data) {
- $user = get_userdata($user_id);
- if ($user && $user->roles !== $old_user_data->roles) {
- foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
- self::invalidateAll($role);
- }
- }
- }
- // Clear WordPress core user cache
- clean_user_cache($user_id);
- }
-
- /**
- * Handle user deletion
- */
- public static function onUserDelete(int $user_id): void
- {
- self::invalidateAll($user_id);
- // Clear WordPress core user cache
- clean_user_cache($user_id);
- }
-
- /**
- * Handle post meta updates
- */
- public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $post = get_post($post_id);
- if (!$post) {
- return;
- }
-
- self::onPostSave($post_id, $post);
- }
- public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $post = get_post($post_id);
- if (!$post) {
- return;
- }
-
- self::onPostSave($post_id, $post);
- }
-
- /**
- * Handle term meta updates
- */
- public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $term = get_term($term_id);
- if (!$term || is_wp_error($term)) {
- return;
- }
-
- self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
- }
-
- public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $term = get_term($term_id);
- if (!$term || is_wp_error($term)) {
- return;
- }
-
- self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
- }
-
- /**
- * Handle user meta updates
- */
- public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $user = get_userdata($user_id);
- if (!$user) {
- return;
- }
-
- self::onUserUpdate($user_id, null);
- }
-
- public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
- {
- if (!str_starts_with($meta_key, BASE)) {
- return;
- }
-
- $user = get_userdata($user_id);
- if (!$user) {
- return;
- }
-
- self::onUserUpdate($user_id, null);
- }
-}
diff --git a/inc/managers/CacheManagerOld.php b/inc/managers/CacheManagerOld.php
index 38297f6..d1cf099 100644
--- a/inc/managers/CacheManagerOld.php
+++ b/inc/managers/CacheManagerOld.php
@@ -2,30 +2,296 @@
namespace JVBase\managers;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
-class CacheManagerOld
+/**
+ * Manages HTTP cache timestamps and relationship-based invalidation
+ *
+ * Data caching: Use wrapper methods or wp_cache_get/set directly
+ * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
+ */
+class CacheManager
{
- private string $prefix = 'jvb_';
+ private const CONNECTIONS_OPTION = BASE.'cache_connections';
+ private static ?array $connections_cache = null; // Cache in memory
+ private string $prefix = BASE;
private string $group;
private int $cache_ttl;
private static ?bool $use_object_cache = null;
+ private static array $instances = []; // Cache instances per type
+ private static array $http_timestamps = []; // Request-level memory cache
+ private static ?CacheManager $singleton = null;
/**
- * @param string|null $group The group name for this cache instance
- * @param int|null $ttl The default ttl for this instance
+ * Private constructor - use for() factory method instead
*/
- public function __construct(?string $group = null, ?int $ttl = null)
+ private function __construct(string $group, ?int $ttl = null)
{
- $this->group = $group ?: 'jvb_default';
+ $this->group = jvbNoBase($group);
$this->cache_ttl = $ttl ?: 3600;
- // Check if Redis/Memcached is available
if (is_null(static::$use_object_cache)) {
- static::$use_object_cache = !is_null(wp_using_ext_object_cache());
-// error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+ static::$use_object_cache = wp_using_ext_object_cache();
}
+
+ add_action('init', [$this, 'registerHooks']);
+ }
+
+ /**
+ * Get singleton instance (for general cache operations)
+ * For type-specific operations, use for() or forUser() instead
+ */
+ public static function getInstance(): self
+ {
+ if (self::$singleton === null) {
+ self::$singleton = new self('global', HOUR_IN_SECONDS);
+ }
+ return self::$singleton;
+ }
+
+ /**
+ * Get all cache connections (public accessor)
+ *
+ * @return array Array of cache group connections
+ */
+ public static function getAllConnections(): array
+ {
+ return self::getConnections();
+ }
+
+ /**
+ * Get all registered cache groups
+ *
+ * @return array List of cache group names
+ */
+ public static function getAllGroups(): array
+ {
+ $connections = self::getConnections();
+ return array_keys($connections);
+ }
+ /**
+ * Register WordPress hooks for automatic cache invalidation
+ * Call this once during plugin initialization
+ */
+ public static function registerHooks(): void
+ {
+ // Post updates (all post types including core)
+ add_action('save_post', [self::class, 'onPostSave'], 10, 2);
+ add_action('delete_post', [self::class, 'onPostDelete']);
+ // Meta updates (will catch MetaManager updates)
+ add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+ add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+ add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
+ // transition_post_status?
+
+ // Term updates (all taxonomies)
+ add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
+ add_action('create_term', [self::class, 'onTermSave'], 10, 3);
+ add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+ // Term meta updates
+ add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+ add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+ add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
+
+ // User updates
+ add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
+ add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
+ add_action('deleted_user', [self::class, 'onUserDelete']);
+
+ // User meta updates
+ add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+ add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+ add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
+ }
+
+ /**
+ * Get or create a cache manager instance for a content type
+ *
+ * @param string $type Content type (tattoo, style, etc.)
+ * @param int|null $ttl Optional TTL override
+ * @return self Fluent interface
+ */
+ public static function for(string $type, ?int $ttl = null): self
+ {
+ $type = jvbNoBase($type);
+ $key = $type . ($ttl ? "_{$ttl}" : '');
+
+ if (!isset(self::$instances[$key])) {
+ self::$instances[$key] = new self($type, $ttl);
+ }
+
+ return self::$instances[$key];
+ }
+
+ /**
+ * Get cache manager for a specific user
+ * Each user gets their own cache group for complete isolation
+ *
+ * @param int $user_id User ID
+ * @param int|null $ttl Optional TTL
+ * @return self
+ */
+ public static function forUser(int $user_id, ?int $ttl = null): self
+ {
+ return self::for("user_{$user_id}", $ttl);
+ }
+
+ /**
+ * Get HTTP cache timestamp for content type(s)
+ * Used for ETag and Last-Modified header generation
+ *
+ * @param string|array $types Single type or array of types
+ * @return int Latest timestamp (Unix time)
+ */
+ public static function getTimestamp(string|array $types): int
+ {
+ // Multiple types - return latest
+ if (is_array($types)) {
+ $latest = 0;
+ foreach ($types as $type) {
+ $timestamp = self::getTimestamp($type);
+ if ($timestamp > $latest) {
+ $latest = $timestamp;
+ }
+ }
+ return $latest ?: time();
+ }
+
+ $type = jvbNoBase($types);
+
+ // Check request-level cache
+ if (isset(self::$http_timestamps[$type])) {
+ return self::$http_timestamps[$type];
+ }
+
+ // Load from cache (Redis or transient - wp_cache handles it)
+ $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
+
+ // Cache in memory for this request
+ self::$http_timestamps[$type] = $timestamp;
+
+ return $timestamp;
+ }
+
+ /**
+ * Update HTTP cache timestamp (marks content as modified)
+ *
+ * @param string $type Content type
+ * @return int The new timestamp
+ */
+ public static function updateTimestamp(string $type): int
+ {
+ $type = jvbNoBase($type);
+ $timestamp = time();
+
+ // Store (Redis or transient - wp_cache handles it)
+ wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
+
+ // Update request cache
+ self::$http_timestamps[$type] = $timestamp;
+
+ do_action('jvb_http_timestamp_updated', $type, $timestamp);
+
+ return $timestamp;
+ }
+
+ /**
+ * Invalidate cache for a content type
+ *
+ * @param string $type Content type to invalidate
+ * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+ * @param bool $flush_connections Whether to flush connected caches
+ * @return void
+ */
+ public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
+ {
+ $type = jvbNoBase($type);
+
+ // Update HTTP timestamp
+ self::updateTimestamp($type);
+
+ // If specific keys provided, only delete those
+ if ($specific_keys !== null) {
+ $instance = self::for($type);
+ if (is_array($specific_keys)) {
+ foreach ($specific_keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($specific_keys);
+ }
+ } else {
+ // Flush the entire group
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($type);
+ } else {
+ wp_cache_flush();
+ }
+ }
+
+ // Flush connected caches
+ if ($flush_connections) {
+ self::for($type)->connections();
+ }
+
+ do_action('jvb_cache_invalidated', $type);
+ }
+
+ /**
+ * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
+ * Use this when you want surgical cache invalidation
+ *
+ * @param string $type Content type
+ * @param string|array $keys Key(s) to delete
+ * @return void
+ */
+ public static function invalidateKeys(string $type, string|array $keys): void
+ {
+ $instance = self::for($type);
+
+ if (is_array($keys)) {
+ foreach ($keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($keys);
+ }
+ }
+
+ /**
+ * Fluent instance method to invalidate this cache type
+ *
+ * @param string|array|null $specific_keys Optional specific key(s)
+ * @param bool $flush_connections Whether to flush connected caches
+ * @return self For chaining
+ */
+ public function invalidate($specific_keys = null, bool $flush_connections = true): self
+ {
+ self::invalidateAll($this->group, $specific_keys, $flush_connections);
+ return $this;
+ }
+
+ /**
+ * Get the HTTP timestamp for this instance's type
+ *
+ * @return int
+ */
+ public function timestamp(): int
+ {
+ return self::getTimestamp($this->group);
+ }
+
+ /**
+ * Update the HTTP timestamp for this instance's type
+ *
+ * @return self For chaining
+ */
+ public function touch(): self
+ {
+ self::updateTimestamp($this->group);
+ return $this;
}
/**
@@ -37,39 +303,17 @@
public function get(string|array $key, ?string $group = null): mixed
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
+ $value = wp_cache_get($cache_key, $group);
+
+ // Fallback to transient if no external object cache
+ if ($value === false && !wp_using_ext_object_cache()) {
+ $value = get_transient($group . '_' . $cache_key);
}
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
- }
-
- public function getTimestamp(string|array $key, ?string $group = null): mixed
- {
- $group = $group ?: $this->group;
-
- $key = $this->normalizeKey($key);
-
- $cache_key = $this->buildKey($key);
-
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
- }
-
- return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+ return $value;
}
/**
@@ -84,25 +328,21 @@
{
$ttl = $ttl ?: $this->cache_ttl;
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- $temp = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
- $value = $temp;
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_set($cache_key, $value, $group, $ttl);
- } else {
- // Fallback to transients
- return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
+ self::updateTimestamp($this->group);
+
+ // Try object cache first
+ $result = wp_cache_set($cache_key, $value, $group, $ttl);
+
+ // If no external object cache, also store in transient for persistence
+ if (!wp_using_ext_object_cache()) {
+ set_transient($group . '_' . $cache_key, $value, $ttl);
}
- }
+ return $result;
+ }
/**
* Delete a cached value
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
@@ -112,147 +352,60 @@
public function delete(string|array $key, ?string $group = null): bool
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_delete($cache_key, $group);
- } else {
- return delete_transient($this->getTransientKey($cache_key, $group));
+ $result = wp_cache_delete($cache_key, $group);
+
+ // Also delete transient if no external object cache
+ if (!wp_using_ext_object_cache()) {
+ delete_transient($group . '_' . $cache_key);
}
+
+ return $result;
}
- public function clear():bool
+
+ /**
+ * Clear all cache for this group
+ * @return bool
+ */
+ public function clear(): bool
{
try {
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($this->group);
- return true;
- }
- return false;
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- }
- return true;
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($this->group);
}
- } catch (\Exception $e) {
- } finally {
+ // Clear transients for this group if no external object cache
+ if (!wp_using_ext_object_cache()) {
+ $this->clearGroupTransients();
+ }
+
+ self::updateTimestamp($this->group);
+ return true;
+ } catch (\Exception $e) {
return false;
}
}
/**
- * Alias for delete() for backwards compatibility
- * @param string $key The key to look up (auto-generates key from array of key=>values)
- * @param string|null $group The group to delete from (defaults to current group))
- * @return void
+ * Clear all transients for this cache group
*/
- public function invalidate(string $key, ?string $group = null): void
+ private function clearGroupTransients(): void
{
- $this->delete($key, $group);
- }
+ global $wpdb;
- /**
- * Clear all cache entries for a group
- * @param string $group The group to clear
- * @return bool
- */
- public static function invalidateGroup(string $group): bool
- {
- $group = jvbNoBase($group);
+ $pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
+ $timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
- if (wp_using_ext_object_cache()) {
- // With Redis/Memcached, use native group flush
- if (function_exists('wp_cache_flush_group')) {
- return wp_cache_flush_group($group);
- } else {
- // Fallback for older WP versions - flush everything (not ideal)
- return wp_cache_flush();
- }
- } else {
- // For transients, we need to delete them from database
- global $wpdb;
-
- $prefix = self::getTransientPrefix($group);
-
- // Delete transients and their timeouts
- $sql = "DELETE FROM {$wpdb->options}
- WHERE option_name LIKE %s
- OR option_name LIKE %s";
-
- $result = $wpdb->query($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '_transient_timeout_' . $prefix . '%'
- ));
-
- return $result !== false;
- }
- }
-
- /**
- * Clear cache entries by pattern (only works efficiently with Redis)
- * @param string $pattern
- * @return int
- */
- public function clearPattern(string $pattern): int
- {
- $count = 0;
-
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($this->group);
- return $count;
- }
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '%' . $pattern . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- $count++;
- }
- }
-
- return $count;
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
+ $pattern,
+ $timeout_pattern
+ )
+ );
}
/**
@@ -289,22 +442,18 @@
{
$group = $group ?: $this->group;
$ttl = $ttl ?: $this->cache_ttl;
-
$key = $this->normalizeKey($key);
$value = $this->get($key, $group);
+
if ($value === false) {
$value = $callback();
- if ($value !== false) {
- $value = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
+ if ($value !== false && $value !== null) {
$this->set($key, $value, $ttl, $group);
}
}
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+ return $value;
}
/**
@@ -318,59 +467,390 @@
}
/**
- * Get transient key for fallback mode
- * @param string $key
- * @param string $group
- * @return string
+ * Get instance group name (for debugging)
*/
- private function getTransientKey(string $key, string $group): string
+ public function getGroup(): string
{
- // Transients have a 172 character limit
- $full_key = $group . '_' . $key;
+ return $this->group;
+ }
- if (strlen($full_key) > 160) {
- // Use hash for long keys, but keep group prefix for clearPattern()
- return substr($group, 0, 20) . '_' . md5($full_key);
+
+ /***************************************************************************
+ * CONNECTIONS
+ * Connect to other caches by instantiating and defining connection
+ * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
+ * Where: $type = content / taxonomy / user
+ * $scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
+ * $keyPattern = ??
+ ***************************************************************************/
+ /**
+ * Define a connection between cache groups
+ * Connected caches will have their ID-based keys deleted when this cache invalidates
+ *
+ * @param string $type Grand overview ('post', 'taxonomy', 'user')
+ * @param string $scope Type-specific constant, user role, or 'id'
+ * @return self For chaining
+ */
+ public function connectTo(string $type, string $scope = 'id'): self
+ {
+ //TODO: Handle connect to where $type === 'all'
+ $connections = self::getConnections();
+
+ if (!isset($connections[$this->group])) {
+ $connections[$this->group] = [];
}
- return $full_key;
- }
+ $new_connection = [
+ 'parent' => $type,
+ 'scope' => $scope
+ ];
- /**
- * Get transient prefix for a group
- */
- private static function getTransientPrefix(string $group): string
- {
- return $group . '_jvb_';
- }
-
- /**
- * Check if using object cache
- */
- public function isUsingObjectCache(): bool
- {
- return static::$use_object_cache;
- }
-
-
- /**
- * Cleanup expired transients (maintenance method for non-Redis environments)
- */
- public static function cleanupExpiredTransients(): int
- {
- if (wp_using_ext_object_cache()) {
- return 0; // Not needed with Redis
+ // Check if already exists
+ foreach ($connections[$this->group] as $existing) {
+ if ($existing === $new_connection) {
+ return $this;
+ }
}
- global $wpdb;
+ $connections[$this->group][] = $new_connection;
+ update_option(self::CONNECTIONS_OPTION, $connections, false);
+ self::$connections_cache = $connections;
- // Delete expired transients
- $sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
- WHERE a.option_name LIKE '_transient_%'
- AND a.option_name NOT LIKE '_transient_timeout_%'
- AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
- AND b.option_value < %d";
+ return $this;
+ }
- return $wpdb->query($wpdb->prepare($sql, time()));
+ /**
+ * Get all registered connections (cached for performance)
+ *
+ * @param bool $refresh Force refresh from database
+ * @return array
+ */
+ private static function getConnections(bool $refresh = false): array
+ {
+ if (self::$connections_cache === null || $refresh) {
+ self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
+ }
+
+ return self::$connections_cache;
+ }
+
+ /**
+ * Flush all caches connected to this one
+ *
+ * @return self For chaining
+ */
+ public function connections(): self
+ {
+ $all_connections = self::getConnections();
+
+ foreach ($all_connections as $cache_group => $connections) {
+ foreach ($connections as $conn) {
+ if ($this->matchesConnection($conn)) {
+ $this->flushConnection($cache_group, $conn);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Check if this cache group matches a connection definition
+ */
+ private function matchesConnection(array $connection): bool
+ {
+ $parent = $connection['parent'] ?? '';
+ $scope = $connection['scope'] ?? 'id';
+
+ // Grand overview match
+ if ($this->group === $parent) {
+ return true;
+ }
+
+ // Type-specific match
+ if ($scope !== 'id') {
+ if ($this->group === jvbNoBase($scope)) {
+ return true;
+ }
+
+ // Check constants
+ if ($parent === 'post' && defined('JVB_CONTENT')) {
+ return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
+ }
+
+ if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
+ return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
+ }
+ }
+
+ // ID-specific match: 'user_123' matches 'user' + 'id'
+ if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Flush a connected cache group
+ * For ID-specific connections, deletes the specific ID key
+ * For type/overview connections, flushes entire group
+ */
+ private function flushConnection(string $cache_group, array $connection): void
+ {
+ $scope = $connection['scope'] ?? 'id';
+
+ // ID-specific: delete specific key
+ if ($scope === 'id') {
+ $id = $this->extractIdFromGroup();
+
+ if ($id !== null) {
+ self::invalidateKeys($cache_group, $id);
+ return;
+ }
+ }
+
+ // Type/overview: flush entire group
+ self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
+ }
+
+ /**
+ * Extract ID from group name like 'user_123' -> '123'
+ *
+ * @return string|null
+ */
+ private function extractIdFromGroup(): ?string
+ {
+ if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Register multiple connections at once
+ */
+ public static function registerConnections(array $connections): void
+ {
+ $existing = self::getConnections();
+ $changed = false;
+
+ foreach ($connections as $cache_group => $configs) {
+ if (!isset($existing[$cache_group])) {
+ $existing[$cache_group] = [];
+ }
+
+ foreach ($configs as $config) {
+ $duplicate = false;
+ foreach ($existing[$cache_group] as $existing_config) {
+ if ($existing_config === $config) {
+ $duplicate = true;
+ break;
+ }
+ }
+
+ if (!$duplicate) {
+ $existing[$cache_group][] = $config;
+ $changed = true;
+ }
+ }
+ }
+
+ if ($changed) {
+ update_option(self::CONNECTIONS_OPTION, $existing, false);
+ self::$connections_cache = $existing;
+ }
+ }
+
+ /**
+ * Handle post save/update
+ */
+ public static function onPostSave(int $post_id, \WP_Post $post): void
+ {
+ // Skip revisions and autosaves
+ if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
+ return;
+ }
+
+ $post_type = jvbNoBase($post->post_type);
+
+ // Invalidate post type cache
+ self::invalidateAll($post_type);
+
+ // Invalidate specific post cache
+ self::invalidateAll($post_id);
+ // Clear WordPress core post object cache
+ clean_post_cache($post_id);
+ }
+
+ /**
+ * Handle post deletion
+ */
+ public static function onPostDelete(int $post_id): void
+ {
+ $post = get_post($post_id);
+ if (!$post) {
+ return;
+ }
+
+ $post_type = jvbNoBase($post->post_type);
+
+ self::invalidateAll($post_type);
+ self::invalidateAll($post_id);
+ // Clear WordPress core post object cache
+ clean_post_cache($post_id);
+ }
+
+ /**
+ * Handle term save/update
+ */
+ public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
+ {
+ // Clear WordPress core term cache
+ clean_term_cache($term_id, $taxonomy);
+ $taxonomy = jvbNoBase($taxonomy);
+
+ // Invalidate taxonomy cache
+ self::invalidateAll($taxonomy);
+
+ // Invalidate specific term cache
+ self::invalidateAll($term_id);
+ }
+
+ /**
+ * Handle term deletion
+ */
+ public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
+ {
+ // Clear WordPress core term cache
+ clean_term_cache($term_id, $taxonomy);
+ $taxonomy = jvbNoBase($taxonomy);
+
+ self::invalidateAll($taxonomy);
+ self::invalidateAll($term_id);
+ }
+
+ /**
+ * Handle user update
+ */
+ public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
+ {
+ // Invalidate user-specific cache
+ self::invalidateAll($user_id);
+
+ // Invalidate user role caches if roles changed
+ if ($old_user_data) {
+ $user = get_userdata($user_id);
+ if ($user && $user->roles !== $old_user_data->roles) {
+ foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
+ self::invalidateAll($role);
+ }
+ }
+ }
+ // Clear WordPress core user cache
+ clean_user_cache($user_id);
+ }
+
+ /**
+ * Handle user deletion
+ */
+ public static function onUserDelete(int $user_id): void
+ {
+ self::invalidateAll($user_id);
+ // Clear WordPress core user cache
+ clean_user_cache($user_id);
+ }
+
+ /**
+ * Handle post meta updates
+ */
+ public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ return;
+ }
+
+ self::onPostSave($post_id, $post);
+ }
+ public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ return;
+ }
+
+ self::onPostSave($post_id, $post);
+ }
+
+ /**
+ * Handle term meta updates
+ */
+ public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $term = get_term($term_id);
+ if (!$term || is_wp_error($term)) {
+ return;
+ }
+
+ self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+ }
+
+ public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $term = get_term($term_id);
+ if (!$term || is_wp_error($term)) {
+ return;
+ }
+
+ self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+ }
+
+ /**
+ * Handle user meta updates
+ */
+ public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $user = get_userdata($user_id);
+ if (!$user) {
+ return;
+ }
+
+ self::onUserUpdate($user_id, null);
+ }
+
+ public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $user = get_userdata($user_id);
+ if (!$user) {
+ return;
+ }
+
+ self::onUserUpdate($user_id, null);
}
}
diff --git a/inc/managers/CloudflareTurnstile.php b/inc/managers/CloudflareTurnstile.php
deleted file mode 100644
index 47b8d4d..0000000
--- a/inc/managers/CloudflareTurnstile.php
+++ /dev/null
@@ -1,218 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use WP_User;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-/**
- * Cloudflare Turnstile Integration for WordPress
- *
- * Adds Turnstile protection to login and registration forms
- */
-
-class CloudflareTurnstile
-{
-
- private string $site_key;
- private string $secret_key;
-
- /**
- * Constructor
- */
- public function __construct()
- {
- return;
- // Set your Cloudflare Turnstile keys here
-
-// $this->site_key = JVB_CLOUDFLARE_SITE_KEY;
-// $this->secret_key = JVB_CLOUDFLARE_SECRET_KEY;
-
- // Add hooks for login and registration forms
- add_action('login_enqueue_scripts', [$this, 'enqueueTurnstileScripts']);
- add_action('login_form', [$this, 'addTurnstileToLogin']);
- add_action('register_form', [$this, 'addTurnstileToRegister']);
-
- // Add verification hooks
- add_filter('authenticate', [$this, 'verifyLoginTurnstile'], 99, 3);
- add_filter('registration_errors', [$this, 'verifyRegisterTurnstile'], 10, 3);
-
- // Add hook for lost password form
- add_action('lostpassword_form', [$this, 'addTurnstileToLogin']);
- add_action('lostpassword_post', [$this, 'verifyLostpasswordTurnstile']);
- }
-
- /**
- * Enqueue Turnstile script
- * @return void
- */
- public function enqueueTurnstileScripts():void
- {
- wp_enqueue_script(
- 'cloudflare-turnstile',
- 'https://challenges.cloudflare.com/turnstile/v0/api.js',
- [],
- null,
- true
- );
- // Add this line to set the async and defer attributes
- wp_script_add_data('cloudflare-turnstile', 'async', true);
- wp_script_add_data('cloudflare-turnstile', 'defer', true);
- }
-
- /**
- * Add Turnstile to login form
- * @return void
- */
- public function addTurnstileToLogin():void
- {
- echo '<div class="cf-turnstile-wrapper" style="margin: 1em 0;">';
- echo '<div class="cf-turnstile" data-sitekey="' . esc_attr($this->site_key) . '" data-theme="light"></div>';
- echo '</div>';
- }
-
- /**
- * Add Turnstile to registration form
- * @return void
- */
- public function addTurnstileToRegister():void
- {
- echo '<div class="cf-turnstile-wrapper" style="margin: 1em 0;">';
- echo '<div class="cf-turnstile" data-sitekey="' . esc_attr($this->site_key) . '" data-theme="light"></div>';
- echo '<style>.register .cf-turnstile-wrapper { clear: both; }</style>';
- echo '</div>';
- }
-
- /**
- * Verify Turnstile token
- * @param $token
- *
- * @return bool
- */
- public function verifyTurnstile($token = null):bool
- {
- // If no token is provided, try to get it from the request
- if (!$token && isset($_POST['cf-turnstile-response'])) {
- $token = $_POST['cf-turnstile-response'];
- }
-
- // If still no token, verification fails
- if (!$token) {
- return false;
- }
-
- $data = [
- 'secret' => $this->secret_key,
- 'response' => $token,
- 'remoteip' => $_SERVER['REMOTE_ADDR']
- ];
-
- $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
- $response = wp_remote_post($url, [
- 'body' => $data,
- 'timeout' => 30
- ]);
-
-
-
- if (is_wp_error($response)) {
- return false;
- }
-
- $body = wp_remote_retrieve_body($response);
- $result = json_decode($body, true);
-
- return isset($result['success']) && $result['success'] === true;
- }
-
- /**
- * Verify login form
- * @param null|WP_User|WP_Error $user
- * @param string $username
- * @param string $password
- *
- * @return WP_Error|WP_User
- */
- public function verifyLoginTurnstile(null|WP_User|WP_Error $user, string $username, string $password):WP_Error|WP_User
- {
- global $_POST;
- // Skip verification if already logged in or if no username/password
- if (is_user_logged_in() || empty($username) || empty($password)) {
- return $user;
- }
-
- // Skip on AJAX requests (for better compatibility with other plugins)
- if (wp_doing_ajax()) {
- return $user;
- }
-
- // If already have an error, just return it
- if (is_wp_error($user)) {
- return $user;
- }
-
- // Check Turnstile
- if (!$this->verifyTurnstile()) {
- return new WP_Error('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
- }
-
- return $user;
- }
-
- /**
- * Verify registration form
- * @param WP_Error $errors
- * @param string $sanitized_user_login
- * @param string $user_email
- *
- * @return WP_Error
- */
- public function verifyRegisterTurnstile(WP_Error $errors, string $sanitized_user_login, string $user_email):WP_Error
- {
- // Skip on AJAX requests (for better compatibility with other plugins)
- if (wp_doing_ajax()) {
- return $errors;
- }
-
- // Check Turnstile
- if (!$this->verifyTurnstile()) {
- $errors->add('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
- }
-
- return $errors;
- }
-
- /**
- * Verify lost password form
- * @param WP_Error $errors
- *
- * @return WP_Error
- */
- public function verifyLostpasswordTurnstile(WP_Error $errors):WP_Error
- {
- // Skip on AJAX requests (for better compatibility with other plugins)
- if (wp_doing_ajax()) {
- return $errors;
- }
-
- // Check if the form was submitted
- if (!isset($_POST['user_login']) || empty($_POST['user_login'])) {
- return $errors ;
- }
-
- // Check Turnstile
- if (!$this->verifyTurnstile()) {
- $redirect_to = isset($_POST['redirect_to']) ? $_POST['redirect_to'] : '';
- $errors = new WP_Error('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
-
- // WP's lost password form handling
- wp_die($errors->get_error_message(), __('Security Check Failed', 'edmonton-ink'), [
- 'response' => 403,
- 'back_link' => wp_lostpassword_url($redirect_to)
- ]);
- }
- return $errors;
- }
-}
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index bc12ee2..5a25ea0 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -17,14 +17,14 @@
class DashboardManager
{
protected WP_User $user;
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $role;
protected string $baseURL;
protected int $userLink;
public function __construct()
{
- $this->cache = CacheManager::for('dashboard', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->connect('user');
add_action('init', [$this, 'registerDashboard']);
if (!$this->isRegistered()) {
add_action('init', [$this, 'buildDashboard']);
@@ -210,6 +210,7 @@
$page = $this->getCurrentPageTitle();
// Check if page exists in allowed pages
$allowedPages = $this->getUserAllowedPages();
+
if (!in_array($page, $allowedPages)) {
error_log("User not allowed to access page: {$page}");
$this->redirectToDashboard();
@@ -353,14 +354,14 @@
// Pass along to the Integrations template handler which knows to check for subpages
$page = 'integrations';
}
- echo $this->renderDashboard($page);
- //TODO: Reenable
-// echo $this->cache->remember(
-// $page,
-// function() use ($page) {
-// return $this->renderDashboard($page);
-// }
-// );
+// echo $this->renderDashboard($page);
+
+ echo $this->cache->remember(
+ $page,
+ function() use ($page) {
+ return $this->renderDashboard($page);
+ }
+ );
return '';
}
@@ -597,8 +598,7 @@
if (!$post) {
return '';
}
-
- return $post->post_title;
+ return html_entity_decode($post->post_title);
}
protected function getCurrentPageSlug():string
{
@@ -1347,15 +1347,14 @@
}
- $cacheKey = "user_pages_{$userID}";
- $pages = $this->cache->get($cacheKey);
- $pages = false;
+ $pages = $this->cache->get($userID);
+
if ($pages === false || JVB_TESTING) {
if (user_can($userID, 'manage_options')) {
// Admin gets all pages as flat array
$pages = $this->getAllDashboardPages();
// Extract just the values (slugs)
- $this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+ $this->cache->set($userID, $pages, WEEK_IN_SECONDS);
return $pages;
}
$roles = array_map('jvbNoBase', $user->roles);
@@ -1477,7 +1476,7 @@
$pages = apply_filters('jvbUserDashboardPages', $pages, $user->roles, $userID);
$pages = array_unique($pages);
- $this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+ $this->cache->set($userID, $pages, WEEK_IN_SECONDS);
}
return $pages;
@@ -1551,20 +1550,4 @@
// Default to edit_{type}s
return 'edit_'.$type.'s';
}
-
- /**
- * Invalidate dashboard page cache for a user or all users
- * Call this when user roles or permissions change
- * @param int|null $userID Specific user to invalidate, null for all
- * @return void
- */
- public function invalidatePagesCache(?int $userID = null):void
- {
- if ($userID !== null) {
- $this->cache->delete("user_pages_{$userID}");
- } else {
- // Invalidate all user caches by invalidating the group
- $this->cache->invalidate();
- }
- }
}
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index d724af6..541ca2c 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -6,6 +6,7 @@
}
use JVBase\registry\PostTypeRegistrar;
+use JVBase\managers\Cache;
use JVBase\utility\Features;
use WP_Block;
use WP_Query;
@@ -18,7 +19,7 @@
protected int $perPage;
protected static string $type = BASE.'for_type';
protected static string $slug = BASE.'for_type_slug';
- protected CacheManager $cache;
+ protected Cache $cache;
public function __construct($perPage = 100)
{
@@ -27,18 +28,15 @@
return;
}
$this->perPage = $perPage;
- $this->cache = CacheManager::for('directory', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('directory', WEEK_IN_SECONDS);
+ $this->cache->connect('post', true)
+ ->connect('taxonomy', true)
+ ->connect('user', true);
+
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
- foreach(['content','taxonomy','user'] as $key) {
- if (array_key_exists($key, $this->directories)) {
- $this->cache->connectTo($key);
- }
- }
-
-
add_action('init', [$this, 'registerDirectories']);
jvb_register_do_once('directories_registered', [$this, 'activate']);
add_action('render_block', [$this, 'renderBlock'], 99999, 3);
@@ -445,7 +443,7 @@
if ( $terms && ! is_wp_error( $terms ) ) {
$term = $terms[0];
$extra[] = [
- 'name' => (get_term_meta( $term->term_id, BASE . 'singular', true ) !== '') ? get_term_meta( $term->term_id, BASE . 'singular', true ) : $term->name,
+ 'name' => (get_term_meta( $term->term_id, BASE . 'singular', true ) !== '') ? get_term_meta( $term->term_id, BASE . 'singular', true ) : html_entity_decode($term->name),
'url' => get_term_link( $term->term_id, $item ),
'id' => $term->term_id,
'type' => $item,
@@ -489,7 +487,7 @@
$list = $this->alphabetizeMe(
$list,
- $term->name,
+ html_entity_decode($term->name),
get_term_link( $term->term_id, jvbCheckBase( $slug ) ),
$term->term_id,
$extra
@@ -580,11 +578,11 @@
$children =$this->renderListChunk($taxonomy, $term->term_id);
$out .= '<li>';
if ($children !== '') {
- $out .= '<details class="term"><summary class="row btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.$term->name.'</a></summary>';
+ $out .= '<details class="term"><summary class="row btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.html_entity_decode($term->name).'">'.$term->name.'</a></summary>';
$out .= $children;
$out .= '</details>';
} else {
- $out .= '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.$term->name.'</a>';
+ $out .= '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.html_entity_decode($term->name).'</a>';
}
$out .= '</li>';
}
diff --git a/inc/managers/IconsManager.php b/inc/managers/IconsManager.php
index 4415bc7..2f66a45 100644
--- a/inc/managers/IconsManager.php
+++ b/inc/managers/IconsManager.php
@@ -18,7 +18,7 @@
// Instance-specific properties
protected string $source;
protected array $icons = []; // Icons for THIS source [style => [names]]
- protected CacheManager $cache;
+ protected Cache $cache;
protected string $style = 'regular';
protected array $styles = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin'];
protected array $customIcons = []; // Custom icons for THIS source
@@ -42,7 +42,7 @@
private function __construct(string $source)
{
$this->source = $source;
- $this->cache = CacheManager::for('icons_' . $source, WEEK_IN_SECONDS);
+ $this->cache = Cache::for('icons_' . $source, WEEK_IN_SECONDS);
$this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
? JVB_SITE['icons']
: 'regular';
@@ -385,7 +385,7 @@
*/
protected function registerGlobalHooks(): void
{
- add_action('init', [$this, 'checkCSS']);
+ add_action('wp_loaded', [self::class, 'checkCSS']);
}
/**
@@ -410,7 +410,7 @@
wp_enqueue_style('jvb-icons-'.$this->source);
}
- public function checkCSS(): void
+ public static function checkCSS(): void
{
$needsUpdate = get_option(BASE.'icons_needs_update', []);
if (!empty($needsUpdate)) {
@@ -420,7 +420,7 @@
}
}
- protected static function regenerateAllCSS(array $sourcesToUpdate = []): void
+ public static function regenerateAllCSS(array $sourcesToUpdate = []): void
{
error_log('[IconsManager]:regenerateCSS');
$css_dir = JVB_CHILD_DIR.'/assets/css/';
@@ -429,28 +429,41 @@
wp_mkdir_p($css_dir);
}
+ // Load all icons from database option
+ $allIcons = get_option(BASE.'usedIcons', []);
+
// If no specific sources provided, regenerate all
if (empty($sourcesToUpdate)) {
- $sourcesToUpdate = array_fill_keys(array_keys(self::$instances), true);
+ $sourcesToUpdate = array_fill_keys(array_keys($allIcons), true);
}
- // Generate CSS only for sources that need it
- foreach (self::$instances as $source => $instance) {
- if (!isset($sourcesToUpdate[$source])) {
+ // Generate CSS for each source that needs it
+ foreach ($sourcesToUpdate as $source => $needsUpdate) {
+ if (!$needsUpdate || !isset($allIcons[$source])) {
continue;
}
+ // Get or create instance for this source
+ $instance = self::for($source);
+
+ // Temporarily set icons from database
+ $originalIcons = $instance->icons;
+ $instance->icons = $allIcons[$source];
+
$css = $instance->generateIconCSS();
$css_path = $css_dir . $source . '.css';
$instance->archiveCurrentVersion($css);
if (file_put_contents($css_path, $css) !== false) {
- CacheManager::updateTimestamp('icons_' . $source);
+ Cache::touch('icons_' . $source);
error_log("[IconsManager] Updated {$source}.css");
} else {
error_log("[IconsManager] Could not write {$source}.css");
}
+
+ // Restore original icons
+ $instance->icons = $originalIcons;
}
}
@@ -481,7 +494,7 @@
$instance->archiveCurrentVersion($css);
if (file_put_contents($css_path, $css) !== false) {
- CacheManager::updateTimestamp('icons_' . $source);
+ Cache::touch('icons_' . $source);
error_log("[IconsManager] Updated {$source}.css");
} else {
error_log("[IconsManager] Could not write {$source}.css");
@@ -516,6 +529,10 @@
*/
public function get(string $name, array $options = []): string
{
+ if ($name === '') {
+ //No icon requested
+ return '';
+ }
$style = $options['style'] ?? $this->style;
$name = $this->map[$name] ?? $name;
@@ -640,7 +657,7 @@
public function registerStyle(): void
{
- $timestamp = CacheManager::getTimestamp('icons_' . $this->source);
+ $timestamp = Cache::lastModified('icons_' . $this->source);
$handle = 'jvb-icons-' . $this->source;
wp_register_style(
@@ -723,8 +740,7 @@
// Clear cache for all sources
foreach (self::$instances as $source => $instance) {
- $instance->cache->delete('icon_styles_css');
- CacheManager::updateTimestamp('icons_' . $source);
+ $instance->cache->forget('icon_styles_css');
}
}
@@ -778,7 +794,7 @@
if (file_put_contents($css_path, $entry['css']) !== false) {
$this->icons = $entry['iconList'];
$this->saveIcons();
- CacheManager::updateTimestamp('icons_' . $this->source);
+ Cache::touch('icons_' . $this->source);
return true;
}
@@ -799,7 +815,7 @@
}
$needsUpdate[$this->source] = true;
update_option(BASE.'icons_needs_update', $needsUpdate);
- CacheManager::updateTimestamp('icons_' . $this->source);
+ Cache::touch('icons_' . $this->source);
}
public function mergeVersions(array $timestamps): bool
diff --git a/inc/managers/IconsManagerBackup.php b/inc/managers/IconsManagerBackup.php
deleted file mode 100644
index 285ec6b..0000000
--- a/inc/managers/IconsManagerBackup.php
+++ /dev/null
@@ -1,670 +0,0 @@
-<?php
-namespace JVBase\inc\managers;
-
-use JVBase\managers\CacheManager;
-use JVBase\utility\Features;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-class IconsManagerBackup
-{
- protected static ?IconsManagerBackup $instance = null;
- protected CacheManager $cache;
- protected string $style = 'regular';
- protected array $styles = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin'];
- // Custom icons registered via filter
- protected array $customIcons = [];
- protected array $usedIcons = [];
- protected array $map = [];
- protected const MAX_VERSIONS = 5;
-
- /**
- * Get singleton instance
- */
- public static function getInstance(): IconsManagerBackup
- {
- if (self::$instance === null) {
- self::$instance = new self();
- }
- return self::$instance;
- }
- private function __construct()
- {
- $this->cache = CacheManager::for('icons', WEEK_IN_SECONDS);
-
- $this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
- ? JVB_SITE['icons']
- : 'regular';
-
- $this->addMap();
-
- // Allow custom icon registration
- $this->customIcons = apply_filters('jvbRegisterCustomIcons', [
- 'syncing' => JVB_DIR .'/assets/icons/cloud-sync-thin.svg',
- 'alphabetical' => JVB_DIR.'/assets/icons/alphabetical.svg'
- ]);
-
-
- $this->usedIcons = get_option(BASE.'usedIcons', []);
- $this->includeIcons();
- // Track custom icons for CSS generation
- $this->trackCustomIcons();
- // Register hooks only once
- $this->registerHooks();
- }
-
- /**
- * Ensure custom icons are tracked for CSS generation
- */
- protected function trackCustomIcons(): void
- {
- if (empty($this->customIcons)) {
- return;
- }
-
- foreach ($this->customIcons as $name => $path) {
- $this->trackIconUsage($name, $this->style);
- }
- }
-
- /**
- * Include icons via filter (for JS usage, etc.)
- */
- protected function includeIcons():void
- {
- $icons = get_option(BASE.'includeIcons');
-
- if (!$icons) {
- $icons = [
- 'check-circle',
- 'close-circle',
- 'cloud-slash',
- 'exclamation-mark',
- 'cloud-arrow-down',
- 'cloud-arrow-up',
- 'cloud-check',
- 'cloud-slash',
- 'cloud-warning',
- 'syncing',
- 'cloud-x',
- 'arrows-clockwise',
- 'share-fat',
- 'trash',
- 'star',
- ['name' => 'star-half', 'style' => 'fill'],
- ['name' => 'star', 'style' => 'fill'],
- //FORMATTING
- 'copy',
- 'paragraph',
- 'text-h-one',
- 'text-h-two',
- 'text-h-three',
- 'text-h-four',
- 'text-h-five',
- 'text-h-six',
- ['name' =>'text-b', 'style' => 'fill'],
- 'text-italic',
- 'text-underline',
- 'text-strikethrough',
- 'list-dashes',
- 'list-numbers',
- 'text-align-left',
- 'text-align-center',
- 'text-align-right',
-// 'text-align-justify',
- 'link',
- //FILE ICONS
- 'file-pdf',
- 'file-csv',
- 'file-doc',
- 'file-txt',
- 'file-xls',
- ];
-
- $check = [JVB_CONTENT, JVB_TAXONOMY, JVB_USER];
- foreach ($check as $constant) {
- foreach ($constant as $key => $value) {
- if (array_key_exists('icon', $value) && !in_array($value['icon'], $icons)) {
- $icons[] = $value['icon'];
- }
- }
- }
- $icons = apply_filters('jvbIncludeIcons', $icons);
- $icons = $this->maybePrefixIcons($icons);
- update_option(BASE.'includeIcons', $icons);
- }
-
- // Ensure icons are in the correct format (handle legacy data)
- if (!$this->isIconsArrayPrefixed($icons)) {
- $icons = $this->maybePrefixIcons($icons);
- update_option(BASE.'includeIcons', $icons);
- }
-
- $additional = apply_filters('jvbIncludeIcons', []);
- if (!empty($additional)) {
- $additional = $this->maybePrefixIcons($additional);
- $merged = $this->mergeUsedIcons($icons, $additional);
-
- if ($icons != $merged) {
- update_option(BASE.'includeIcons', $merged);
- $icons = $merged;
- }
- }
-
- foreach ($icons as $style => $theIcons) {
- foreach($theIcons as $icon) {
- $this->trackIconUsage($icon, $style);
- }
- }
- }
-
- /**
- * Check if icons array is in the prefixed format [style => [icons]]
- */
- protected function isIconsArrayPrefixed(array $icons): bool
- {
- if (empty($icons)) {
- return true;
- }
-
- // Check if first key is a valid style name
- $first_key = array_key_first($icons);
- if (!in_array($first_key, $this->styles)) {
- return false;
- }
-
- // Check if first value is an array
- return is_array($icons[$first_key]);
- }
-
- protected function maybePrefixIcons(array $icons):array
- {
- $out = [];
- foreach ($icons as $icon) {
- if (is_array($icon) && array_key_exists('style', $icon)) {
- if (!array_key_exists($icon['style'], $out)) {
- $out[$icon['style']] = [];
- }
- if (!in_array($icon['name'], $out[$icon['style']])) {
- $out[$icon['style']][] = $icon['name'];
- }
- } elseif(is_array($icon)) {
- $icon = $icon['name'];
- }
- if (!is_array($icon)) {
- if (!array_key_exists($this->style, $out)) {
- $out[$this->style] = [];
- }
- if (!in_array($icon, $out[$this->style])){
- $out[$this->style][] = $icon;
- }
- }
- }
- return $out;
- }
-
- protected function addMap():void
- {
- $map = get_option(BASE.'iconMap');
- if (!$map) {
- $map = [
- 'seo' => 'robot'
- ];
- if (Features::forSite()->has('referrals')){
- $map['referrals'] = 'hand-heart';
- }
- if (Features::forSite()->has('dashboard')){
- $map['dash'] = 'door';
- }
- if (Features::forSite()->has('magicLink')){
- $map['magicLink'] = 'magic-wand';
- }
- if (Features::hasAnyIntegration()) {
- $map['integrations'] = 'plugs-connected';
- }
-
-
- update_option(BASE.'iconMap', $map);
- }
-
- $this->map = apply_filters('jvbMapIcons', $map);
- }
-
- /**
- * Register WordPress hooks
- */
- protected function registerHooks(): void
- {
- add_action('init', [$this, 'includeIcons'], 1);
- add_action('init', [$this, 'checkCSS'], 10);
- add_action('wp_enqueue_scripts', [$this, 'enqueueIconStyles']);
- add_action('admin_enqueue_scripts', [$this, 'enqueueIconStyles']);
- }
-
- public function checkCSS():void
- {
-// update_option(BASE.'icons_needs_update', true);
- if (get_option(BASE.'icons_needs_update', false)) {
- error_log('Regenerating CSS');
- delete_option(BASE.'icons_needs_update');
- $this->regenerateCSS();
- }
- }
-
- protected function regenerateCSS(): void
- {
- error_log('[IconsManager]:regenerateCSS');
- $css_dir = JVB_CHILD_DIR.'/assets/css/';
- if (!file_exists($css_dir)) {
- wp_mkdir_p($css_dir);
- }
-
- // Generate CSS for each source
- foreach ($this->usedIcons as $source => $styles) {
- $css = $this->generateIconCSS($source);
- $css_path = $css_dir . $source . '.css';
-
- $this->archiveCurrentVersion($css, $source);
-
- if (file_put_contents($css_path, $css) !== false) {
- CacheManager::updateTimestamp('icons_' . $source);
- } else {
- error_log("[IconsManager] Could not write {$source}.css");
- }
- }
- }
-
- /**
- * Prevent cloning
- */
- private function __clone() {}
-
- /**
- * Prevent unserialization
- */
- public function __wakeup()
- {
- throw new \Exception("Cannot unserialize singleton");
- }
-
- /**
- * Get an icon element
- *
- * @param string $name Icon name (e.g., 'heart', 'calendar')
- * @param array $options Options array:
- * - 'style' => 'regular'|'bold'|'fill'|etc.
- * - 'label' => 'Accessible label' (for standalone icons)
- * - 'decorative' => true (for icons next to text)
- * - 'class' => 'additional classes'
- * - 'size' => 24 (for custom sizing via inline style)
- * @return string HTML icon element
- */
- public function getIcon(string $name, array $options = []): string
- {
- $style = array_key_exists('style', $options) ? $options['style'] :$this->style;
- $source = $options['source'] ?? 'icons';
- $name = (array_key_exists($name, $this->map)) ? $this->map[$name] : $name;
-
- // Validate icon exists
- if (!$this->iconExists($name, $style)) {
- error_log('[IconsManager] Icon not found: ' . $name);
- return '';
- }
-
-
-
- // Track icon usage
- $this->trackIconUsage($name, $style, $source);
-
- $styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
- // Build classes
- $classes = ['icon', 'icon-' . $name.$styleClass];
- if (!empty($options['class'])) {
- $classes[] = $options['class'];
- }
-
-
- $attrs = ['class="' . esc_attr(implode(' ', $classes)) . '"'];
- $attrs[] = 'aria-hidden="true"';
-
-
-
- return '<i ' . implode(' ', $attrs) . '></i>';
- }
-
- /**
- * Track icon usage for CSS generation
- */
- protected function trackIconUsage(string $name, string $style, string $source = 'icons'): void
- {
- // Initialize source array if needed
- if (!isset($this->usedIcons[$source])) {
- $this->usedIcons[$source] = [];
- }
-
- // Initialize style array if needed
- if (!isset($this->usedIcons[$source][$style])) {
- $this->usedIcons[$source][$style] = [];
- }
-
- // Add icon if not already tracked
- if (!in_array($name, $this->usedIcons[$source][$style])) {
- $this->usedIcons[$source][$style][] = $name;
- $needsUpdate = true;
- }
-
- if ($needsUpdate) {
- $existing = get_option(BASE.'usedIcons', []);
- $merged = $this->mergeUsedIcons($existing, $this->usedIcons);
- update_option(BASE.'usedIcons', $merged);
- update_option(BASE.'icons_needs_update', true);
- $this->cache->delete('icon_styles_css');
- }
- }
-
- /**
- * Check if icon file exists
- */
- protected function iconExists(string $name, ?string $style = null): bool
- {
- if (!$style) {
- $style = $this->style;
- }
- // Check custom icons first
- if (array_key_exists($name, $this->customIcons)) {
- return file_exists($this->customIcons[$name]);
- }
-
- // Check standard icons
- $filepath = $this->buildFilePath($name, $style);
- return file_exists($filepath);
- }
-
- /**
- * Build file path for icon
- */
- protected function buildFilePath(string $name, ?string $style = null): string
- {
- if (!$style) {
- $style = $this->style;
- }
- // Custom icons (absolute path provided)
- if (array_key_exists($name, $this->customIcons)) {
- return $this->customIcons[$name];
- }
-
- // Standard SVG icons in /assets/icons/
- if (str_ends_with($name, '.svg')) {
- return JVB_DIR . '/assets/icons/' . $name;
- }
- $name = ($style === 'regular') ? $name : $name . '-' . $style;
-
- // Phosphor icons with style variants
- return JVB_DIR . '/assets/phosphor-icons/' . $style . '/' . $name . '.svg';
- }
-
- /**
- * Get raw SVG content for CSS mask-image
- */
- protected function getRawSvg(string $name, ?string $style = null): ?string
- {
- if (!$style) {
- $style = $this->style;
- }
- $filepath = $this->buildFilePath($name, $style);
-
- if (!file_exists($filepath)) {
- return null;
- }
-
- $svg = file_get_contents($filepath);
- if ($svg === false) {
- return null;
- }
-
- // Clean up SVG for CSS usage
- $svg = preg_replace("/([\n\t]+)/", ' ', $svg);
- $svg = preg_replace('/>\s*</', '><', $svg);
- $svg = trim($svg);
-
- return $svg;
- }
-
-
- /**
- * Enqueue icon styles via REST endpoint
- */
- public function enqueueIconStyles(): void
- {
- $timestamp = CacheManager::getTimestamp('icons');
-
- wp_enqueue_style(
- 'jvb-icons',
- JVB_CHILD_URL.'assets/css/icons.css',
- [],
- $timestamp
- );
- }
-
- /**
- * Generate CSS from icon list
- */
- protected function generateIconCSS(string $source = 'icons'): string
- {
- $css = '';
-
- if (!isset($this->usedIcons[$source])) {
- return $css;
- }
-
- foreach ($this->usedIcons[$source] as $style => $icons) {
- $styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
- foreach ($icons as $icon) {
- $svg = $this->getEncodedSVG($icon, $style);
- if ($svg !== '') {
- $css .= ".icon-{$icon}{$styleClass}{";
- $css .= "--icon:url('data:image/svg+xml;base64,{$svg}');";
- $css .= "}";
- }
- }
- }
- return $this->minifyCss($css);
- }
-
- protected function mergeUsedIcons(array|bool $oldIcons = true, array|bool $newIcons = true):array
- {
- $set = false;
- if ($oldIcons === true) {
- $oldIcons = $this->usedIcons;
- $set = true;
- }
- if ($newIcons === true) {
- $history = $this->getVersionHistory();
- $newIcons = (count($history) > 0) ? $history[0]['iconList'] : [];
- }
- foreach ($newIcons as $style => $icons) {
- if (!isset($oldIcons[$style])) {
- //Style doesn't exist in previous set, add the whole thing
- $oldIcons[$style] = $icons;
- } else {
- $oldIcons[$style] = array_unique(
- array_merge($oldIcons[$style], $icons)
- );
- }
- }
- if ($set) {
- $this->usedIcons = $oldIcons;
- update_option(BASE.'usedIcons', $oldIcons);
- }
- return $oldIcons;
- }
-
- protected function minifyCSS(string $css): string
- {
- // Remove comments
- $css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css);
- // Remove whitespace
- $css = preg_replace('/\s+/', ' ', $css);
- // Remove spaces around specific characters
- $css = preg_replace('/\s*([:;{}])\s*/', '$1', $css);
-
- return trim($css);
- }
- public function getCSSIcon(string $icon, ?string $style=null):string
- {
- if (!$style) {
- $style = $this->style;
- }
- $svg = $this->getEncodedSVG($icon, $style);
- if ($svg !== '') {
- return "data:image/svg+xml;base64,{$svg}";
- }
- return '';
- }
- public function getEncodedSVG(string $icon, ?string $style = null):string
- {
- if (!$style) {
- $style = $this->style;
- }
- return $this->cache->remember($style.$icon,
- function () use ($icon, $style) {
- $svg = $this->getRawSvg($icon, $style);
- if ($svg) {
- return base64_encode($svg);
- }
- return '';
- });
-
- }
-
- /**
- * Clear icon cache (useful for development/debugging)
- */
- public function clearIconCache(): void
- {
- delete_option(BASE . 'icon_usage_list'); // Clear DB option
- delete_option(BASE.'usedIcons');
- delete_option(BASE.'includeIcons');
- delete_option(BASE.'iconMap');
- $this->cache->delete('icon_styles_css');
- CacheManager::updateTimestamp('icons');
- }
-
- protected function archiveCurrentVersion(string $css, string $source = 'icons'): void
- {
- $history = $this->getVersionHistory($source);
-
- $icon_count = 0;
- if (isset($this->usedIcons[$source])) {
- foreach ($this->usedIcons[$source] as $style => $icons) {
- $icon_count += count($icons);
- }
- }
-
- $newEntry = [
- 'css' => $css,
- 'iconList' => $this->usedIcons[$source] ?? [],
- 'timestamp' => time(),
- 'icon_count' => $icon_count,
- 'size' => strlen($css),
- 'size_formatted' => size_format(strlen($css), 2)
- ];
-
- array_unshift($history, $newEntry);
-
- if (count($history) > self::MAX_VERSIONS) {
- $history = array_slice($history, 0, self::MAX_VERSIONS);
- }
-
- update_option(BASE.'icon_css_history_' . $source, $history);
- }
-
- public function getVersionHistory(string $source = 'icons'): array
- {
- return get_option(BASE.'icon_css_history_' . $source, []);
- }
-
-
- public function restoreVersion(int $timestamp): bool
- {
- $history = $this->getVersionHistory();
-
- foreach ($history as $entry) {
- if ($entry['timestamp'] === $timestamp) {
- $css_path = JVB_DIR . '/assets/css/icons.css';
-
- // Archive current before restoring
- $current_css = file_get_contents($css_path);
- if ($current_css !== false) {
- $this->archiveCurrentVersion($current_css);
- }
-
- // Restore the version
- if (file_put_contents($css_path, $entry['css']) !== false) {
- $this->usedIcons = $entry['iconList'];
- update_option(BASE.'usedIcons', $this->usedIcons);
- CacheManager::updateTimestamp('icons');
- return true;
- }
-
- return false;
- }
- }
-
- error_log("[IconsManager] Version {$timestamp} not found in history");
- return false;
- }
-
- public function forceRefresh(): void
- {
- $this->clearIconCache();
- update_option(BASE.'icons_needs_update', true);
- CacheManager::updateTimestamp('icons');
- }
-
- public function mergeVersions(array $timestamps): bool
- {
- if (empty($timestamps)) {
- return false;
- }
-
- $history = get_option(BASE.'icon_css_history', []);
- $merged_icons = [];
- // Collect icons from selected versions
- foreach ($history as $entry) {
- if (in_array($entry['timestamp'], $timestamps)) {
- foreach ($entry['iconList'] as $style => $icons) {
- if (!isset($merged_icons[$style])) {
- $merged_icons[$style] = [];
- }
- // Merge and keep unique
- $merged_icons[$style] = array_unique(
- array_merge($merged_icons[$style], $icons)
- );
- }
- }
- }
-
- if (empty($merged_icons)) {
- error_log('[IconsManager] No icons found in selected versions');
- return false;
- }
-
- // Archive current version
- $current_css = file_get_contents(JVB_DIR . '/assets/css/icons.css');
- if ($current_css !== false) {
- $this->archiveCurrentVersion($current_css);
- }
-
- // Update used icons and regenerate
- $this->usedIcons = $merged_icons;
- update_option(BASE.'usedIcons', $this->usedIcons);
-
- // Force regeneration
- $this->regenerateCSS();
-
- return true;
- }
-}
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 308a51c..5a25159 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -18,7 +18,7 @@
{
protected Features $siteFeatures;
protected ?MetaForm $metaForm = null;
- protected CacheManager $cache;
+ protected Cache $cache;
protected array $forms =[];
@@ -43,7 +43,7 @@
$this->siteFeatures = Features::forSite();
- $this->cache = CacheManager::for('login');
+ $this->cache = Cache::for('login');
// Initialize magic link support if enabled
if ($this->siteFeatures->has('magicLink')) {
diff --git a/inc/managers/LoginManagerOld.php b/inc/managers/LoginManagerOld.php
deleted file mode 100644
index a2fa1b7..0000000
--- a/inc/managers/LoginManagerOld.php
+++ /dev/null
@@ -1,1061 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\meta\MetaManager;
-use JVBase\utility\Features;
-use WP_Error;
-use WP_User;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-class LoginManagerOld
-{
- protected ?MagicLinkManager $magicLink = null;
- private array|null $invitation_data = null;
- protected array $inviteData = [];
- private array $allowed_file_types = [
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'application/pdf'
- ];
- private int $max_file_size = 5242880; // 5MB in bytes
-
- public function __construct()
- {
- // Common login page customization
- add_action('login_enqueue_scripts', [$this, 'loginStyles']);
- add_action('login_header', [$this, 'loginHeader'], 0);
- add_action('login_footer', [$this, 'loginFooter']);
-
- // Login page filters
- add_filter('login_headerurl', [$this, 'logoUrl']);
- add_filter('login_headertext', [$this, 'logoTitle']);
- add_filter('login_message', [$this, 'loginMessage']);
- add_filter('login_errors', [$this, 'loginErrors']);
-
- // Login success handling
- add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
-
- if (Features::forSite()->has('magicLink')) {
- $this->magicLink = new MagicLinkManager();
- }
-
- // Registration-specific hooks
- if ($this->isRegistrationPage()) {
- $this->initRegistrationHooks();
- }
- }
-
- /**
- * Check if we're on the registration page
- */
- private function isRegistrationPage(): bool
- {
- return isset($_GET['action']) && $_GET['action'] === 'register';
- }
-
- /**
- * Initialize registration-specific hooks
- */
- private function initRegistrationHooks(): void
- {
- add_action('register_form', [$this, 'addRegistrationFields']);
- add_action('login_header', [$this, 'addRegistrationScript']);
- add_filter('registration_errors', [$this, 'registrationErrorsFilter'], 10, 3);
- add_action('user_register', [$this, 'saveRegistrationFields'], 999, 2);
- add_action('login_head', [$this, 'modifyRegistrationForm']);
- add_action('register_form', [$this, 'addUploadSupport']);
- add_filter('pre_user_login', [$this, 'setUserLogin'], 1);
- add_filter('pre_user_email', [$this, 'setUserEmail'], 1);
- add_filter('register_message', [$this, 'customRegisterMessage']);
- add_filter('wp_login_errors', [$this, 'registrationSuccessMessage'], 10, 2);
- add_filter('login_form_top', [$this, 'loginFormTop']);
- add_filter('login_form_bottom', [$this, 'loginFormBottom']);
- add_filter('login_form_middle', [$this, 'loginFormMiddle']);
-
- // Remove default username requirement for registration
- remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
- }
-
- /**
- * Combined login styles for both login and registration
- */
- public function loginStyles(): void
- {
- do_action('jvbLoginStyles');
- }
-
- /**
- * Login header - used for both login and registration
- */
- public function loginHeader(): void
- {
- ?>
- <script type="text/javascript">
- document.addEventListener('DOMContentLoaded', function() {
- let loginLabel = document.querySelector('label[for="user_login"');
- loginLabel.innerHTML = '<?= jvbIcon('envelope', ['size' => 20]); ?> Your Email';
-
- let passwordLabel = document.querySelector('label[for="user_pass"');
- passwordLabel.innerHTML = '<?= jvbIcon('password', ['size' => 20]); ?> Your Password';
-
- document.querySelector('form').classList.add('loaded');
- });
-
- </script>
- <?php
- }
-
- /**
- * Login footer with donate section
- */
- public function loginFooter(): void
- {
- do_action('jvbLoginFooter');
-
- }
-
- /**
- * Logo URL
- */
- public function logoUrl(): string
- {
- return home_url();
- }
-
- /**
- * Logo title
- */
- public function logoTitle(): string
- {
- return get_bloginfo('name');
- }
-
- /**
- * Login message - handles both login and registration
- */
- public function loginMessage(string $message): string
- {
- if ($this->isRegistrationPage()) {
- if (jvbSiteHasInvitations() && $this->fromInvite()) {
- $data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
- $name = $data->name;
- $inviters = json_decode($data->inviters, true);
- $names = [];
- foreach ($inviters as $inviter) {
- $artist = jvbContentFromUser((int)$inviter['user_id']);
- $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
- }
- $message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and invited you to join in!';
- return '<h2>Join the Scene, '.$name.'</h2>
- <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
- }
- if (jvbSiteHasFavourites() && $this->fromFavourites()) {
- return '<h2>'.JVB_LOGIN['login_from_favourite_header']??'Save your Favourites'.'</h2>';
- }
- return '<h2>'.JVB_LOGIN['join_header'].'</h2>';
- } else {
- if (jvbSiteHasFavourites()) {
- $login = (!$this->fromFavourites()) ? '<h2>'.JVB_LOGIN['login_header'].'</h2>' : '<h2>'.JVB_LOGIN['login_from_favourite_header'].'</h2>';
- } else {
- $login = '<h2>'.JVB_LOGIN['login_header'].'</h2>';
- }
-
- return (empty($message)) ? $login : $login.$message;
- }
- }
-
- protected function fromFavourites():bool
- {
- return array_key_exists('type', $_GET) && $_GET['type'] === 'favourites';
- }
-
- /**
- * Customize login error messages
- */
- public function loginErrors(string $error): string
- {
- return str_replace(
- [
- 'The password you entered for the username',
- 'Invalid username',
- 'Unknown username',
- 'Unknown email address'
- ],
- [
- 'Wrong password',
- 'We can\'t find that username',
- 'We can\'t find that username',
- 'We can\'t find that email'
- ],
- $error
- );
- }
-
- /**
- * Handle successful login
- */
- public function handleSuccessfulLogin(string $username, WP_User $user): void
- {
- if (isOurPeople() && !user_can($user, 'manage_options')) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- }
-
- // ===== REGISTRATION-SPECIFIC METHODS =====
-
- /**
- * Set user login for registration
- */
- public function setUserLogin(string $login): string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $login;
- }
-
- /**
- * Set user email for registration
- */
- public function setUserEmail(string $email): string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $email;
- }
-
- /**
- * Modify registration form
- */
- public function modifyRegistrationForm(): void
- {
- if (!$this->isRegistrationPage()) {
- return;
- }
-
- ?>
- <script type="text/javascript">
- document.addEventListener('DOMContentLoaded', function() {
- // Hide default fields
- const defaultFields = document.getElementById('registerform').querySelectorAll('p');
- defaultFields.forEach(field => {
- if (field.querySelector('label[for="user_login"]') ||
- field.querySelector('label[for="user_email"]')) {
- field.remove();
- }
- });
-
- // Hide the default registration info text
- const regInfo = document.querySelector('.message.register');
- if (regInfo) {
- regInfo.style.display = 'none';
- }
-
- <?php
- if ($this->fromInvite()) {
- $this->handleArtistInvitation();
- }
- ?>
-
- // Move submit button to the end of the form
- const submitButton = document.getElementById('registerform').querySelector('.submit');
- if (submitButton) {
- document.getElementById('registerform').appendChild(submitButton);
- }
- });
- </script>
- <?php
- }
-
- /**
- * Handle artist invitation pre-fill
- */
- protected function handleArtistInvitation(): void
- {
- $token = sanitize_text_field($_GET['invite']);
- $email = sanitize_email($_GET['email']);
- $data = JVB()->routes('invites')->verifyInvitation($token, $email);
-
- ?>
- document.querySelector('input#artist').checked = true;
- document.querySelector('#artist_first_name').value = '<?=$data->name?>';
- document.querySelector('#artist_email').value = '<?=$email?>';
- <?php
- if ($data->to_shop) {
- ?>
- document.querySelector('#artist_shop').value = '<?=$data->shop?>';
- <?php
- }
- ?>
- let form = document.getElementById('registerform')
- let input = document.createElement('input');
- let email = input.cloneNode(true);
- input.type = 'hidden';
- input.name = 'invite_token';
- input.value = '<?= $token ?>';
- email.type = 'hidden';
- email.name = 'invite_email';
- email.value = '<?= $email?>';
- form.append(input);
- form.append(email);
- <?php
- }
-
- /**
- * Add upload support for registration
- */
- public function addUploadSupport(): void
- {
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const form = document.getElementById('registerform');
- if (form) {
- form.enctype = 'multipart/form-data';
- }
- });
- </script>
- <?php
- }
-
- /**
- * Add registration script
- */
- public function addRegistrationScript(): void
- {
- if (!$this->isRegistrationPage()) {
- return;
- }
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
-
- // Initialize user type selection
- function initUserTypeSelection() {
- const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
- const fieldGroups = document.querySelectorAll('.field-group');
-
- userTypeRadios.forEach(radio => {
- radio.addEventListener('change', function() {
- fieldGroups.forEach(group => group.classList.remove('active'));
- const selectedType = this.value;
- const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- });
- });
-
- const checkedRadio = document.querySelector('input[name="user_type"]:checked');
- if (checkedRadio) {
- const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- }
- }
-
- // Initialize shop selection
- function initShopSelection() {
- let form = document.getElementById('registerform');
- form.addEventListener('change', (e) => {
- if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
- let next = e.target.parentNode.nextElementSibling;
- let input = next.querySelector('input');
-
- if(e.target.value === 'other'){
- next.style.display = 'block';
- next.style.animation = 'fadeIn 0.3s ease';
- input.required = true;
- input.focus();
- }else{
- input.required = false;
- input.value = '';
- }
- }
- });
- }
-
- // Initialize file upload handling
- function initFileUpload() {
- const fileInput = document.getElementById('certification_file');
- const filePreview = document.querySelector('.file-preview');
- const filePreviewName = document.querySelector('.file-preview-name');
- const fileError = document.querySelector('.file-error');
- const removeButton = document.querySelector('.file-preview-remove');
-
- if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
- return;
- }
-
- const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
-
- fileInput.addEventListener('change', function(e) {
- const file = e.target.files[0];
- fileError.classList.remove('active');
-
- if (file) {
- const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
- const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
-
- if (!validTypes.includes(fileExtension)) {
- showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- fileInput.value = '';
- return;
- }
-
- if (file.size > maxSize) {
- showError('File size must be less than 5MB');
- fileInput.value = '';
- return;
- }
-
- filePreviewName.textContent = file.name;
- filePreview.classList.add('active');
- } else {
- filePreview.classList.remove('active');
- }
- });
-
- removeButton.addEventListener('click', function() {
- fileInput.value = '';
- filePreview.classList.remove('active');
- fileError.classList.remove('active');
- });
-
- function showError(message) {
- fileError.textContent = message;
- fileError.classList.add('active');
- filePreview.classList.remove('active');
- }
- }
-
- // Initialize all components
- initUserTypeSelection();
- initShopSelection();
- initFileUpload();
- });
- </script>
- <?php
- }
-
- /**
- * Add registration fields
- */
- public function addRegistrationFields(): void
- {
- echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
- ?>
- <div class="registration-intro">
- <?php
- foreach (JVB_LOGIN['join_intro']??[] as $intro) {
- echo '<p>'.$intro.'</p>';
- }
- ?>
-
- <?php if ($this->fromFavourites()): ?>
- <div class="favourites-login-message">
- <ul class="benefits-list">
- <?php
- foreach (JVB_LOGIN['from_favourites_benefits']??[] as $benefit) {
- echo '<li>'.$benefit.'</li>';
- }
- ?>
- </ul>
- </div>
- <?php endif; ?>
- </div>
-
- <?php
- if (array_key_exists('choose', JVB_LOGIN)) {
- ?>
- <h3><?= JVB_LOGIN['choose']?></h3>
- <?php
- }
- ?>
-
- <?php
- if (count(JVB_USER) > 1) {
- $this->renderUserTypeSelection();
- } else {
- ?>
- <p>
- <label for="first_name" class="required-field">First Name</label>
- <input type="text" id="first_name" name="first_name" class="input">
- </p>
- <p>
- <label for="email" class="required-field">Email</label>
- <input type="email" id="email" name="email" class="input">
- </p>
- <?php
- }
- if ($this->invitation_data) {
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const artistRadio = document.getElementById('artist');
- if (artistRadio) {
- artistRadio.checked = true;
- artistRadio.dispatchEvent(new Event('change'));
- }
-
- const emailField = document.getElementById('artist_email');
- if (emailField) {
- emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
- emailField.readOnly = true;
- }
-
- const shopSelect = document.getElementById('artist_shop');
- if (shopSelect) {
- shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
- shopSelect.readOnly = true;
- }
- });
- </script>
- <input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
- <input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email']) ?>">
- <?php
- }
- }
-
- protected function renderUserTypeSelection():void
- {
-
-
- // Get list of tattoo shops and cities
- $shops = get_terms([
- 'taxonomy' => 'jvb_shop',
- 'hide_empty' => true
- ]);
-
- $cities = get_terms([
- 'taxonomy' => 'jvb_city',
- 'hide_empty' => false,
- ]);
- ?>
- <div class="user-type-section">
-
- <?php
- $i = 1;
- $radio = '<input type="radio" id="user0" name="user_type" value="subscriber" required checked>
- <label for="user0"></label>';
- $descriptions = '';
- foreach (JVB_USER as $role => $config) {
- if (jvbCheck('can_register', $config)) {
- $radio .= '<input type="radio" id="user'.$i.'" name="user_type" value="'.$role.'" required';
- $radio .= ($role === 'enthusiast' && $this->fromFavourites()) ? 'checked' : '';
- $radio .= '><label for="user'.$i.'">'.jvbIcon($role, ['title' =>$config['label'], 'size'=>40]).'<h4>'.$config['label'].'</h4><p>';
- $radio .= $config['join_text']??'';
- $radio .= '</p></label>';
-
- $descriptions .= '<div class="user'.$i.'">'.is_array($config['join_description']) ? implode('', array_map(function ($item) { return '<p>'.$item.'</p>'; }, $config['join_description'])) : '<p>'.$config['join_description'].'</p>'.'</div>';
-
- $i++;
- }
- }
-
- echo $radio;
- echo $descriptions;
- ?>
- <input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
- <label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
- <input type="radio" id="artist" name="user_type" value="artist" required>
- <label for="artist"><?=jvbIcon('drop-simple', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
- <input type="radio" id="partner" name="user_type" value="partner" required>
- <label for="partner"><?=jvbIcon('currency-circle-dollar', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
- <p class="enthusiast">Save your favourites. Get notified.</p>
- <p class="artist">Show off your work.</p>
- <p class="partner">Support the community.</p>
- </div>
-
- <!-- Enthusiast Fields -->
- <div class="field-group" data-type="enthusiast">
- <h4>Welcome to the scene.</h4>
- <p>Sign up with your email to:</p>
- <ul>
- <li>Save your favourites for easy access</li>
- <li>Get notified when your favourite artists add new content</li>
- <li>Stay in the loop with local flash days and events</li>
- <li>Discover styles and artists that match your vision</li>
- </ul>
- <p>
- <label for="enthusiast_first_name" class="required-field">First Name</label>
- <input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
- </p>
- <p>
- <label for="enthusiast_email" class="required-field">Email</label>
- <input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
- </p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
-
- <!-- Artist Fields -->
- <div class="field-group" data-type="artist">
- <h4>Welcome to the scene!</h4>
- <p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
- <ul>
- <li>you are who you say you are</li>
- <li>you work at the shop you listed</li>
- <li>your certification</li>
- </ul>
- <p>
- <label for="artist_first_name" class="required-field">First Name</label>
- <input type="text" id="artist_first_name" name="artist_first_name" class="input">
- </p>
- <p>
- <label for="artist_last_name" class="required-field">Last Name</label>
- <input type="text" id="artist_last_name" name="artist_last_name" class="input">
- </p>
- <p>
- <label for="artist_email" class="required-field">Email</label>
- <input type="email" id="artist_email" name="artist_email" class="input">
- </p>
- <p>
- <label for="artist_shop" class="required-field">Shop</label>
- <select id="artist_shop" name="artist_shop" class="input">
- <option value="">Select a shop</option>
- <option value="other">Add New Shop</option>
- <?php foreach ($shops as $shop) : ?>
- <option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_shop_field" style="display: none;">
- <label for="artist_shop_other" class="required-field">Shop Name</label>
- <input type="text" id="artist_shop_other" name="artist_shop_other" class="input" placeholder="Shop name">
- </p>
-
- <p>
- <label for="artist_type" class="required-field">Type</label>
- <input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
- <label for="type-tattoo-artist">Tattoo Artist</label>
- <input type="radio" id="type-piercer" name="artist_type" value="piercer">
- <label for="type-piercer">Piercer</label>
- <input type="radio" id="type-other" name="artist_type" value="other">
- <label for="type-other">Other</label>
- </p>
- <p>
- <label for="artist_city" class="required-field">City</label>
- <select id="artist_city" name="artist_city" class="input">
- <option value="">Select a city</option>
- <option value="other">Add New City</option>
- <?php foreach ($cities as $city) : ?>
- <option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_city_field" style="display: none;">
- <label for="artist_city_other" class="required-field">City Name</label>
- <input type="text" id="artist_city_other" name="artist_city_other" class="input" placeholder="City">
- </p>
-
- <div class="file-upload-container">
- <label class="file-upload-label">Certification or Training Documents</label>
- <p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
- <div class="file-upload-wrapper">
- <input type="file" name="certification_file" id="certification_file" accept=".jpg,.jpeg,.png,.gif,.pdf" data-max-size="<?= $this->max_file_size; ?>">
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF or PDF (max. 5MB)
- </p>
- </div>
- <div class="file-preview">
- <div class="file-preview-content">
- <span class="file-preview-name"></span>
- <button type="button" class="file-preview-remove">Remove</button>
- </div>
- </div>
- <div class="file-error"></div>
- </div>
- <p>Once you click register:</p>
- <ul>
- <li>We'll start looking into your information (usually within 24-48 hours)</li>
- <li>You'll get a password reset email</li>
- <li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
- </ul>
- <p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
-
- <!-- Partner Fields -->
- <div class="field-group" data-type="partner">
- <h4>Howdy, partner!</h4>
- <p>We appreciate your interest!</p>
- <p>edmonton.ink is a great place to showcase what you do, whether you:</p>
- <ul>
- <li>provide goods or services that tattoo artists could use</li>
- <li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
- <li>provide goods or services that folks who love tattoos could also love</li>
- </ul>
-
- <p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
- <p>
- <label for="partner_name" class="required-field">Contact Name</label>
- <input type="text" id="partner_name" name="partner_name" class="input">
- </p>
- <p>
- <label for="partner_email" class="required-field">Email</label>
- <input type="email" id="partner_email" name="partner_email" class="input">
- </p>
- <p>
- <label for="partner_business" class="required-field">Business Name</label>
- <input type="text" id="partner_business" name="partner_business" class="input">
- </p>
- <p>
- <label for="partner_website">Business Website</label>
- <input type="url" id="partner_website" name="partner_website" class="input">
- </p>
- <p>
- <label for="partner_description">Why would you be a good fit?</label>
- <textarea id="partner_description" name="partner_description" rows="8"></textarea>
- </p>
- <p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
- <p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
- </div>
- <?php
- }
-
- /**
- * Registration errors filter
- */
- public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email): WP_Error
- {
- error_log('Registration Data: '.print_r($_POST, true));
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-
- if (empty($user_type)) {
- $errors->add('user_type_error', 'Please select your user type.');
- return $errors;
- }
-
- // Get email based on user type
- $email_field = $user_type . '_email';
- $email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
-
- // Remove WordPress's default username error
- $errors = new WP_Error();
-
- // If this is an invited artist, validate the invitation
- $invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
- if ($invite && array_key_exists('role', $_POST)) {
- $handler = JVB()->routes('invites');
- $invitation = $handler->verifyInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
-
- if (!$invitation) {
- $errors->add('invalid_invitation', 'Invalid invitation token.');
- } elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
- $errors->add('expired_invitation', 'This invitation has expired.');
- }
- }
-
- // Validate email first
- if (empty($email)) {
- $errors->add('email_error', 'Email is required.');
- } elseif (!is_email($email)) {
- $errors->add('email_error', 'Please enter a valid email address.');
- } elseif (email_exists($email)) {
- $errors->add('email_error', 'This email is already registered.');
- }
-
- switch ($user_type) {
- case 'enthusiast':
- if (empty($_POST['enthusiast_first_name'])) {
- $errors->add('first_name_error', 'First name is required.');
- }
- break;
-
- case 'artist':
- $required_fields = [
- 'artist_first_name' => 'First name',
- 'artist_last_name' => 'Last name',
- 'artist_shop' => 'Shop',
- 'artist_city' => 'City',
- 'artist_type' => 'Type',
- ];
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
-
- case 'partner':
- $required_fields = [
- 'partner_name' => 'Contact name',
- 'partner_business' => 'Business name'
- ];
-
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
- }
-
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Validate file type
- if (!in_array($file['type'], $this->allowed_file_types)) {
- $errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- }
-
- // Validate file size
- if ($file['size'] > $this->max_file_size) {
- $errors->add('file_size_error', 'File size must be less than 5MB');
- }
- }
-
- return $errors;
- }
-
- /**
- * Save registration fields
- */
- public function saveRegistrationFields(int $user_id, array $userdata): void
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
- if (!$user_type) {
- return;
- }
-
- // Set user role based on type
- $user = new WP_User($user_id);
- $caps = JVB()->roles();
- $email = false;
- $upload_dir = wp_upload_dir();
- $base_dir = $upload_dir['basedir'];
-
- switch ($user_type) {
- case 'artist':
- $user->set_role('jvb_artist');
- $user->remove_role('subscriber');
-
- $email = sanitize_email($_POST['artist_email']);
- $first = sanitize_text_field($_POST['artist_first_name']);
- $last = sanitize_text_field($_POST['artist_last_name']);
- $display_name = $first . ' ' . $last;
-
- // Save artist fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $first,
- 'last_name' => $last,
- 'display_name' => $display_name
- ]);
- $user = get_userdata($temp);
-
- $link = $caps->addUserLink($user, 'artist');
- $meta = new MetaManager($link, 'post');
- $meta->setAll([
- 'first_name' => $first,
- 'email' => $email
- ]);
-
- // If this was an invited artist, handle the invitation
- if (array_key_exists('invite_token', $_POST)) {
- $handler = JVB()->routes('invites');
- $handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
- }
-
- if (absint($_POST['artist_shop']) > 0) {
- JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
- }
- if (absint($_POST['artist_city']) > 0) {
- wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
- }
-
- //Create approval request and notify verified users
- JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
-
- //Make base directories
- $artist_dir = $base_dir . '/artists/' . $user_id;
- wp_mkdir_p($artist_dir);
- wp_mkdir_p($artist_dir . '/artwork');
- wp_mkdir_p($artist_dir . '/events');
- wp_mkdir_p($artist_dir . '/profile');
- wp_mkdir_p($artist_dir . '/temp');
-
- switch ($_POST['artist_type']) {
- case 'tattoo-artist':
- $caps->setUserAs($user, 'tattoo-artist');
- $term = get_term_by('name', 'Tattoo Artists', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/tattoos');
- break;
- case 'piercer':
- $caps->setUserAs($user, 'piercer');
- $term = get_term_by('name', 'Piercers', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/piercings');
- break;
- }
- break;
-
- case 'partner':
- $user->set_role('jvb_partner');
- $user->remove_role('subscriber');
- $name = sanitize_text_field($_POST['partner_name']);
- $email = sanitize_email($_POST['partner_email']);
-
- $caps->setUserAs($user, 'partner');
- $link = $caps->addUserLink($user, 'partner');
-
- // Save partner fields
- update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
- update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
- update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
-
- // Create partner base directory
- $partner_dir = $base_dir . '/partners/' . $user_id;
- wp_mkdir_p($partner_dir);
- wp_mkdir_p($partner_dir . '/offers');
- wp_mkdir_p($partner_dir . '/events');
- wp_mkdir_p($partner_dir . '/profile');
- wp_mkdir_p($partner_dir . '/temp');
- break;
-
- case 'enthusiast':
- $user->set_role('jvb_enthusiast');
- $user->remove_role('subscriber');
- $caps->setUserAs($user, 'enthusiast');
- $name = sanitize_text_field($_POST['enthusiast_first_name']);
- $email = sanitize_email($_POST['enthusiast_email']);
-
- // Save enthusiast fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $name,
- 'user_email' => $email,
- ]);
- break;
- }
-
- // Handle file upload for artists
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Setup upload directory
- $upload_dir = wp_upload_dir();
- $user_directory = 'artist-certifications/' . $user_id;
- $target_dir = $upload_dir['basedir'] . '/' . $user_directory;
-
- // Create directory if it doesn't exist
- wp_mkdir_p($target_dir);
-
- // Generate unique filename
- $file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
- $filename = 'certification-' . time() . '.' . $file_extension;
- $target_file = $target_dir . '/' . $filename;
-
- // Move uploaded file
- if (move_uploaded_file($file['tmp_name'], $target_file)) {
- // Save file information in user meta
- update_user_meta($user_id, 'certification_file', [
- 'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
- 'file' => $target_file,
- 'type' => $file['type'],
- 'original_name' => $file['name']
- ]);
- }
- }
-
- // Handle list invitation acceptance
- if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
- $token = sanitize_text_field($_GET['list_token']);
- $email = sanitize_email($_GET['email']);
-
- if ($email) {
- JVB()->routes('favourites')->acceptListInvitation($token, $email, $user_id);
- }
- }
- }
-
- /**
- * Registration success message
- */
- public function registrationSuccessMessage(WP_Error $errors, string $redirect_to): WP_Error
- {
- if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
- // Custom message for invited artists
- $message = "WELCOME ABOARD!<br><br>" .
- "Password setup is in your inbox. <br>" .
- "Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
-
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
-
- if (isset($errors->errors['registered'])) {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
-
- switch ($user_type) {
- case 'enthusiast':
- $message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
- break;
-
- case 'artist':
- $message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
- break;
-
- case 'partner':
- $message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
- break;
-
- default:
- $message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
- }
-
- // Replace the default message
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
-
- return $errors;
- }
-
- /**
- * Check if registration is from invite
- */
- protected function fromInvite(): bool
- {
- return isset($_GET['invite']) && isset($_GET['email']);
- }
-
- /**
- * Custom register message
- */
- public function customRegisterMessage(string $message): string
- {
- return "Join Edmonton's tattoo community";
- }
-}
-
-// Initialize the consolidated auth manager
-//new LoginManager();
-error_log('LoginManager working');
diff --git a/inc/managers/MagicLinkManager.php b/inc/managers/MagicLinkManager.php
index f366c96..c8d0a88 100644
--- a/inc/managers/MagicLinkManager.php
+++ b/inc/managers/MagicLinkManager.php
@@ -16,8 +16,8 @@
*/
class MagicLinkManager
{
- protected CacheManager $cache;
- protected CacheManager $referral_cache;
+ protected Cache $cache;
+ protected Cache $referral_cache;
// Token settings
protected int $token_expiry = 900; // 15 minutes in seconds
@@ -32,8 +32,8 @@
public function __construct()
{
- $this->cache = CacheManager::for('magic_links', $this->token_expiry);
- $this->referral_cache = CacheManager::for('referral_magic_links', 14 * DAY_IN_SECONDS);
+ $this->cache = Cache::for('magic_links', $this->token_expiry);
+ $this->referral_cache = Cache::for('referral_magic_links', 14 * DAY_IN_SECONDS);
// Hook into WordPress auth flow
add_action('template_redirect', [$this, 'handleMagicLinkClick']);
@@ -129,9 +129,9 @@
// Delete token after verification (single use)
// Check which cache it's in and delete from the correct one
if ($token_data['type'] === 'referral') {
- $this->referral_cache->delete($token);
+ $this->referral_cache->forget($token);
} else {
- $this->cache->delete($token);
+ $this->cache->forget($token);
}
return $token_data;
diff --git a/inc/managers/NewsRelationships.php b/inc/managers/NewsRelationships.php
index 526ea30..12098e9 100644
--- a/inc/managers/NewsRelationships.php
+++ b/inc/managers/NewsRelationships.php
@@ -16,13 +16,13 @@
class NewsRelationships
{
private string $table_name;
- private CacheManager $cache;
+ private Cache $cache;
public function __construct()
{
global $wpdb;
$this->table_name = $wpdb->prefix . BASE . 'news_relationships';
- $this->cache = CacheManager::for('news_relationships', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('news_relationships', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true)->connect('user',true);
// Register hooks
add_action('init', [$this, 'registerHooks']);
@@ -512,7 +512,7 @@
}
// Update cache
- $this->cache->delete($shop_id);
+ $this->cache->forget($shop_id);
// Update shop total count
$this->updateShopTotal($shop_id);
@@ -534,7 +534,7 @@
);
// Update cache
- $this->cache->delete($shop_id);
+ $this->cache->forget($shop_id);
}
/**
diff --git a/inc/managers/NotificationManager.php b/inc/managers/NotificationManager.php
index 5656303..5a02499 100644
--- a/inc/managers/NotificationManager.php
+++ b/inc/managers/NotificationManager.php
@@ -20,7 +20,11 @@
*/
class NotificationManager
{
- protected object $cache;
+ protected Cache $userCache; //the individual notifications
+ protected Cache $contentCache; //the 'shared' notifications on new content that has been created
+ protected Cache $artistsCache;
+ protected Cache $favouritesCache;
+ protected Cache $followerCache;
protected string $campaign;
protected string $table = BASE.'notifications';
protected string $contentTable = BASE.'notifications_content';
@@ -139,7 +143,11 @@
*/
public function __construct()
{
- $this->cache = CacheManager::for('notifications', WEEK_IN_SECONDS);
+ $this->userCache = Cache::for('userNotifications', WEEK_IN_SECONDS);
+ $this->contentCache = Cache::for('contentNotifications', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
+ $this->artistsCache = Cache::for('artist', WEEK_IN_SECONDS)->connect('post');
+ $this->favouritesCache = Cache::for('favouritedUsers', WEEK_IN_SECONDS)->connect('favourites');
+ $this->followerCache = Cache::for('totalFollowers', WEEK_IN_SECONDS)->connect('favourites');
// Add filter for bulk operation handling
add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
@@ -364,7 +372,7 @@
*/
public function notifyVerifiedArtists(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
{
- $artists = $this->getVerifiedArtists();
+ $artists = $this->getVerified('artist');
return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
}
/**
@@ -381,7 +389,7 @@
*/
public function notifyVerifiedPartners(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
{
- $artists = $this->getVerifiedPartners();
+ $artists = $this->getVerified('partner');
return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
}
/**
@@ -398,7 +406,7 @@
*/
public function notifyEnthusiasts(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
{
- $artists = $this->getEnthusiasts();
+ $artists = $this->getUserIDs('enthusiast');
return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
}
/**
@@ -415,7 +423,7 @@
*/
public function notifyEveryone(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
{
- $artists = $this->getEveryone();
+ $artists = $this->getUserIDs(array_keys(JVB_USER));
return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
}
@@ -1100,7 +1108,7 @@
}
$content = '';
- $cache = CacheManager::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
+ $cache = Cache::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
// Group updates by artist
$updates_by_artist = [];
@@ -1519,19 +1527,16 @@
protected function getArtistData(int $user_id):array|false
{
// Try to get from cache
- $cache_key = "artist_data_{$user_id}";
- $cached = $this->cache->get($cache_key);
+ $artist_id = get_user_meta($user_id, BASE . 'link', true);
+ if (!$artist_id || $artist_id === '') {
+ return false;
+ }
+ $cached = $this->artistsCache->get($artist_id);
if ($cached !== false) {
return $cached;
}
- // Get artist post ID from user meta
- $artist_id = get_user_meta($user_id, BASE . 'link', true);
- if (!$artist_id) {
- return false;
- }
-
// Get basic artist data
$artist_post = get_post($artist_id);
if (!$artist_post) {
@@ -1548,7 +1553,7 @@
];
// Cache the result
- $this->cache->set($cache_key, $data, null,'artists');
+ $this->artistsCache->set($artist_id, $data);
return $data;
}
@@ -1560,19 +1565,25 @@
*/
protected function getFollowedArtists(int $user_id):array
{
- global $wpdb;
- $favourites_table = $wpdb->prefix . BASE . 'favourites';
+ return $this->favouritesCache->remember(
+ $user_id,
+ function() use ($user_id) {
+ global $wpdb;
+ $favourites_table = $wpdb->prefix . BASE . 'favourites';
- // Get artists this user has favourited
- return $wpdb->get_col($wpdb->prepare(
- "SELECT f.target_id
- FROM {$favourites_table} f
- JOIN {$wpdb->posts} p ON f.target_id = p.ID
- WHERE f.user_id = %d
- AND f.type = 'artist'
- AND p.post_status = 'publish'",
- $user_id
- ));
+ // Get artists this user has favourited
+ return $wpdb->get_col($wpdb->prepare(
+ "SELECT f.target_id
+ FROM {$favourites_table} f
+ JOIN {$wpdb->posts} p ON f.target_id = p.ID
+ WHERE f.user_id = %d
+ AND f.type = 'artist'
+ AND p.post_status = 'publish'",
+ $user_id
+ ));
+ }
+ );
+
}
/**
@@ -1582,15 +1593,21 @@
*/
protected function getFollowerCount(int $artist_id):int
{
- global $wpdb;
- $favourites_table = $wpdb->prefix . BASE . 'favourites';
+ return $this->followerCache->remember(
+ $artist_id,
+ function() use ($artist_id) {
+ global $wpdb;
+ $favourites_table = $wpdb->prefix . BASE . 'favourites';
- return $wpdb->get_var($wpdb->prepare(
- "SELECT COUNT(DISTINCT user_id)
- FROM {$favourites_table}
- WHERE target_id = %d AND type = 'artist'",
- $artist_id
- ));
+ return $wpdb->get_var($wpdb->prepare(
+ "SELECT COUNT(DISTINCT user_id)
+ FROM {$favourites_table}
+ WHERE target_id = %d AND type = 'artist'",
+ $artist_id
+ ));
+ }
+ );
+
}
/**
@@ -1598,27 +1615,16 @@
*
* @return string
*/
- protected function pluralize(string $word):string
+ protected function pluralize(string $content):string
{
- $irregular = [
- 'tattoo' => 'tattoos',
- 'piercing' => 'piercings',
- 'artwork' => 'artwork',
- 'news' => 'news',
- 'offer' => 'offers',
- 'event' => 'events'
- ];
-
- if (isset($irregular[$word])) {
- return $irregular[$word];
- }
-
- // Simple pluralization rules
- if (str_ends_with($word, 'y')) {
- return substr($word, 0, -1) . 'ies';
- }
-
- return $word . 's';
+ if (array_key_exists($content, JVB_CONTENT)) {
+ return JVB_CONTENT[$content]['plural'];
+ } elseif (array_key_exists($content, JVB_TAXONOMY)) {
+ return JVB_TAXONOMY[$content]['plural'];
+ } elseif (array_key_exists($content, JVB_USER)) {
+ return JVB_USER[$content]['plural'];
+ }
+ return $content;
}
/**
@@ -1628,9 +1634,8 @@
*/
protected function clearNotificationCache(int $user_id):void
{
-
- $this->cache->delete("user_{$user_id}_notifications_", 'notifications_' . $user_id);
- $this->cache->delete("user_{$user_id}_content_notifications_", 'notifications_' . $user_id);
+ $this->userCache->forget($user_id);
+ $this->contentCache->forget($user_id);
}
/**
@@ -1697,78 +1702,56 @@
/**
* @return array
*/
- protected function getVerifiedArtists():array
+ protected function getVerified(string|array $userRoles):array
{
- $artists = $this->cache->get('verified_artists');
- if ($artists) {
- return $artists;
- }
+ $userRoles = $this->checkRoles($userRoles);
- $artists = get_users([
- 'role' => BASE.'artist',
- 'capability' => 'skip_moderation',
- 'fields' => 'ID'
- ]);
-
- $this->cache->set('verified_artists', $artists);
- return $artists;
+ if (empty($userRoles)) {
+ return [];
+ }
+ $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user',true);
+ return $cache->remember(
+ 'verified',
+ function() use ($userRoles) {
+ return get_users([
+ 'role' => $userRoles,
+ 'capability' => 'skip_moderation',
+ 'fields' => 'ID'
+ ]);
+ }
+ );
}
- /**
- * @return array
- */
- protected function getVerifiedPartners():array
- {
- $partners = $this->cache->get('verified_partners');
- if ($partners) {
- return $partners;
- }
+ protected function getUserIDs(array|string $roles):array
+ {
+ $roles = $this->checkRoles($roles);
+ if (empty($roles)) {
+ return [];
+ }
+ $cache = Cache::for('everyone', DAY_IN_SECONDS)->connect('user', true);
+ return $cache->remember(
+ $cache->generateKey($roles),
+ function() use ($roles) {
+ return get_users([
+ 'role' => $roles,
+ 'fields' => 'ID'
+ ]);
+ }
+ );
+ }
- $partners = get_users([
- 'role' => BASE.'partner',
- 'capability' => 'skip_moderation',
- 'fields' => 'ID'
- ]);
+ protected function checkRoles(string|array $roles):array
+ {
+ if (!is_array($roles)) {
+ $roles = explode(',',$roles);
+ }
- $this->cache->set('verified_partners', $partners);
- return $partners;
- }
-
- /**
- * @return array
- */
- protected function getEnthusiasts():array
- {
- $enthusiasts = $this->cache->get('enthusiasts');
- if ($enthusiasts) {
- return $enthusiasts;
- }
-
- $enthusiasts = get_users([
- 'role' => BASE.'enthusiast',
- 'fields' => 'ID'
- ]);
-
- $this->cache->set('enthusiasts', $enthusiasts);
- return $enthusiasts;
- }
-
- /**
- * @return array
- */
- protected function getEveryone():array
- {
- $users = $this->cache->get('users');
- if ($users) {
- return $users;
- }
- $users = get_users([
- 'role__in' => [BASE.'artist', BASE.'enthusiast', BASE.'partner'],
- 'fields' => 'ID'
- ]);
- $this->cache->set('users', $users);
- return $users;
- }
+ return array_map(function ($r) {
+ return jvbCheckBase(trim($r));
+ }, array_filter($roles, function ($r) {
+ return array_key_exists(trim($r), JVB_USER);
+ }));
+ }
/**
* @param int $userID
@@ -1777,13 +1760,12 @@
*/
protected function checkUser(int $userID):bool
{
- $checked = $this->cache->get($userID, 'checked_users');
- if ($checked) {
- return $checked;
- }
- $test = (bool)get_userdata($userID);
-
- $this->cache->set($userID, $test, null, 'checked_users');
- return $test;
+ $cache = Cache::for('checked_users', DAY_IN_SECONDS)->connect('user', true);
+ return $cache->remember(
+ $userID,
+ function() use ($userID) {
+ return (bool)get_userdata($userID)?:null;
+ }
+ );
}
}
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
index 047a948..3b93ccf 100644
--- a/inc/managers/OperationQueue.php
+++ b/inc/managers/OperationQueue.php
@@ -45,7 +45,7 @@
];
- protected ?CacheManager $cache = null;
+ protected ?Cache $cache = null;
protected int $ttl = 300;
// Cache keys for different data types
private const CACHE_QUEUE_STATUS = 'status';
@@ -59,7 +59,7 @@
{
global $wpdb;
$this->wpdb = $wpdb;
- $this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
+ $this->cache = Cache::for('queue', DAY_IN_SECONDS)->connect('user');
add_action('jvb_process_queue', [ $this, 'checkQueue' ]);
add_action('jvb_queue_maintenance', [$this, 'hourlyMaintenance']);
add_action('jvbEmailDailyMetricsReport', [$this, 'emailDailyMetricsReport']);
@@ -759,8 +759,8 @@
$this->processOperation($operation);
// Invalidate operation cache after processing
- $this->cache->delete(self::CACHE_OPERATION_PREFIX . $operation->id);
- $this->cache->delete(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
+ $this->cache->forget(self::CACHE_OPERATION_PREFIX . $operation->id);
+ $this->cache->forget(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
}
// Batch invalidate caches at the end
@@ -955,7 +955,7 @@
*/
public function isUserQueueModified(int $user_id, int $since_timestamp): bool
{
- return $this->cache::getTimestamp("user_{$user_id}") > $since_timestamp;
+ return $this->cache::lastModified("user_{$user_id}") > $since_timestamp;
}
protected function invalidateUserQueue(int $user_id): void
{
@@ -963,7 +963,7 @@
// 1. Updates HTTP timestamp for user_{$user_id}
// 2. Flushes user-specific caches
// 3. Triggers connected cache invalidation
- CacheManager::invalidateAll("user_{$user_id}");
+ Cache::for($user_id)->flush();
}
/**
@@ -985,7 +985,7 @@
$keys = $cacheKeys[$scope] ?? $cacheKeys['all'];
foreach ($keys as $key) {
- $this->cache->delete($key);
+ $this->cache->forget($key);
}
$this->cache->touch();
@@ -1491,7 +1491,7 @@
protected function updateUserQueueTimestamp(int $user_id)
{
- CacheManager::updateTimestamp("user_{$user_id}");
+ Cache::touch("user_{$user_id}");
}
/**
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index ade87e0..b3c1644 100644
--- a/inc/managers/ReferralManager.php
+++ b/inc/managers/ReferralManager.php
@@ -24,7 +24,9 @@
{
protected $wpdb;
protected MagicLinkManager $magic_link;
- protected CacheManager $cache;
+ protected Cache $cache;
+ protected Cache $requestCache;
+ protected Cache $statsCache;
protected string $referrals_table;
protected ?int $referralPage = null;
protected string $rewards_table;
@@ -48,7 +50,10 @@
{
global $wpdb;
$this->wpdb = $wpdb;
- $this->cache = CacheManager::for('referrals', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
+ $this->requestCache = Cache::for('referral_requests', WEEK_IN_SECONDS)->connect('referrals', true);
+ $this->statsCache = Cache::for('referral_stats', WEEK_IN_SECONDS)->connect('referrals', true);
+
$this->referrals_table = $wpdb->prefix . BASE . 'referrals';
$this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
@@ -355,7 +360,7 @@
}
// Clear caches
- $this->cache->clear();
+ $this->cache->flush();
// Fire action for tracking
do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral_code);
@@ -519,19 +524,19 @@
*/
public function getUserReferrals(int $user_id, array $args = []): array
{
- return $this->cache->remember(
- $user_id,
+ $defaults = [
+ 'status' => 'all',
+ 'limit' => 100,
+ 'offset' => 0,
+ 'orderby' => 'referred_at',
+ 'order' => 'DESC'
+ ];
+
+ $args = wp_parse_args($args, $defaults);
+
+ return $this->requestCache->remember(
+ $this->requestCache->generateKey(array_merge(['user'=>$user_id], $args)),
function() use ($user_id, $args) {
- $defaults = [
- 'status' => 'all',
- 'limit' => 100,
- 'offset' => 0,
- 'orderby' => 'referred_at',
- 'order' => 'DESC'
- ];
-
- $args = wp_parse_args($args, $defaults);
-
$where = $this->wpdb->prepare("WHERE referrer_id = %d", $user_id);
if ($args['status'] !== 'all') {
@@ -575,37 +580,33 @@
*/
public function getUserStats(int $user_id): array
{
- $cache_key = 'stats_' . $user_id;
- $cached = $this->cache->get($cache_key);
-
- if ($cached !== false) {
- return $cached;
- }
-
- $stats = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT
+ return $this->statsCache->remember(
+ $user_id,
+ function() use ($user_id) {
+ $stats = $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT
COUNT(*) as code_used,
SUM(CASE WHEN status IN ('consulted', 'treated') THEN 1 ELSE 0 END) as consultations,
SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treatments,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending
FROM {$this->referrals_table}
WHERE referrer_id = %d",
- $user_id
- ), ARRAY_A);
+ $user_id
+ ), ARRAY_A);
- // Get total rewards earned (available + redeemed)
- $rewards = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT SUM(amount)
+ // Get total rewards earned (available + redeemed)
+ $rewards = $this->wpdb->get_var($this->wpdb->prepare(
+ "SELECT SUM(amount)
FROM {$this->rewards_table}
WHERE user_id = %d AND reward_type = 'referrer'",
- $user_id
- ));
+ $user_id
+ ));
- $stats['total_rewards'] = floatval($rewards ?? 0);
- $stats['user_id'] = $user_id;
- $this->cache->set($cache_key, $stats, HOUR_IN_SECONDS);
-
- return $stats;
+ $stats['total_rewards'] = floatval($rewards ?? 0);
+ $stats['user_id'] = $user_id;
+ return $stats;
+ }
+ );
}
/**
@@ -617,20 +618,23 @@
*/
public function getTopReferrers(int $limit = 10, string $period = 'all'): array
{
- $where = '';
+ return $this->statsCache->remember(
+ $this->statsCache->generateKey(['limit'=>$limit, 'period' => $period]),
+ function() use ($limit, $period) {
+ $where = '';
- if ($period !== 'all') {
- $date_where = match($period) {
- 'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
- 'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
- 'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
- default => "1=1"
- };
+ if ($period !== 'all') {
+ $date_where = match($period) {
+ 'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
+ 'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
+ 'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
+ default => "1=1"
+ };
- $where = "WHERE {$date_where}";
- }
+ $where = "WHERE {$date_where}";
+ }
- $query = "SELECT
+ $query = "SELECT
referrer_id,
COUNT(*) as referral_count,
SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treated_count
@@ -640,16 +644,19 @@
ORDER BY referral_count DESC
LIMIT {$limit}";
- $results = $this->wpdb->get_results($query);
+ $results = $this->wpdb->get_results($query);
- // Enrich with user data
- foreach ($results as &$result) {
- $user = get_user_by('ID', $result->referrer_id);
- $result->user_name = $user ? $user->display_name : 'Unknown';
- $result->user_email = $user ? $user->user_email : '';
- }
+ // Enrich with user data
+ foreach ($results as &$result) {
+ $user = get_user_by('ID', $result->referrer_id);
+ $result->user_name = $user ? $user->display_name : 'Unknown';
+ $result->user_email = $user ? $user->user_email : '';
+ }
- return $results;
+ return $results;
+ }
+ );
+
}
/**
@@ -772,23 +779,30 @@
*/
protected function generateCSV(array $referrals): string
{
- $csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
+ $cache = Cache::for('referralCSV', HOUR_IN_SECONDS)->connect('referrals');
+ return $cache->remember(
+ 'csv',
+ function () use ($referrals) {
+ $csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
- foreach ($referrals as $referral) {
- $csv .= sprintf(
- '"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
- $referral->referrer_name ?? 'Unknown',
- $referral->referee_name,
- $referral->referee_email,
- $referral->referee_phone,
- $referral->referral_code,
- $referral->status,
- $referral->referred_at,
- $referral->treated_at ?? 'Not yet'
- );
- }
+ foreach ($referrals as $referral) {
+ $csv .= sprintf(
+ '"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
+ $referral->referrer_name ?? 'Unknown',
+ $referral->referee_name,
+ $referral->referee_email,
+ $referral->referee_phone,
+ $referral->referral_code,
+ $referral->status,
+ $referral->referred_at,
+ $referral->treated_at ?? 'Not yet'
+ );
+ }
- return $csv;
+ return $csv;
+ }
+ );
+
}
/**
diff --git a/inc/managers/ReferralManager2.php b/inc/managers/ReferralManager2.php
deleted file mode 100644
index 3f299fc..0000000
--- a/inc/managers/ReferralManager2.php
+++ /dev/null
@@ -1,1006 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\JVB;
-use WP_Error;
-use WP_REST_Response;
-use Exception;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-/**
- * Referral Tracking System
- *
- * Manages user referral codes, tracking, and rewards
- * Uses existing infrastructure: MetaManager, CacheManager, NotificationManager
- */
-class ReferralManager
-{
- protected $wpdb;
- protected $cache;
- protected $table_codes;
- protected $table_usage;
- protected $table_rewards;
-
- // Default reward settings
- const DEFAULT_REFERRER_REWARD_TYPE = 'per_user'; // or 'flat_total'
- const DEFAULT_REFERRER_REWARD_AMOUNT = 25.00;
- const DEFAULT_REFERRED_REWARD_TYPE = 'percentage'; // or 'fixed'
- const DEFAULT_REFERRED_REWARD_AMOUNT = 20; // 20% or $20
-
- public function __construct()
- {
- global $wpdb;
- $this->wpdb = $wpdb;
- $this->cache = JVB()->cache();
-
- $this->table_codes = $wpdb->prefix . BASE . 'referral_codes';
- $this->table_usage = $wpdb->prefix . BASE . 'referral_usage';
- $this->table_rewards = $wpdb->prefix . BASE . 'referral_rewards';
-
- $this->registerHooks();
- }
-
- /**
- * Register WordPress hooks
- */
- protected function registerHooks(): void
- {
- // Track new user registrations with referral codes
- add_action('user_register', [$this, 'trackReferralRegistration'], 10, 2);
-
- // Monthly report cron
- add_action(BASE . 'referral_monthly_report', [$this, 'generateMonthlyReports']);
-
- // Cleanup expired codes
- add_action(BASE . 'cleanup_referrals', [$this, 'cleanupExpiredCodes']);
-
- // Handle bulk operations
- add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
- }
-
- /************************************************************
- * Referral Code Management
- ************************************************************/
-
- /**
- * Create or update a user's referral code
- *
- * @param int $user_id User ID
- * @param string|null $custom_code Optional custom code (must be unique)
- * @return array|WP_Error
- */
- public function createReferralCode(int $user_id, ?string $custom_code = null): array|WP_Error
- {
- // Validate user
- if (!$this->validateUser($user_id)) {
- return new WP_Error('invalid_user', 'Invalid user ID');
- }
-
- // Check if user already has a code
- $existing = $this->getUserReferralCode($user_id);
-
- if ($existing && !$custom_code) {
- return $existing; // Return existing code if no custom code requested
- }
-
- // Generate or validate custom code
- $code = $custom_code ? $this->sanitizeCode($custom_code) : $this->generateUniqueCode($user_id);
-
- // Check if code is already taken
- if ($this->isCodeTaken($code, $user_id)) {
- return new WP_Error('code_taken', 'This referral code is already in use');
- }
-
- // Validate code format
- if (!$this->validateCodeFormat($code)) {
- return new WP_Error('invalid_format', 'Code must be 4-20 alphanumeric characters');
- }
-
- $data = [
- 'user_id' => $user_id,
- 'code' => $code,
- 'is_active' => 1,
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ];
-
- if ($existing) {
- // Update existing code
- $result = $this->wpdb->update(
- $this->table_codes,
- ['code' => $code, 'updated_at' => current_time('mysql')],
- ['user_id' => $user_id]
- );
- } else {
- // Insert new code
- $result = $this->wpdb->insert($this->table_codes, $data);
- }
-
- if ($result === false) {
- return new WP_Error('db_error', 'Failed to save referral code');
- }
-
- // Clear cache
- $this->cache->delete('referral_code_' . $user_id);
- $this->cache->delete('referral_user_' . $code);
-
- return [
- 'success' => true,
- 'code' => $code,
- 'url' => $this->getReferralUrl($code)
- ];
- }
-
- /**
- * Get user's referral code
- *
- * @param int $user_id User ID
- * @return array|null
- */
- public function getUserReferralCode(int $user_id): ?array
- {
- $cache_key = 'referral_code_' . $user_id;
- $cached = $this->cache->get($cache_key);
-
- if ($cached !== false) {
- return $cached;
- }
-
- $result = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->table_codes} WHERE user_id = %d",
- $user_id
- ), ARRAY_A);
-
- if ($result) {
- $result['url'] = $this->getReferralUrl($result['code']);
- $result['stats'] = $this->getCodeStats($result['code']);
- $this->cache->set($cache_key, $result, 3600);
- }
-
- return $result;
- }
-
- /**
- * Get referral code statistics
- *
- * @param string $code Referral code
- * @return array
- */
- public function getCodeStats(string $code): array
- {
- $cache_key = 'referral_stats_' . $code;
- $cached = $this->cache->get($cache_key);
-
- if ($cached !== false) {
- return $cached;
- }
-
- $stats = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT
- COUNT(*) as total_uses,
- COUNT(CASE WHEN registered_at IS NOT NULL THEN 1 END) as completed_registrations,
- COUNT(CASE WHEN first_order_at IS NOT NULL THEN 1 END) as converted_orders
- FROM {$this->table_usage}
- WHERE referral_code = %s",
- $code
- ), ARRAY_A);
-
- $this->cache->set($cache_key, $stats, 1800);
- return $stats ?: ['total_uses' => 0, 'completed_registrations' => 0, 'converted_orders' => 0];
- }
-
- /**
- * Get user ID from referral code
- *
- * @param string $code Referral code
- * @return int|null User ID or null
- */
- public function getUserFromCode(string $code): ?int
- {
- $cache_key = 'referral_user_' . $code;
- $cached = $this->cache->get($cache_key);
-
- if ($cached !== false) {
- return $cached;
- }
-
- $user_id = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT user_id FROM {$this->table_codes}
- WHERE code = %s AND is_active = 1",
- $code
- ));
-
- if ($user_id) {
- $this->cache->set($cache_key, (int)$user_id, 3600);
- return (int)$user_id;
- }
-
- return null;
- }
-
- /************************************************************
- * Referral Tracking
- ************************************************************/
-
- /**
- * Track when someone clicks a referral link
- *
- * @param string $code Referral code
- * @param string|null $email Optional email if user provides it
- * @return array|WP_Error
- */
- public function trackReferralClick(string $code, ?string $email = null): array|WP_Error
- {
- $user_id = $this->getUserFromCode($code);
-
- if (!$user_id) {
- return new WP_Error('invalid_code', 'Invalid referral code');
- }
-
- // Check if this email/IP already used this code recently (prevent duplicate tracking)
- $ip_address = $this->getClientIp();
-
- $existing = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT id FROM {$this->table_usage}
- WHERE referral_code = %s
- AND (email = %s OR ip_address = %s)
- AND clicked_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)",
- $code,
- $email ?: '',
- $ip_address
- ));
-
- if ($existing) {
- return ['success' => true, 'message' => 'Already tracked'];
- }
-
- // Track the click
- $data = [
- 'referral_code' => $code,
- 'referrer_user_id' => $user_id,
- 'email' => $email,
- 'ip_address' => $ip_address,
- 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
- 'clicked_at' => current_time('mysql')
- ];
-
- $result = $this->wpdb->insert($this->table_usage, $data);
-
- if ($result === false) {
- return new WP_Error('db_error', 'Failed to track referral');
- }
-
- // Store in cookie for 30 days
- setcookie('jvb_referral', $code, time() + (86400 * 30), '/');
-
- return [
- 'success' => true,
- 'tracking_id' => $this->wpdb->insert_id
- ];
- }
-
- /**
- * Track referral when user registers
- *
- * @param int $new_user_id Newly registered user ID
- * @param array $userdata User data
- * @return void
- */
- public function trackReferralRegistration(int $new_user_id, array $userdata = []): void
- {
- // Check for referral code in cookie or GET parameter
- $code = $_COOKIE['jvb_referral'] ?? $_GET['ref'] ?? null;
-
- if (!$code) {
- return;
- }
-
- $user = get_userdata($new_user_id);
- if (!$user) {
- return;
- }
-
- // Update or create usage record
- $usage = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->table_usage}
- WHERE referral_code = %s
- AND (email = %s OR ip_address = %s)
- ORDER BY clicked_at DESC LIMIT 1",
- $code,
- $user->user_email,
- $this->getClientIp()
- ), ARRAY_A);
-
- if ($usage) {
- // Update existing record
- $this->wpdb->update(
- $this->table_usage,
- [
- 'referred_user_id' => $new_user_id,
- 'email' => $user->user_email,
- 'registered_at' => current_time('mysql')
- ],
- ['id' => $usage['id']]
- );
- } else {
- // Create new record (direct registration with code)
- $referrer_id = $this->getUserFromCode($code);
-
- if ($referrer_id) {
- $this->wpdb->insert($this->table_usage, [
- 'referral_code' => $code,
- 'referrer_user_id' => $referrer_id,
- 'referred_user_id' => $new_user_id,
- 'email' => $user->user_email,
- 'ip_address' => $this->getClientIp(),
- 'clicked_at' => current_time('mysql'),
- 'registered_at' => current_time('mysql')
- ]);
- }
- }
-
- // Clear cache
- $this->cache->delete('referral_stats_' . $code);
-
- // Notify referrer
- if (isset($referrer_id) && $referrer_id) {
- $this->notifyReferrer($referrer_id, $new_user_id);
- }
- }
-
- /**
- * Track when referred user makes first order
- *
- * @param int $user_id User who made order
- * @param float $order_amount Order amount
- * @return void
- */
- public function trackFirstOrder(int $user_id, float $order_amount): void
- {
- $usage = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->table_usage}
- WHERE referred_user_id = %d
- AND first_order_at IS NULL",
- $user_id
- ), ARRAY_A);
-
- if (!$usage) {
- return;
- }
-
- // Update usage record
- $this->wpdb->update(
- $this->table_usage,
- [
- 'first_order_at' => current_time('mysql'),
- 'first_order_amount' => $order_amount
- ],
- ['id' => $usage['id']]
- );
-
- // Process rewards
- $this->processRewards($usage['referrer_user_id'], $user_id, $order_amount);
-
- // Clear cache
- $this->cache->delete('referral_stats_' . $usage['referral_code']);
- }
-
- /************************************************************
- * Reward Management
- ************************************************************/
-
- /**
- * Process referral rewards
- *
- * @param int $referrer_id User who referred
- * @param int $referred_id User who was referred
- * @param float $order_amount First order amount
- * @return void
- */
- protected function processRewards(int $referrer_id, int $referred_id, float $order_amount): void
- {
- // Get reward settings
- $settings = $this->getRewardSettings();
-
- // Calculate referrer reward
- $referrer_amount = $this->calculateReferrerReward($referrer_id, $settings);
-
- if ($referrer_amount > 0) {
- $this->addReward($referrer_id, 'referrer', $referrer_amount, $referred_id);
- }
-
- // Calculate referred user reward (already applied at checkout)
- $referred_amount = $this->calculateReferredReward($order_amount, $settings);
-
- if ($referred_amount > 0) {
- $this->addReward($referred_id, 'referred', $referred_amount, $referrer_id);
- }
- }
-
- /**
- * Calculate referrer reward amount
- *
- * @param int $referrer_id Referrer user ID
- * @param array $settings Reward settings
- * @return float Reward amount
- */
- protected function calculateReferrerReward(int $referrer_id, array $settings): float
- {
- $type = $settings['referrer_reward_type'];
- $amount = floatval($settings['referrer_reward_amount']);
-
- if ($type === 'per_user') {
- return $amount;
- }
-
- // For 'flat_total', check if total reward cap reached
- $total_earned = $this->getTotalRewardsEarned($referrer_id, 'referrer');
-
- if ($total_earned >= $amount) {
- return 0; // Cap reached
- }
-
- return min($settings['referrer_reward_per_user'] ?? 25.00, $amount - $total_earned);
- }
-
- /**
- * Calculate referred user reward
- *
- * @param float $order_amount Order amount
- * @param array $settings Reward settings
- * @return float Discount amount
- */
- protected function calculateReferredReward(float $order_amount, array $settings): float
- {
- $type = $settings['referred_reward_type'];
- $amount = floatval($settings['referred_reward_amount']);
-
- if ($type === 'percentage') {
- return $order_amount * ($amount / 100);
- }
-
- return min($amount, $order_amount); // Fixed amount, but not more than order
- }
-
- /**
- * Add reward to user's account
- *
- * @param int $user_id User receiving reward
- * @param string $type 'referrer' or 'referred'
- * @param float $amount Reward amount
- * @param int $related_user_id Related user ID
- * @return bool
- */
- protected function addReward(int $user_id, string $type, float $amount, int $related_user_id): bool
- {
- $data = [
- 'user_id' => $user_id,
- 'reward_type' => $type,
- 'amount' => $amount,
- 'related_user_id' => $related_user_id,
- 'status' => 'pending',
- 'created_at' => current_time('mysql')
- ];
-
- $result = $this->wpdb->insert($this->table_rewards, $data);
-
- if ($result) {
- // Notify user
- $notification_type = $type === 'referrer' ? 'referral_reward_earned' : 'referral_reward_received';
- JVB()->notification()->addNotification(
- $user_id,
- $notification_type,
- null,
- sprintf('You earned $%.2f in referral rewards!', $amount)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Get total rewards earned by user
- *
- * @param int $user_id User ID
- * @param string|null $type Optional reward type filter
- * @return float Total amount
- */
- public function getTotalRewardsEarned(int $user_id, ?string $type = null): float
- {
- $sql = "SELECT SUM(amount) FROM {$this->table_rewards} WHERE user_id = %d";
- $params = [$user_id];
-
- if ($type) {
- $sql .= " AND reward_type = %s";
- $params[] = $type;
- }
-
- $total = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
- return floatval($total);
- }
-
- /**
- * Get user's available reward balance
- *
- * @param int $user_id User ID
- * @return float Available balance
- */
- public function getAvailableBalance(int $user_id): float
- {
- $total = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT SUM(amount) FROM {$this->table_rewards}
- WHERE user_id = %d AND status IN ('pending', 'available')",
- $user_id
- ));
-
- return floatval($total);
- }
-
- /************************************************************
- * Monthly Reports
- ************************************************************/
-
- /**
- * Generate monthly reports for all users with referrals
- *
- * @return void
- */
- public function generateMonthlyReports(): void
- {
- $first_day = date('Y-m-01', strtotime('last month'));
- $last_day = date('Y-m-t', strtotime('last month'));
-
- // Get all users who had referral activity last month
- $users = $this->wpdb->get_col($this->wpdb->prepare(
- "SELECT DISTINCT referrer_user_id
- FROM {$this->table_usage}
- WHERE registered_at BETWEEN %s AND %s
- OR first_order_at BETWEEN %s AND %s",
- $first_day, $last_day, $first_day, $last_day
- ));
-
- if (empty($users)) {
- return;
- }
-
- // Queue report generation
- $queue = JVB()->queue();
- $queue->queueOperation(
- 'generate_referral_report',
- 0,
- [
- 'users' => $users,
- 'period_start' => $first_day,
- 'period_end' => $last_day
- ],
- [
- 'count' => count($users),
- 'chunk_key' => 'users',
- 'chunk_size' => 10,
- 'priority' => 'low'
- ]
- );
- }
-
- /**
- * Generate report for a single user
- *
- * @param int $user_id User ID
- * @param string $period_start Start date
- * @param string $period_end End date
- * @return array|WP_Error
- */
- public function generateUserReport(int $user_id, string $period_start, string $period_end): array|WP_Error
- {
- $user = get_userdata($user_id);
- if (!$user) {
- return new WP_Error('invalid_user', 'Invalid user');
- }
-
- $code = $this->getUserReferralCode($user_id);
- if (!$code) {
- return new WP_Error('no_code', 'User has no referral code');
- }
-
- // Get activity for period
- $activity = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT * FROM {$this->table_usage}
- WHERE referrer_user_id = %d
- AND (
- (registered_at BETWEEN %s AND %s)
- OR (first_order_at BETWEEN %s AND %s)
- )
- ORDER BY registered_at DESC",
- $user_id, $period_start, $period_end, $period_start, $period_end
- ), ARRAY_A);
-
- // Generate CSV
- $csv_path = $this->generateActivityCSV($user_id, $activity, $period_start, $period_end);
-
- // Send email with CSV attachment
- $this->sendMonthlyReportEmail($user, $activity, $csv_path, $period_start, $period_end);
-
- return [
- 'success' => true,
- 'user_id' => $user_id,
- 'activity_count' => count($activity)
- ];
- }
-
- /**
- * Generate CSV file for activity
- *
- * @param int $user_id User ID
- * @param array $activity Activity records
- * @param string $period_start Start date
- * @param string $period_end End date
- * @return string File path
- */
- protected function generateActivityCSV(int $user_id, array $activity, string $period_start, string $period_end): string
- {
- $upload_dir = wp_upload_dir();
- $filename = sprintf(
- 'referral-report-%d-%s-to-%s.csv',
- $user_id,
- $period_start,
- $period_end
- );
- $filepath = $upload_dir['basedir'] . '/referral-reports/' . $filename;
-
- // Create directory if needed
- wp_mkdir_p(dirname($filepath));
-
- $fp = fopen($filepath, 'w');
-
- // Headers
- fputcsv($fp, [
- 'Date',
- 'Type',
- 'Email',
- 'User ID',
- 'Status',
- 'Order Amount',
- 'Reward Earned'
- ]);
-
- // Data rows
- foreach ($activity as $record) {
- $type = $record['registered_at'] ? 'Registration' : 'Click';
- if ($record['first_order_at']) {
- $type = 'First Order';
- }
-
- fputcsv($fp, [
- $record['registered_at'] ?? $record['clicked_at'],
- $type,
- $record['email'],
- $record['referred_user_id'] ?? 'N/A',
- $record['first_order_at'] ? 'Converted' : ($record['registered_at'] ? 'Registered' : 'Pending'),
- $record['first_order_amount'] ?? 'N/A',
- $this->getRewardForUsage($record['id'])
- ]);
- }
-
- fclose($fp);
- return $filepath;
- }
-
- /**
- * Get reward amount for usage record
- *
- * @param int $usage_id Usage ID
- * @return string Formatted amount or N/A
- */
- protected function getRewardForUsage(int $usage_id): string
- {
- $usage = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->table_usage} WHERE id = %d",
- $usage_id
- ), ARRAY_A);
-
- if (!$usage || !$usage['referred_user_id']) {
- return 'N/A';
- }
-
- $reward = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT amount FROM {$this->table_rewards}
- WHERE user_id = %d AND related_user_id = %d
- AND reward_type = 'referrer'",
- $usage['referrer_user_id'],
- $usage['referred_user_id']
- ));
-
- return $reward ? '$' . number_format($reward, 2) : 'N/A';
- }
-
- /**
- * Send monthly report email
- *
- * @param object $user User object
- * @param array $activity Activity records
- * @param string $csv_path Path to CSV file
- * @param string $period_start Start date
- * @param string $period_end End date
- * @return bool
- */
- protected function sendMonthlyReportEmail($user, array $activity, string $csv_path, string $period_start, string $period_end): bool
- {
- $total_clicks = count(array_filter($activity, fn($a) => !empty($a['clicked_at'])));
- $total_registrations = count(array_filter($activity, fn($a) => !empty($a['registered_at'])));
- $total_orders = count(array_filter($activity, fn($a) => !empty($a['first_order_at'])));
-
- $total_earned = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT SUM(r.amount) FROM {$this->table_rewards} r
- INNER JOIN {$this->table_usage} u ON r.related_user_id = u.referred_user_id
- WHERE r.user_id = %d
- AND r.reward_type = 'referrer'
- AND r.created_at BETWEEN %s AND %s",
- $user->ID, $period_start, $period_end
- ));
-
- $subject = sprintf(
- 'Your Referral Report for %s',
- date('F Y', strtotime($period_start))
- );
-
- $message = sprintf(
- "Hi %s,\n\n" .
- "Here's your referral activity summary for %s:\n\n" .
- "📊 Activity Overview:\n" .
- "- Clicks: %d\n" .
- "- New Registrations: %d\n" .
- "- First Orders: %d\n" .
- "- Total Earned: $%.2f\n\n" .
- "Your current reward balance: $%.2f\n\n" .
- "Detailed activity is attached as a CSV file.\n\n" .
- "Keep sharing your referral link to earn more rewards!\n" .
- "Your link: %s\n\n" .
- "Thanks,\n%s",
- $user->display_name,
- date('F Y', strtotime($period_start)),
- $total_clicks,
- $total_registrations,
- $total_orders,
- floatval($total_earned),
- $this->getAvailableBalance($user->ID),
- $this->getReferralUrl($this->getUserReferralCode($user->ID)['code']),
- get_bloginfo('name')
- );
-
- return wp_mail(
- $user->user_email,
- $subject,
- $message,
- ['Content-Type: text/plain; charset=UTF-8'],
- [$csv_path]
- );
- }
-
- /************************************************************
- * Settings & Configuration
- ************************************************************/
-
- /**
- * Get referral reward settings
- *
- * @return array Settings
- */
- public function getRewardSettings(): array
- {
- $defaults = [
- 'referrer_reward_type' => self::DEFAULT_REFERRER_REWARD_TYPE,
- 'referrer_reward_amount' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
- 'referrer_reward_per_user' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
- 'referred_reward_type' => self::DEFAULT_REFERRED_REWARD_TYPE,
- 'referred_reward_amount' => self::DEFAULT_REFERRED_REWARD_AMOUNT
- ];
-
- // Get from options (can be customized in admin settings)
- $saved = get_option(BASE . 'referral_settings', []);
-
- return array_merge($defaults, $saved);
- }
-
- /**
- * Update referral reward settings
- *
- * @param array $settings New settings
- * @return bool
- */
- public function updateRewardSettings(array $settings): bool
- {
- $valid_settings = [];
-
- if (isset($settings['referrer_reward_type'])) {
- $valid_settings['referrer_reward_type'] = in_array($settings['referrer_reward_type'], ['per_user', 'flat_total'])
- ? $settings['referrer_reward_type']
- : self::DEFAULT_REFERRER_REWARD_TYPE;
- }
-
- if (isset($settings['referrer_reward_amount'])) {
- $valid_settings['referrer_reward_amount'] = max(0, floatval($settings['referrer_reward_amount']));
- }
-
- if (isset($settings['referrer_reward_per_user'])) {
- $valid_settings['referrer_reward_per_user'] = max(0, floatval($settings['referrer_reward_per_user']));
- }
-
- if (isset($settings['referred_reward_type'])) {
- $valid_settings['referred_reward_type'] = in_array($settings['referred_reward_type'], ['percentage', 'fixed'])
- ? $settings['referred_reward_type']
- : self::DEFAULT_REFERRED_REWARD_TYPE;
- }
-
- if (isset($settings['referred_reward_amount'])) {
- $valid_settings['referred_reward_amount'] = max(0, floatval($settings['referred_reward_amount']));
- }
-
- return update_option(BASE . 'referral_settings', $valid_settings);
- }
-
- /************************************************************
- * Helper Methods
- ************************************************************/
-
- /**
- * Generate unique referral code
- *
- * @param int $user_id User ID
- * @return string Unique code
- */
- protected function generateUniqueCode(int $user_id): string
- {
- $user = get_userdata($user_id);
- $base = strtoupper(substr($user->user_login, 0, 6));
- $code = $base . rand(1000, 9999);
-
- // Ensure uniqueness
- while ($this->isCodeTaken($code)) {
- $code = $base . rand(1000, 9999);
- }
-
- return $code;
- }
-
- /**
- * Sanitize referral code
- *
- * @param string $code Raw code
- * @return string Sanitized code
- */
- protected function sanitizeCode(string $code): string
- {
- return strtoupper(preg_replace('/[^A-Z0-9]/', '', strtoupper($code)));
- }
-
- /**
- * Check if code is already taken
- *
- * @param string $code Code to check
- * @param int|null $exclude_user_id User ID to exclude
- * @return bool
- */
- protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
- {
- $sql = "SELECT COUNT(*) FROM {$this->table_codes} WHERE code = %s";
- $params = [$code];
-
- if ($exclude_user_id) {
- $sql .= " AND user_id != %d";
- $params[] = $exclude_user_id;
- }
-
- $count = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
- return $count > 0;
- }
-
- /**
- * Validate code format
- *
- * @param string $code Code to validate
- * @return bool
- */
- protected function validateCodeFormat(string $code): bool
- {
- return preg_match('/^[A-Z0-9]{4,20}$/', $code);
- }
-
- /**
- * Validate user ID
- *
- * @param int $user_id User ID
- * @return bool
- */
- protected function validateUser(int $user_id): bool
- {
- return get_userdata($user_id) !== false;
- }
-
- /**
- * Get referral URL for code
- *
- * @param string $code Referral code
- * @return string Full URL
- */
- protected function getReferralUrl(string $code): string
- {
- return add_query_arg('ref', $code, home_url('/register'));
- }
-
- /**
- * Get client IP address
- *
- * @return string IP address
- */
- protected function getClientIp(): string
- {
- $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ??
- $_SERVER['HTTP_X_FORWARDED_FOR'] ??
- $_SERVER['REMOTE_ADDR'] ??
- '0.0.0.0';
-
- return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
- }
-
- /**
- * Notify referrer of new registration
- *
- * @param int $referrer_id Referrer user ID
- * @param int $referred_id Referred user ID
- * @return void
- */
- protected function notifyReferrer(int $referrer_id, int $referred_id): void
- {
- JVB()->notification()->addNotification(
- $referrer_id,
- 'referral_signup',
- $referred_id,
- 'Someone signed up using your referral code!'
- );
- }
-
- /**
- * Cleanup expired/old records
- *
- * @return void
- */
- public function cleanupExpiredCodes(): void
- {
- // Delete clicks older than 90 days with no registration
- $this->wpdb->query(
- "DELETE FROM {$this->table_usage}
- WHERE clicked_at < DATE_SUB(NOW(), INTERVAL 90 DAY)
- AND registered_at IS NULL"
- );
- }
-
- /**
- * Handle bulk operations
- *
- * @param mixed $result Default result
- * @param object $operation Operation object
- * @param array $data Operation data
- * @return mixed
- */
- public function processOperation($result, object $operation, array $data)
- {
- if ($operation->type === 'generate_referral_report') {
- $user_id = $data['users'][$operation->progress_count] ?? null;
-
- if ($user_id) {
- return $this->generateUserReport(
- $user_id,
- $data['period_start'],
- $data['period_end']
- );
- }
- }
-
- return $result;
- }
-}
diff --git a/inc/managers/RegisterManager.php b/inc/managers/RegisterManager.php
deleted file mode 100644
index 185a1d1..0000000
--- a/inc/managers/RegisterManager.php
+++ /dev/null
@@ -1,1298 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\meta\MetaManager;
-use WP_Error;
-use WP_User;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-class RegisterManager
-{
- private array|null $invitation_data = null;
- protected array $inviteData = [];
- private array $allowed_file_types = [
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'application/pdf'
- ];
- private int $max_file_size = 5242880; // 5MB in bytes
-
-
- public function __construct()
- {
- // Hide default fields and add our customizations
- add_action('login_enqueue_scripts', array($this, 'registrationStyles'));
- add_action('register_form', array($this, 'addRegistrationFields'));
- add_action('login_header', array($this, 'addRegistrationScript'));
-
- // Handle the registration
- add_filter('registration_errors', array($this, 'registrationErrorsFilter'), 10, 3);
- add_action('user_register', array($this, 'saveRegistrationFields'), 999, 2);
-
- // Modify default registration fields
- add_action('login_head', array($this, 'modifyRegistrationForm'));
-
- // Add support for file uploads in the form
- add_action('register_form', array($this, 'addUploadSupport'));
-
- add_filter('login_message', array($this, 'loginMessage'), 999, 1);
-
-
- add_filter('pre_user_login', array($this, 'setUserLogin'), 1);
- add_filter('pre_user_email', array($this, 'setUserEmail'), 1);
-
- // Remove the default username requirement
- remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
-
- // Add this new filter for registration message
- add_filter('register_message', array($this, 'customRegisterMessage'));
- add_filter('wp_login_errors', array($this, 'registrationSuccessMessage'), 10, 2);
- }
-
-
- /**
- * @param string $message
- *
- * @return string
- */
- public function loginMessage(string $message):string
- {
- if (array_key_exists('action', $_GET) && $_GET['action'] == 'register') {
- if ($this->fromInvite()) {
- $data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
- $name = $data->name;
- $inviters = json_decode($data->inviters, true);
- $names = [];
- foreach ($inviters as $inviter) {
- $artist = jvbContentFromUser((int)$inviter['user_id']);
- $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
- }
- $message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and inivited you to join in!';
- return '<h2>Join the Scene, '.$name.'</h2>
- <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
- }
- if ($this->fromFavourites()) {
- return '<h2>Join the scene; keep your collection.</h2>';
- }
- return '<h2>Join the Scene</h2>';
- } else {
- return '<h2>Enter the Scene</h2>';
- }
-
- }
-
- /**
- * @param string $login
- *
- * @return string
- */
- public function setUserLogin(string $login):string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $login;
- }
-
- /**
- * @param string $email
- *
- * @return string
- */
- public function setUserEmail(string $email):string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $email;
- }
-
- /**
- * @return void
- */
- public function modifyRegistrationForm():void
- {
- if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
- return;
- }
-
- ?>
- <script type="text/javascript">
- document.addEventListener('DOMContentLoaded', function() {
- // Hide default fields
- const defaultFields = document.getElementById('registerform').querySelectorAll('p');
- defaultFields.forEach(field => {
- if (field.querySelector('label[for="user_login"]') ||
- field.querySelector('label[for="user_email"]')) {
- field.remove();
- }
- });
-
- // Hide the default registration info text
- const regInfo = document.querySelector('.message.register');
- if (regInfo) {
- regInfo.style.display = 'none';
- }
-
- <?php
- if ($this->fromInvite()) {
- $this->handleArtistInvitation();
- }
- ?>
- // Move submit button to the end of the form
- const submitButton = document.getElementById('registerform').querySelector('.submit');
- if (submitButton) {
- document.getElementById('registerform').appendChild(submitButton);
- }
- });
- </script>
-
- <?php
- }
-
- /**
- * @return void
- */
- protected function handleArtistInvitation():void
- {
- $token = sanitize_text_field($_GET['invite']);
- $email = sanitize_email($_GET['email']);
- $data = JVB()->routes('invites')->verifyInvitation($token, $email);
-
- ?>
- document.querySelector('input#artist').checked = true;
- document.querySelector('#artist_first_name').value = '<?=$data->name?>';
- document.querySelector('#artist_email').value = '<?=$email?>';
- <?php
- if ($data->to_shop) {
- ?>
- document.querySelector('#artist_shop').value = '<?=$data->shop?>';
- <?php
- }
- ?>
- let form = document.getElementById('registerform')
- let input = document.createElement('input');
- let email = input.cloneNode(true);
- input.type = 'hidden';
- input.name = 'invite_token';
- input.value = '<?= $token ?>';
- email.type = 'hidden';
- email.name = 'invite_email';
- email.value = '<?= $email?>';
- form.append(input);
- form.append(email);
- <?php
- }
-
- /**
- * @return void
- */
- public function addUploadSupport():void
- {
- // Add enctype to the form for file uploads
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const form = document.getElementById('registerform');
- if (form) {
- form.enctype = 'multipart/form-data';
- }
- });
- </script>
- <?php
- }
-
- /**
- * @return void
- */
- public function registrationStyles():void
- {
- if (isset($_GET['action']) && $_GET['action'] === 'favourites') {
- ?>
- <style>
- .benefits {
- background: rgba(255, 0, 128, 0.05);
- padding: 1.5rem;
- border-radius: 4px;
- margin: 1rem 0 2rem;
- }
-
- .benefits h3 {
- color: #FF0080;
- margin: 0 0 1rem;
- font-size: 1.1rem;
- }
-
- .benefits ul {
- margin: 0;
- padding-left: 1.5rem;
- }
-
- .benefits li {
- margin-bottom: 0.5rem;
- color: #666;
- }
-
- .benefits li:last-child {
- margin-bottom: 0;
- }
-
- /* Make the form more focused for this flow */
- .field-group {
- max-width: 400px;
- margin: 0 auto;
- }
- </style>
- <?php
- }
- if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
- return;
- }
- ?>
- <style>
- /* Hide the default registration fields initially */
- #registerform > p:not(.submit) {
- display: none;
- }
-
- /* Registration form specific styles */
- #registerform {
- padding: 2rem !important;
- }
-
- .registration-intro {
- text-align: center;
- margin-bottom: 2rem !important;
- }
-
- .registration-intro h2 {
- margin: 0 0 1rem !important;
- font-size: 1.5rem !important;
- }
-
- .registration-intro p {
- color: #666 !important;
- margin: 0 !important;
- }
-
- .user-type-section {
- margin-bottom: 2rem !important;
- }
- .user-type-section p {
- font-size: 1.4rem;
- line-height: 1.4;
- font-weight: bolder;
- }
-
- .user-type-selection {
- display: flex !important;
- gap: 1rem !important;
- margin-bottom: 1rem !important;
- }
-
- .user-type-option {
- flex: 1 !important;
- text-align: center !important;
- padding: 1rem !important;
- border: 2px solid #ddd !important;
- border-radius: 4px !important;
- cursor: pointer !important;
- transition: all 0.3s ease !important;
- }
-
- .user-type-option:hover {
- border-color: var(--primary) !important;
- transform: translateY(-2px) !important;
- }
-
- .user-type-option.selected {
- border-color: var(--primary) !important;
- background: var(--primary) !important;
- color: white !important;
- }
-
- .user-type-option h3 {
- margin: 0 0 0.5rem !important;
- font-size: 1.2rem !important;
- }
-
- .user-type-option p {
- margin: 0 !important;
- font-size: 0.9rem !important;
- }
-
- .field-group {
- display: none;
- animation: fadeIn 0.3s ease;
- }
-
- .field-group.active {
- display: block !important;
- }
-
- @keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
- }
-
- /* Field styles */
- .login form textarea,
- .login form .input,
- .login select {
- font-size: 16px !important;
- padding: 12px !important;
- border: 2px solid #ddd !important;
- border-radius: 4px !important;
- margin: 5px 0 15px !important;
- width: 100% !important;
- box-sizing: border-box !important;
- background: white !important;
- }
-
- .login form textarea,
- .login form .input:focus,
- .login select:focus {
- border-color: var(--primary) !important;
- outline: none !important;
- }
-
- .login select {
- height: auto !important;
- padding-right: 30px !important;
- background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='https://www.w3.org/2000/svg'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
- background-repeat: no-repeat !important;
- background-position: right 8px center !important;
- background-size: 16px !important;
- -webkit-appearance: none !important;
- -moz-appearance: none !important;
- appearance: none !important;
- }
-
- /* Required field indicator */
- .required-field::after {
- content: '*' !important;
- color: var(--error) !important;
- margin-left: 4px !important;
- }
-
- /* Submit button styling */
- .wp-core-ui .button-primary {
- width: 100% !important;
- margin-top: 1rem !important;
- text-transform: uppercase !important;
- }
-
- /* Error messages */
- #login_error {
- border-left-color: var(--error) !important;
- }
-
- /* Responsive adjustments */
- @media screen and (max-width: 480px) {
- .user-type-selection {
- flex-direction: column !important;
- }
- }
-
- /* File upload styling */
- .file-upload-container {
- margin-bottom: 1.5rem !important;
- }
-
- .file-upload-label {
- display: block !important;
- margin-bottom: 0.5rem !important;
- }
-
- .file-upload-wrapper {
- position: relative !important;
- border: 2px dashed #ddd !important;
- border-radius: 4px !important;
- padding: 1.5rem !important;
- text-align: center !important;
- transition: all 0.3s ease !important;
- background: #f9f9f9 !important;
- }
-
- .file-upload-wrapper:hover {
- border-color: var(--primary) !important;
- background: #fff !important;
- }
-
- .file-upload-wrapper input[type="file"] {
- position: absolute !important;
- left: 0 !important;
- top: 0 !important;
- width: 100% !important;
- height: 100% !important;
- opacity: 0 !important;
- cursor: pointer !important;
- }
-
- .file-upload-text {
- color: #666 !important;
- margin: 0 !important;
- }
-
- .file-upload-text strong {
- color: var(--primary) !important;
- text-decoration: underline !important;
- }
-
- .file-preview {
- display: none;
- margin-top: 1rem !important;
- }
-
- .file-preview.active {
- display: block !important;
- }
-
- .file-preview-content {
- display: flex !important;
- align-items: center !important;
- padding: 0.5rem !important;
- background: #fff !important;
- border: 1px solid #ddd !important;
- border-radius: 4px !important;
- }
-
- .file-preview-name {
- flex-grow: 1 !important;
- margin-right: 1rem !important;
- }
-
- .file-preview-remove {
- background: none !important;
- border: none !important;
- color: var(--error) !important;
- cursor: pointer !important;
- padding: 0.25rem 0.5rem !important;
- font-size: 0.9rem !important;
- }
-
- .file-error {
- color: var(--error) !important;
- font-size: 0.9rem !important;
- margin-top: 0.5rem !important;
- display: none;
- }
-
- .file-error.active {
- display: block !important;
- }
-
- .user-type-section {
- display: flex;
- justify-content: space-between;
- align-items: stretch;
- gap: .5rem;
- flex-wrap: wrap;
- }
- .user-type-section input[type=radio] {
- position: absolute;
- left: -300vw;
- }
- .user-type-section p {
- width: 100%;
- max-height: 0;
- transform: scaleY(0);
- transform-origin: top;
- visibility: hidden;
- transition: max-height var(--timing) var(--function);
- transition-property: max-height, transform;
- position: absolute;
- text-align: center;
- }
- .user-type-section input#enthusiast:checked ~ p.enthusiast {
- max-height: 100%;
- transform: scaleY(1);
- visibility: visible;
- transition: max-height var(--timing) var(--function);
- transition-property: max-height, transform;
- position: relative;
- }
- .user-type-section input#artist:checked ~ p.artist {
- max-height: 100%;
- transform: scaleY(1);
- visibility: visible;
- transition: max-height var(--timing) var(--function);
- transition-property: max-height, transform;
- position: relative;
- }
- .user-type-section input#partner:checked ~ p.partner {
- max-height: 100%;
- transform: scaleY(1);
- visibility: visible;
- transition: max-height var(--timing) var(--function);
- transition-property: max-height, transform;
- position: relative;
- }
- .login .user-type-section label:not([for="subscriber"]) {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- border: 2px solid transparent;
- border-radius: 1rem;
- padding: .5rem;
- flex: 1;
- text-align: center;
- }
- .login .user-type-section label h4 {
- font-size: 1.1rem;
- font-weight: normal;
- }
- .login .user-type-section label:hover,
- .login .user-type-section :checked + label {
- border-color: #FF0080;
- /*background-color: #222222!important;*/
- /*color: #f9f9f9!important;*/
- }
- </style>
- <?php
- }
-
- /**
- * @return void
- */
- public function addRegistrationScript():void
- {
- if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
- return;
- }
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
-
- // Initialize user type selection
- function initUserTypeSelection() {
- // Get all radio buttons with name="user_type"
- const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
- const fieldGroups = document.querySelectorAll('.field-group');
-
- userTypeRadios.forEach(radio => {
- radio.addEventListener('change', function() {
- // Hide all field groups first
- fieldGroups.forEach(group => group.classList.remove('active'));
-
- // Show the selected field group
- const selectedType = this.value;
- const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- });
- });
-
- // Show initial field group if a radio is already selected (e.g., on form reload)
- const checkedRadio = document.querySelector('input[name="user_type"]:checked');
- if (checkedRadio) {
- const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- }
- }
-
- // Initialize shop selection
- function initShopSelection() {
- let form = document.getElementById('registerform');
- form.addEventListener('change', (e) => {
- if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
- let next = e.target.parentNode.nextElementSibling;
- let input = next.querySelector('input');
-
- if(e.target.value === 'other'){
- next.style.display = 'block';
- next.style.animation = 'fadeIn 0.3s ease';
- input.required = true;
- input.focus();
- }else{
- input.required = false;
- input.value = '';
- }
- }
- });
- }
-
- // Initialize file upload handling
- function initFileUpload() {
- const fileInput = document.getElementById('certification_file');
- const filePreview = document.querySelector('.file-preview');
- const filePreviewName = document.querySelector('.file-preview-name');
- const fileError = document.querySelector('.file-error');
- const removeButton = document.querySelector('.file-preview-remove');
-
- if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
- return;
- }
-
- const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
-
- fileInput.addEventListener('change', function(e) {
- const file = e.target.files[0];
- fileError.classList.remove('active');
-
- if (file) {
- const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
- const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
-
- if (!validTypes.includes(fileExtension)) {
- showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- fileInput.value = '';
- return;
- }
-
- if (file.size > maxSize) {
- showError('File size must be less than 5MB');
- fileInput.value = '';
- return;
- }
-
- filePreviewName.textContent = file.name;
- filePreview.classList.add('active');
- } else {
- filePreview.classList.remove('active');
- }
- });
-
- removeButton.addEventListener('click', function() {
- fileInput.value = '';
- filePreview.classList.remove('active');
- fileError.classList.remove('active');
- });
-
- function showError(message) {
- fileError.textContent = message;
- fileError.classList.add('active');
- filePreview.classList.remove('active');
- }
- }
-
- // Initialize all components
- initUserTypeSelection();
- initShopSelection();
- initFileUpload();
- });
- </script>
- <?php
- }
-
- /**
- * @return void
- */
- public function addRegistrationFields():void
- {
- // Get list of tattoo shops from your custom post type
- $shops = get_terms(array(
- 'taxonomy' => 'jvb_shop',
- 'hide_empty' => true
- ));
-
- // Get list of cities from your taxonomy
- $cities = get_terms(array(
- 'taxonomy' => 'jvb_city',
- 'hide_empty' => false,
- ));
-
- echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
- ?>
- <div class="registration-intro">
-
- <p><b>No algorithm.</b> <b>No BS.</b> <b>Just Art.</b></p>
- <p>Drop by. Get Lost. Find your next artist.</p>
-
- <?php
- if ($this->fromFavourites()) {
- ?>
-
- <div class="favourites-login-message">
- <ul class="benefits-list">
- <li>Save designs you love</li>
- <li>Get personalized recommendations</li>
- <li>Connect with artists</li>
- <li>Build your inspiration collection</li>
- <li>Bonus: It's all free!</li>
- </ul>
- </div>
- <?php
- }
- ?>
- </div>
- <h3 style="font-size:1rem;font-weight:normal;text-align:center;color:#ff0080;">Choose how you wish to interact with the community:</h3>
- <div class="user-type-section">
- <input type="radio" id="subscriber" name="user_type" value="subscriber" required checked>
- <label for="subscriber"></label>
- <input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
- <label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
- <input type="radio" id="artist" name="user_type" value="artist" required>
- <label for="artist"><?=jvbIcon('drop-simple', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
- <input type="radio" id="partner" name="user_type" value="partner" required>
- <label for="partner"><?=jvbIcon('currency-circle-dollar', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
- <p class="enthusiast">Save your favourites. Get notified.</p>
- <p class="artist">Show off your work.</p>
- <p class="partner">Support the community.</p>
- </div>
-
- <!-- Enthusiast Fields -->
- <div class="field-group" data-type="enthusiast">
- <h4>Welcome to the scene.</h4>
- <p>Sign up with your email to:</p>
- <ul>
- <li>Save your favourites for easy access</li>
- <li>Get notified when your favourite artists add new content</li>
- <li>Stay in the loop with local flash days and events</li>
- <li>Discover styles and artists that match your vision</li>
- </ul>
- <p>
- <label for="enthusiast_first_name" class="required-field">First Name</label>
- <input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
- </p>
- <p>
- <label for="enthusiast_email" class="required-field">Email</label>
- <input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
- </p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
-
- <!-- Artist Fields -->
- <div class="field-group" data-type="artist">
- <h4>Welcome to the scene!</h4>
- <p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
- <ul>
- <li>you are who you say you are</li>
- <li>you work at the shop you listed</li>
- <li>your certification</li>
- </ul>
- <p>
- <label for="artist_first_name" class="required-field">First Name</label>
- <input type="text" id="artist_first_name" name="artist_first_name" class="input">
- </p>
- <p>
- <label for="artist_last_name" class="required-field">Last Name</label>
- <input type="text" id="artist_last_name" name="artist_last_name" class="input">
- </p>
- <p>
- <label for="artist_email" class="required-field">Email</label>
- <input type="email" id="artist_email" name="artist_email" class="input">
- </p>
- <p>
- <label for="artist_shop" class="required-field">Shop</label>
- <select id="artist_shop" name="artist_shop" class="input">
- <option value="">Select a shop</option>
- <option value="other">Add New Shop</option>
- <?php foreach ($shops as $shop) : ?>
- <option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_shop_field" style="display: none;">
- <label for="artist_shop_other" class="required-field">Shop Name</label>
- <input type="text" id="artist_shop_other" name="artist_shop_other" class="input"
- placeholder="Shop name">
- </p>
-
- <p>
- <label for="artist_type" class="required-field">Type</label>
- <input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
- <label for="type-tattoo-artist">Tattoo Artist</label>
- <input type="radio" id="type-piercer" name="artist_type" value="piercer">
- <label for="type-piercer">Piercer</label>
- <input type="radio" id="type-other" name="artist_type" value="other">
- <label for="type-other">Other</label>
- </p>
- <p>
- <label for="artist_city" class="required-field">City</label>
- <select id="artist_city" name="artist_city" class="input">
- <option value="">Select a city</option>
- <option value="other">Add New City</option>
- <?php foreach ($cities as $city) : ?>
- <option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_city_field" style="display: none;">
- <label for="artist_city_other" class="required-field">City Name</label>
- <input type="text" id="artist_city_other" name="artist_city_other" class="input"
- placeholder="City">
- </p>
-
- <div class="file-upload-container">
- <label class="file-upload-label">Certification or Training Documents</label>
- <p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
- <div class="file-upload-wrapper">
- <input type="file"
- name="certification_file"
- id="certification_file"
- accept=".jpg,.jpeg,.png,.gif,.pdf"
- data-max-size="<?= $this->max_file_size; ?>">
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF or PDF (max. 5MB)
- </p>
- </div>
- <div class="file-preview">
- <div class="file-preview-content">
- <span class="file-preview-name"></span>
- <button type="button" class="file-preview-remove">Remove</button>
- </div>
- </div>
- <div class="file-error"></div>
- </div>
- <p>Once you click register:</p>
- <ul>
- <li>We'll start looking into your information (usually within 24-48 hours)</li>
- <li>You'll get a password reset email</li>
- <li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
- </ul>
- <p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
-
- <!-- Partner Fields -->
- <div class="field-group" data-type="partner">
- <h4>Howdy, partner!</h4>
- <p>We appreciate your interest!</p>
- <p>edmonton.ink is a great place to showcase what you do, whether you:</p>
- <ul>
- <li>provide goods or services that tattoo artists could use</li>
- <li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
- <li>provide goods or services that folks who love tattoos could also love</li>
- </ul>
-
- <p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
- <p>
- <label for="partner_name" class="required-field">Contact Name</label>
- <input type="text" id="partner_name" name="partner_name" class="input">
- </p>
- <p>
- <label for="partner_email" class="required-field">Email</label>
- <input type="email" id="partner_email" name="partner_email" class="input">
- </p>
- <p>
- <label for="partner_business" class="required-field">Business Name</label>
- <input type="text" id="partner_business" name="partner_business" class="input">
- </p>
- <p>
- <label for="partner_website">Business Website</label>
- <input type="url" id="partner_website" name="partner_website" class="input">
- </p>
- <p>
- <label for="partner_description">Why would you be a good fit?</label>
- <textarea id="partner_description" name="partner_description" rows="8"></textarea>
- </p>
- <p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
- <p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
- </div>
-
- <?php
-
- if ($this->invitation_data) {
- // Pre-select artist type and populate email
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- // Auto-select artist radio button
- const artistRadio = document.getElementById('artist');
- if (artistRadio) {
- artistRadio.checked = true;
- artistRadio.dispatchEvent(new Event('change'));
- }
-
- // Pre-fill email
- const emailField = document.getElementById('artist_email');
- if (emailField) {
- emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
- emailField.readOnly = true;
- }
-
- // Pre-select shop
- const shopSelect = document.getElementById('artist_shop');
- if (shopSelect) {
- shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
- shopSelect.readOnly = true;
- }
- });
- </script>
- <input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
- <input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email'])?>"
- <?php
- }
- }
-
- /**
- * @param WP_Error $errors
- * @param string $sanitized_user_login
- * @param string $user_email
- *
- * @return WP_Error
- */
- public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email):WP_Error
- {
- error_log('Registration Data: '.print_r($_POST, true));
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-
- if (empty($user_type)) {
- $errors->add('user_type_error', 'Please select your user type.');
- return $errors;
- }
- // Get email based on user type
- $email_field = $user_type . '_email';
- $email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
-
- // Remove WordPress's default username error
- $errors = new WP_Error();
-
- // If this is an invited artist, validate the invitation
- $invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
- if ($invite&& $user_type === 'artist') {
- $handler = JVB()->routes('invites');
- $invitation = $handler->validateInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
-
- if (!$invitation) {
- $errors->add('invalid_invitation', 'Invalid invitation token.');
- } elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
- $errors->add('expired_invitation', 'This invitation has expired.');
- }
- }
-
- // Validate email first
- if (empty($email)) {
- $errors->add('email_error', 'Email is required.');
- } elseif (!is_email($email)) {
- $errors->add('email_error', 'Please enter a valid email address.');
- } elseif (email_exists($email)) {
- $errors->add('email_error', 'This email is already registered.');
- }
-
- switch ($user_type) {
- case 'enthusiast':
- if (empty($_POST['enthusiast_first_name'])) {
- $errors->add('first_name_error', 'First name is required.');
- }
- break;
-
- case 'artist':
- $required_fields = array(
- 'artist_first_name' => 'First name',
- 'artist_last_name' => 'Last name',
- 'artist_shop' => 'Shop',
- 'artist_city' => 'City',
- 'artist_type' => 'Type',
- );
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
-
- case 'partner':
- $required_fields = array(
- 'partner_name' => 'Contact name',
- 'partner_business' => 'Business name'
- );
-
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
- }
-
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Validate file type
- if (!in_array($file['type'], $this->allowed_file_types)) {
- $errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- }
-
- // Validate file size
- if ($file['size'] > $this->max_file_size) {
- $errors->add('file_size_error', 'File size must be less than 5MB');
- }
- }
-
-
- return $errors;
- }
-
- /**
- * @param int $user_id
- * @param array $userdata
- *
- * @return void
- */
- public function saveRegistrationFields(int $user_id, array $userdata):void
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
- if (!$user_type) {
- return;
- }
- $shop_id = $_POST['shop_id'] ?? false;
-
- // Set user role based on type
- $user = new WP_User($user_id);
-
-
- $caps = JVB()->roles();
-
- $email = false;
- $upload_dir = wp_upload_dir();
- $base_dir = $upload_dir['basedir'];
- switch ($user_type) {
- case 'artist':
- $user->set_role('jvb_artist');
- $user->remove_role('subscriber');
-
-
- $email = sanitize_email($_POST['artist_email']);
- $first = sanitize_text_field($_POST['artist_first_name']);
- $last = sanitize_text_field($_POST['artist_last_name']);
- $display_name = $first . ' ' . $last;
- // Save artist fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $first,
- 'last_name' => $last,
- 'display_name' => $display_name
- ]);
- $user = get_userdata($temp);
-
- $link = $caps->addUserLink($user, 'artist');
- $meta = new MetaManager($link, 'post');
- $meta->updateValue('first_name', $first);
- $meta->updateValue('email', $email);
-
- // If this was an invited artist, handle the invitation
- if (array_key_exists('invite_token', $_POST)) {
- $handler = JVB()->routes('invites');
- $handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
- $user->add_cap('skip_moderation', true);
- }
-
-
- if (absint($_POST['artist_shop']) > 0) {
- JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
- }
- if (absint($_POST['artist_city']) >0) {
- wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
- }
-
- //Create approval request and notify verified users
- JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
-
- //Make base directories
- $artist_dir = $base_dir . '/artists/' . $user_id;
- wp_mkdir_p($artist_dir);
- // Directories for all artists
- wp_mkdir_p($artist_dir . '/artwork');
- wp_mkdir_p($artist_dir . '/events');
- // Add a directory for profile images
- wp_mkdir_p($artist_dir . '/profile');
-
- // Add a temp directory for uploads in progress
- wp_mkdir_p($artist_dir . '/temp');
-
-
- switch ($_POST['artist_type']) {
- case 'tattoo-artist':
- $caps->setUserAs($user, 'tattoo-artist');
- $term = get_term_by('name', 'Tattoo Artists', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/tattoos');
- break;
- case 'piercer':
- $caps->setUserAs($user, 'piercer');
- $term = get_term_by('name', 'Piercers', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/piercings');
- break;
- }
-
- break;
- case 'partner':
- $user->set_role('jvb_partner');
- $user->remove_role('subscriber');
- $name = sanitize_text_field($_POST['partner_name']);
- $email = sanitize_email($_POST['partner_email']);
-
- $caps->setUserAs($user, 'partner');
- $link = $caps->addUserLink($user, 'partner');
- // Save partner fields
- update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
- update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
- update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
-
- // Create partner base directory
- $partner_dir = $base_dir . '/partners/' . $user_id;
- wp_mkdir_p($partner_dir);
-
- // Partner subdirectories
- wp_mkdir_p($partner_dir . '/offers');
- wp_mkdir_p($partner_dir . '/events');
- wp_mkdir_p($partner_dir . '/profile');
- wp_mkdir_p($partner_dir . '/temp');
- break;
-
- case 'enthusiast':
- $user->set_role('jvb_enthusiast');
- $user->remove_role('subscriber');
- $caps->setUserAs($user, 'enthusiast');
- $name = sanitize_text_field($_POST['enthusiast_first_name']);
- $email = sanitize_email($_POST['enthusiast_email']);
-
- // Save artist fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $name,
- 'user_email' => $email,
- ]);
- break;
- default:
- break;
- }
-
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Setup upload directory
- $upload_dir = wp_upload_dir();
- $user_directory = 'artist-certifications/' . $user_id;
- $target_dir = $upload_dir['basedir'] . '/' . $user_directory;
-
- // Create directory if it doesn't exist
- wp_mkdir_p($target_dir);
-
- // Generate unique filename
- $file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
- $filename = 'certification-' . time() . '.' . $file_extension;
- $target_file = $target_dir . '/' . $filename;
-
- // Move uploaded file
- if (move_uploaded_file($file['tmp_name'], $target_file)) {
- // Save file information in user meta
- update_user_meta($user_id, 'certification_file', array(
- 'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
- 'file' => $target_file,
- 'type' => $file['type'],
- 'original_name' => $file['name']
- ));
- }
- }
-
- if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
- $token = sanitize_text_field($_GET['list_token']);
- $email = sanitize_email($_GET['email']);
-
- // Accept the list invitation for this new user
- if ($email) {
- JVB()->routes('favourites')->acceptListInvitation(
- $token,
- $email,
- $user_id
- );
- }
- }
- }
-
- /**
- * @param WP_Error $errors
- * @param string $redirect_to
- *
- * @return WP_Error
- */
- public function registrationSuccessMessage(WP_Error $errors, string $redirect_to):WP_Error
- {
- if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
- // Custom message for invited artists
- $message = "WELCOME ABOARD!<br><br>" .
- "Password setup is in your inbox. <br>" .
- "Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
-
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
- if (isset($errors->errors['registered'])) {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
-
- switch ($user_type) {
- case 'enthusiast':
- $message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
- break;
-
- case 'artist':
- $message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
- break;
-
- case 'partner':
- $message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
- break;
-
- default:
- $message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
- }
-
- // Replace the default message
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
-
- return $errors;
- }
-
- /**
- * @return bool
- */
- protected function fromFavourites():bool
- {
- return isset($_GET['type']) && $_GET['type'] === 'favourites';
- }
-
- /**
- * @return bool
- */
- protected function fromInvite():bool
- {
- return isset($_GET['invite']) && isset($_GET['email']);
- }
-
- /**
- * @param string $message
- *
- * @return string
- */
- public function customRegisterMessage(string $message):string
- {
- return "Join Edmonton's tattoo community";
- }
-}
-
-// Initialize the registration customizer
-new RegisterManager();
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index 0a23c5c..1eecb1b 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/inc/managers/SEO/BreadcrumbManager.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\utility\Features;
use WP_Post;
use WP_Term;
@@ -18,14 +18,14 @@
*/
class BreadcrumbManager
{
- private CacheManager $cache;
+ private Cache $cache;
private static ?self $instance = null;
private function __construct()
{
- $this->cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS)->connectTo('all');
+ $this->cache = Cache::for('breadcrumbs', MONTH_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
@@ -65,16 +65,13 @@
break;
}
- $crumbs = $this->cache->get($key);
- if ($crumbs !== false) {
- return $crumbs;
- }
-
- $crumbs = $this->buildCrumbs();
- $crumbs = apply_filters('jvbBreadcrumbs',$crumbs);
- $this->cache->set($key, $crumbs);
-
- return $crumbs;
+ return $this->cache->remember(
+ $key,
+ function() {
+ $crumbs = $this->buildCrumbs();
+ return apply_filters('jvbBreadcrumbs',$crumbs);
+ }
+ );
}
/**
@@ -215,7 +212,7 @@
{
$url = get_term_link($term->term_id);
array_unshift($crumbs, [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'url' => $url,
'id' => $term->term_id,
]);
@@ -349,9 +346,9 @@
public function invalidateCache(?int $objectId = null): void
{
if ($objectId) {
- $this->cache->delete($objectId);
+ $this->cache->forget($objectId);
} else {
- $this->cache->clear();
+ $this->cache->flush();
}
}
@@ -374,7 +371,7 @@
}
}
$crumbs[] = [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'url' => get_term_link($term, $taxonomy)
];
}
diff --git a/inc/managers/SEO/SEOAdminPage.php b/inc/managers/SEO/SEOAdminPage.php
index 53ea618..93dea24 100644
--- a/inc/managers/SEO/SEOAdminPage.php
+++ b/inc/managers/SEO/SEOAdminPage.php
@@ -2,7 +2,6 @@
namespace JVBase\managers\SEO;
use JVBase\managers\AdminPages;
-use JVBase\managers\CacheManager;
use JVBase\meta\MetaForm;
use JVBase\ui\Tabs;
diff --git a/inc/managers/SEO/SchemaOutputManager.php b/inc/managers/SEO/SchemaOutputManager.php
index 140716b..cd8d0e5 100644
--- a/inc/managers/SEO/SchemaOutputManager.php
+++ b/inc/managers/SEO/SchemaOutputManager.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use WP_Term;
use WP_User;
@@ -16,14 +16,14 @@
* Integrates with The SEO Framework, letting it handle defaults
* while we override with our configured templates.
*
- * Now with integrated caching via CacheManager for performance.
+ * Now with integrated caching via Cache for performance.
*/
class SchemaOutputManager
{
private ConfigManager $config;
private SchemaBuilder $registry;
private ?TemplateResolver $resolver = null;
- private CacheManager $cache;
+ private Cache $cache;
private array $pseudoTypes = [
'BeforeAfter',
];
@@ -31,12 +31,10 @@
public function __construct()
{
$this->registry = SchemaBuilder::getInstance();
- $this->cache = CacheManager::for('schema');
-
- // Register cache connections
- $this->cache->connectTo('post', 'id');
- $this->cache->connectTo('taxonomy', 'id');
- $this->cache->connectTo('user', 'id');
+ $this->cache = Cache::for('schema')
+ ->connect('post',true)
+ ->connect('taxonomy',true)
+ ->connect('user',true);
// Hook into TSF for meta
add_filter('the_seo_framework_title_from_generation', [$this, 'filterTitle'], 10, 2);
diff --git a/inc/managers/SEO/TemplateResolver.php b/inc/managers/SEO/TemplateResolver.php
index 1090f9c..8b5c57d 100644
--- a/inc/managers/SEO/TemplateResolver.php
+++ b/inc/managers/SEO/TemplateResolver.php
@@ -607,8 +607,8 @@
} elseif ($this->objectType === 'term' && $this->objectId) {
$term = get_term($this->objectId);
if ($term && !is_wp_error($term)) {
- $this->context['term_name'] = $term->name;
- $this->context['term_description'] = $term->description;
+ $this->context['term_name'] = html_entity_decode($term->name);
+ $this->context['term_description'] = wptexturize($term->description);
$this->context['taxonomy'] = $term->taxonomy;
}
} elseif ($this->objectType === 'user' && $this->objectId) {
diff --git a/inc/managers/SEO/_edmonotonink.php b/inc/managers/SEO/_edmonotonink.php
index c4a356a..14120eb 100644
--- a/inc/managers/SEO/_edmonotonink.php
+++ b/inc/managers/SEO/_edmonotonink.php
@@ -463,7 +463,7 @@
foreach ((array)$similar as $similar_id) {
$term = get_term($similar_id, BASE . 'theme');
if ($term && !is_wp_error($term)) {
- $similar_names[] = $term->name;
+ $similar_names[] = html_entity_decode($term->name);
}
}
if (!empty($similar_names)) {
@@ -524,7 +524,7 @@
$term = get_term($term_id);
return [
'@type' => 'PostalAddress',
- 'addressLocality' => $term->name,
+ 'addressLocality' => html_entity_decode($term->name),
'addressRegion' => 'Alberta',
'addressCountry' => 'CA'
];
diff --git a/inc/managers/SEOMetaManager.php b/inc/managers/SEOMetaManager.php
index 44d1399..5ccbb0e 100644
--- a/inc/managers/SEOMetaManager.php
+++ b/inc/managers/SEOMetaManager.php
@@ -174,7 +174,7 @@
return $this->getCityTitle($term);
default:
- return $term->name;
+ return html_entity_decode($term->name);
}
}
@@ -556,7 +556,7 @@
}
}
- $length = strlen($term->name) + strlen($city);
+ $length = strlen(html_entity_decode($term->name)) + strlen($city);
$title = match (true) {
$length < 36 => $city . '\s Best Tattoo Studios',
@@ -565,7 +565,8 @@
$length < 46 => $city . ' Tattoo Shop',
default => 'Tattoo Shop: ',
};
- return "{$term->name} | {$title}";
+ $name = html_entity_decode($term->name);
+ return "{$name} | {$title}";
}
/**
@@ -613,7 +614,7 @@
}
// Build description
- $description = "{$term->name}";
+ $description = html_entity_decode($term->name);
if (!empty($established)) {
$description .= " has been slinging ink in {$city} since {$established}";
@@ -638,7 +639,8 @@
*/
protected function getStyleTitle(WP_Term $term):string
{
- return "Edmonton's Best {$term->name} Tattoo Artists";
+ $name = html_entity_decode($term->name);
+ return "Edmonton's Best {$name} Tattoo Artists";
}
/**
@@ -674,7 +676,8 @@
}
// Build description
- $description = "{$term->name}{$alt_name_text} is a distinctive tattoo style";
+ $name = html_entity_decode($term->name);
+ $description = "{$name}{$alt_name_text} is a distinctive tattoo style";
if (!empty($characteristics)) {
$stripped = wp_strip_all_tags($characteristics);
@@ -685,7 +688,8 @@
}
}
- $description .= " Find Edmonton artists specializing in {$term->name} tattoos.";
+ $name = html_entity_decode($term->name);
+ $description .= " Find Edmonton artists specializing in {$name} tattoos.";
return $description;
}
@@ -698,7 +702,8 @@
*/
protected function getThemeTitle(WP_Term $term):string
{
- return "Edmonton's Best {$term->name} Tattoos";
+ $name = html_entity_decode($term->name);
+ return "Edmonton's Best {$name} Tattoos";
}
/**
@@ -734,15 +739,16 @@
}
// Build description
- $description = "Explore {$term->name} tattoos";
+ $name = html_entity_decode($term->name);
+ $description = "Explore {$name} tattoos";
$description .= ", a popular motif in Edmonton's tattoo scene.";
$description .= $similar_text;
-
- $description .= " Find artists specializing in {$term->name} tattoos.";
+ $name = html_entity_decode($term->name);
+ $description .= " Find artists specializing in {$name} tattoos.";
return $description;
}
@@ -755,7 +761,8 @@
*/
protected function getCityTitle(WP_Term $term):string
{
- return "{$term->name} Tattoo Artists & Shops | edmonton.ink";
+ $name = html_entity_decode($term->name);
+ return "{$name} Tattoo Artists & Shops | edmonton.ink";
}
/**
@@ -798,7 +805,8 @@
}
// Build description
- $description = "Discover {$term->name}'s vibrant tattoo scene";
+ $name = html_entity_decode($term->name);
+ $description = "Discover {$name}'s vibrant tattoo scene";
if ($shop_count > 0 || $artist_count > 0) {
$description .= " featuring";
diff --git a/inc/managers/SchemaManager.php b/inc/managers/SchemaManager.php
index 1ba6204..5242d30 100644
--- a/inc/managers/SchemaManager.php
+++ b/inc/managers/SchemaManager.php
@@ -365,7 +365,7 @@
[
'@type' => 'LocalBusiness',
'@id' => $permalink . '#organization',
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'description' => $meta->getValue('short_bio') ?: $term->description,
'url' => $permalink,
'priceRange' => '$$', // Default price range
@@ -976,7 +976,7 @@
[
'@type' => 'CreativeWork',
'@id' => $permalink . '#style',
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'description' => $meta->getValue('characteristics') ?: $term->description,
'url' => $permalink,
'mainEntityOfPage' => [
@@ -1009,7 +1009,7 @@
$schema = [
'@type' => 'CreativeWork',
'@id' => $permalink . '#theme',
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'description' => $meta->getValue('description') ?: $term->description,
'url' => $permalink,
'mainEntityOfPage' => [
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index 8a28ebb..69a4069 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
add_action('init', 'jvbRegisterScripts', 5);
function jvbRegisterScripts() {
- $version = '1.1.23';
+ $version = '1.1.27';
$strategy = [
'strategy' => 'defer',
'in_footer' => true
diff --git a/inc/managers/TaxonomyRelationships.php b/inc/managers/TaxonomyRelationships.php
index 2f466a7..2becd42 100644
--- a/inc/managers/TaxonomyRelationships.php
+++ b/inc/managers/TaxonomyRelationships.php
@@ -1,8 +1,7 @@
<?php
namespace JVBase\managers;
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use WP_Error;
use WP_Post;
@@ -20,7 +19,7 @@
{
global $wpdb;
$this->table_name = $wpdb->prefix . BASE.'taxonomy_relationships';
- $this->cache = CacheManager::for('term_relationship', WEEK_IN_SECONDS);
+ $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS);
// Ensure the table exists
// $this->create_table_if_not_exists();
@@ -64,7 +63,6 @@
if (in_array($post_type, jvbIgnoredPostTypes())) {
return;
}
- $this->cache->invalidate();
// Get all taxonomies for this post type
$taxonomies = get_object_taxonomies($post_type);
@@ -339,7 +337,7 @@
*/
public function rebuildAllRelationships():bool
{
- $this->cache->invalidate();
+ $this->cache->flush();
global $wpdb;
// Clear existing relationships
@@ -371,7 +369,7 @@
]
);
- $this->cache->invalidate();
+ $this->cache->flush();
return true;
}
@@ -449,6 +447,5 @@
$term_id,
$term_id
));
- $this->cache->invalidate();
}
}
diff --git a/inc/managers/UmamiMetrics.php b/inc/managers/UmamiMetrics.php
deleted file mode 100644
index d88df16..0000000
--- a/inc/managers/UmamiMetrics.php
+++ /dev/null
@@ -1,1785 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
-use wpdb;
-use WP_Error;
-use Exception;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-/**
- * Handles integration with Umami.js analytics to collect and provide metrics
- */
-class UmamiMetrics
-{
- protected wpdb $wpdb;
- protected string $api_url = 'https://cloud.umami.is/api';
- protected string $website_id;
- protected string $events_table;
- protected string $metrics_table;
- protected CacheManager $cache;
-
- /**
- * Constructor
- */
- public function __construct()
- {
- global $wpdb;
- $this->wpdb = $wpdb;
- $this->events_table = $wpdb->prefix . BASE . 'umami_events';
- $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
-
- // Get Umami website ID from options
- $this->website_id = get_option('jvb_umami_website_id', UMAMI_WEBSITE_ID);
-
- // Initialize cache manager
- $this->cache = CacheManager::for('umami_metrics', DAY_IN_SECONDS);
-
- // Register hooks
- add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
- }
-
- /**
- * Get authentication token for Umami API
- *
- * @return string|WP_Error Token or error
- */
- protected function getAuthToken():string|WP_Error
- {
- // Check if we have a cached token
- $token = get_transient('jvb_umami_auth_token');
- if ($token) {
- return $token;
- }
-
- // Get decrypted credentials
- $credentials = $this->getUmamiCredentials();
- if (!$credentials) {
- JVB()->error()->log(
- 'umami',
- 'Missing or invalid Umami API credentials',
- [],
- 'error'
- );
- return new WP_Error('missing_credentials', 'Umami API credentials not configured');
- }
-
- // Authenticate with Umami
- $response = wp_remote_post($this->api_url . '/auth/login', [
- 'body' => json_encode([
- 'username' => $credentials['username'],
- 'password' => $credentials['password']
- ]),
- 'headers' => [
- 'Content-Type' => 'application/json'
- ]
- ]);
-
- if (is_wp_error($response)) {
- JVB()->error()->log(
- 'umami',
- 'Failed to authenticate with Umami API: ' . $response->get_error_message(),
- [],
- 'error'
- );
- return $response;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if (empty($data['token'])) {
- JVB()->error()->log(
- 'umami',
- 'Invalid response from Umami API',
- ['response' => wp_remote_retrieve_body($response)],
- 'error'
- );
- return new WP_Error('auth_failed', 'Failed to get auth token from Umami API');
- }
-
- // Cache token for 23 hours (tokens usually expire after 24 hours)
- set_transient('jvb_umami_auth_token', $data['token'], 23 * HOUR_IN_SECONDS);
-
- return $data['token'];
- }
-
- /**
- * Save Umami API credentials
- *
- * @param string $username Umami username
- * @param string $password Umami password
- * @return bool Success or failure
- */
- public function saveUmamiCredentials(string $username, string $password):bool
- {
- // Encrypt sensitive data
- $encrypted_username = $this->encryptData($username);
- $encrypted_password = $this->encryptData($password);
-
- // Store encrypted data
- $success1 = update_option('jvb_umami_username_encrypted', $encrypted_username);
- $success2 = update_option('jvb_umami_password_encrypted', $encrypted_password);
-
- return $success1 && $success2;
- }
-
- /**
- * Get Umami API credentials
- *
- * @return array|false Credentials or false on failure
- */
- protected function getUmamiCredentials():array|false
- {
- // Get encrypted credentials
- $encrypted_username = get_option('jvb_umami_username_encrypted');
- $encrypted_password = get_option('jvb_umami_password_encrypted');
-
- if (!$encrypted_username || !$encrypted_password) {
- return false;
- }
-
- // Decrypt
- $username = $this->decryptData($encrypted_username);
- $password = $this->decryptData($encrypted_password);
-
- if (!$username || !$password) {
- return false;
- }
-
- return [
- 'username' => $username,
- 'password' => $password
- ];
- }
-
- /**
- * Securely encrypt sensitive data
- *
- * @param string $data Data to encrypt
- * @return string Encrypted data
- */
- public function encryptData(string $data):string
- {
- // Get or generate encryption key
- $encryption_key = $this->getEncryptionKey();
-
- // Generate a random initialization vector
- $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
-
- // Encrypt the data
- $encrypted = openssl_encrypt($data, 'aes-256-cbc', $encryption_key, 0, $iv);
-
- // Combine IV and encrypted data for storage
- return base64_encode($iv . $encrypted);
- }
-
- /**
- * Decrypt sensitive data
- *
- * @param string $encrypted_data Encrypted data
- * @return string|bool Decrypted data or false on failure
- */
- public function decryptData(string $encrypted_data):string|bool
- {
- // Get encryption key
- $encryption_key = $this->getEncryptionKey();
-
- // Decode from base64
- $data = base64_decode($encrypted_data);
- if (!$data) {
- return false;
- }
-
- // Extract IV and encrypted data
- $iv_length = openssl_cipher_iv_length('aes-256-cbc');
- $iv = substr($data, 0, $iv_length);
- $encrypted = substr($data, $iv_length);
-
- // Decrypt
- return openssl_decrypt($encrypted, 'aes-256-cbc', $encryption_key, 0, $iv);
- }
-
- /**
- * Get or generate the encryption key
- *
- * @return string Encryption key
- */
- protected function getEncryptionKey():string
- {
- // Try to get existing key
- $key = get_option('jvb_encryption_key');
-
- // If no key exists, generate one and store it
- if (!$key) {
- // Generate a strong 32-byte key
- $key = bin2hex(openssl_random_pseudo_bytes(32));
-
- // Store key in WordPress options
- update_option('jvb_encryption_key', $key, true);
- }
-
- return $key;
- }
-
- /**
- * Fetch data from Umami API
- *
- * @param string $endpoint API endpoint
- * @param array $params Query parameters
- * @return array|WP_Error Response data or error
- */
- protected function fetchFromApi(string $endpoint, array $params = []):array|WP_Error
- {
- $token = $this->getAuthToken();
- if (is_wp_error($token)) {
- return $token;
- }
-
- $url = $this->api_url . $endpoint;
- if (!empty($params)) {
- $url = add_query_arg($params, $url);
- }
-
- $response = wp_remote_get($url, [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $token
- ]
- ]);
-
- if (is_wp_error($response)) {
- JVB()->error()->log(
- 'umami',
- 'Error fetching from Umami API: ' . $response->get_error_message(),
- ['endpoint' => $endpoint, 'params' => $params],
- 'error'
- );
- return $response;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if (empty($data)) {
- JVB()->error()->log(
- 'umami',
- 'Empty response from Umami API',
- ['endpoint' => $endpoint, 'response' => wp_remote_retrieve_body($response)],
- 'warning'
- );
- return new WP_Error('empty_response', 'Empty response from Umami API');
- }
-
- return $data;
- }
-
- /**
- * Collect daily analytics data from Umami and store in database
- *
- * @param string|null $date Optional date to collect (defaults to yesterday)
- * @return array Collection results
- */
- public function collectDailyData(string|null $date = null):array
- {
- // Default to yesterday if no date provided
- if (empty($date)) {
- $date = date('Y-m-d', strtotime('-1 day'));
- }
-
- $start_time = microtime(true);
- $results = [
- 'date' => $date,
- 'events_collected' => 0,
- 'metrics_updated' => 0,
- 'errors' => []
- ];
-
- try {
- // Fetch custom events from Umami
- $events_data = $this->fetchCustomEvents($date);
-
- if (is_wp_error($events_data)) {
- $results['errors'][] = 'Failed to fetch custom events: ' . $events_data->get_error_message();
- return $results;
- }
-
- // Process and store events
- $events_stored = $this->storeEvents($events_data, $date);
- $results['events_collected'] = count($events_stored);
-
- // Generate aggregated metrics
- $metrics_created = $this->generateDailyMetrics($date);
- $results['metrics_updated'] = $metrics_created;
-
- // Log success
- $duration = round(microtime(true) - $start_time, 2);
- $results['duration'] = $duration;
-
- JVB()->error()->log(
- 'umami',
- 'Successfully collected Umami data',
- [
- 'date' => $date,
- 'events' => count($events_stored),
- 'metrics' => $metrics_created,
- 'duration' => $duration
- ],
- 'info'
- );
-
- // Clear cache for the processed date
- $this->cache->invalidate();
- } catch (Exception $e) {
- $results['errors'][] = 'Exception during data collection: ' . $e->getMessage();
-
- JVB()->error()->log(
- 'umami',
- 'Exception during Umami data collection: ' . $e->getMessage(),
- ['date' => $date],
- 'error'
- );
- }
-
- return $results;
- }
-
- /**
- * Fetch custom events from Umami
- *
- * @param string $date Date to fetch in YYYY-MM-DD format
- * @return array|WP_Error Events data or error
- */
- protected function fetchCustomEvents(string $date):array|WP_Error
- {
- // Calculate start and end timestamps for the specified date
- $start_time = strtotime($date . ' 00:00:00');
- $end_time = strtotime($date . ' 23:59:59');
-
- return $this->fetchFromApi('/websites/' . $this->website_id . '/events', [
- 'startAt' => $start_time,
- 'endAt' => $end_time,
- 'unit' => 'day'
- ]);
- }
-
- /**
- * Store events data in the database
- *
- * @param array $events_data Events from Umami API
- * @param string $date Date of events
- * @return array Stored event IDs
- */
- protected function storeEvents(array $events_data, string $date):array
- {
- $stored_ids = [];
-
- // Start transaction
- $this->wpdb->query('START TRANSACTION');
-
- try {
- foreach ($events_data as $event) {
- // Extract data from the event
- $event_name = $event['event_name'] ?? '';
- $event_type = '';
- $user_id = null;
- $content_id = null;
- $content_type = null;
- $source_id = null;
- $source_type = null;
- $owner_id = null;
- $owner_type = null;
- $referrer = null;
- $metadata = [];
-
- // Process event data properties
- foreach ($event['event_data'] ?? [] as $key => $value) {
- switch ($key) {
- case 'type':
- $event_type = $value;
- break;
- case 'user-id':
- $user_id = (int)$value;
- break;
- case 'id':
- $content_id = (int)$value;
- break;
- case 'content-type':
- $content_type = $value;
- break;
- case 'source-id':
- $source_id = (int)$value;
- break;
- case 'source-type':
- $source_type = $value;
- break;
- case 'owner-id':
- $owner_id = (int)$value;
- break;
- case 'owner-type':
- $owner_type = $value;
- break;
- case 'from':
- $referrer = $value;
- break;
- default:
- // Store any other data as metadata
- if ( str_starts_with( $key, 'meta-' ) ) {
- $meta_key = str_replace('meta-', '', $key);
- $metadata[$meta_key] = $value;
- }
- }
- }
-
- // Insert event into database
- $result = $this->wpdb->insert(
- $this->events_table,
- [
- 'date' => $date,
- 'timestamp' => date('Y-m-d H:i:s', $event['created_at'] ?? time()),
- 'event' => $event_name,
- 'event_type' => $event_type,
- 'user_id' => $user_id,
- 'content_id' => $content_id,
- 'content_type' => $content_type,
- 'source_id' => $source_id,
- 'source_type' => $source_type,
- 'owner_id' => $owner_id,
- 'owner_type' => $owner_type,
- 'referrer' => $referrer,
- 'metadata' => !empty($metadata) ? json_encode($metadata) : null
- ]
- );
-
- if ($result) {
- $stored_ids[] = $this->wpdb->insert_id;
- }
- }
-
- // Commit transaction
- $this->wpdb->query('COMMIT');
-
- return $stored_ids;
- } catch (Exception $e) {
- // Rollback on error
- $this->wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'umami',
- 'Error storing Umami events: ' . $e->getMessage(),
- ['date' => $date],
- 'error'
- );
-
- throw $e;
- }
- }
-
- /**
- * Generate daily aggregated metrics for all users
- *
- * @param string $date Date to generate metrics for
- * @return int Number of metrics records created/updated
- */
- protected function generateDailyMetrics(string $date):int
- {
- // Start transaction
- $this->wpdb->query('START TRANSACTION');
-
- try {
- $count = 0;
-
- // First, get all users who had activity on this date
- $active_users = $this->wpdb->get_col($this->wpdb->prepare(
- "SELECT DISTINCT owner_id FROM {$this->events_table}
- WHERE date = %s AND owner_id IS NOT NULL",
- $date
- ));
-
- // Process metrics for each user
- foreach ($active_users as $user_id) {
- if ($this->generateUserMetrics($user_id, $date)) {
- $count++;
- }
- }
-
- // Also generate metrics for all shops
- $active_shops = $this->wpdb->get_col($this->wpdb->prepare(
- "SELECT DISTINCT content_id FROM {$this->events_table}
- WHERE date = %s AND event = 'view_shop' AND content_id IS NOT NULL",
- $date
- ));
-
- foreach ($active_shops as $shop_id) {
- if ($this->generateShopMetrics($shop_id, $date)) {
- $count++;
- }
- }
-
- // Commit transaction
- $this->wpdb->query('COMMIT');
-
- return $count;
- } catch (Exception $e) {
- // Rollback on error
- $this->wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'umami',
- 'Error generating metrics: ' . $e->getMessage(),
- ['date' => $date],
- 'error'
- );
-
- throw $e;
- }
- }
-
- /**
- * Generate metrics for a specific user
- *
- * @param int $user_id User ID
- * @param string $date Date to generate metrics for
- * @return bool Success or failure
- */
- protected function generateUserMetrics(int $user_id, string $date):bool
- {
- // Get the artist profile ID for this user
- $artist_id = get_user_meta($user_id, BASE . 'link', true);
- if (!$artist_id) {
- return false;
- }
-
- // Calculate metrics
- $metrics = [
- 'profile_view_count' => $this->countEvents($date, 'view_profile', $artist_id, 'content_id'),
- 'feed_view_count' => $this->countEvents($date, 'view_feed', $artist_id, 'owner_id'),
- 'taxonomy_view_count' => $this->countEvents($date, 'view_taxonomy', $artist_id, 'owner_id'),
- 'shop_view_count' => $this->countEvents($date, 'view_shop', $artist_id, 'owner_id'),
- 'favourite_count' => $this->countEvents($date, 'toggle_favourite', $artist_id, 'content_id', [
- 'metadata_condition' => "JSON_EXTRACT(metadata, '$.action') = 'add'"
- ]),
- 'upvote_count' => $this->countEvents($date, 'vote', $artist_id, 'content_id', [
- 'metadata_condition' => "JSON_EXTRACT(metadata, '$.vote') = 'up'"
- ]),
- 'downvote_count' => $this->countEvents($date, 'vote', $artist_id, 'content_id', [
- 'metadata_condition' => "JSON_EXTRACT(metadata, '$.vote') = 'down'"
- ])
- ];
-
- // Calculate total view count
- $metrics['total_view_count'] = $metrics['profile_view_count'] +
- $metrics['feed_view_count'] +
- $metrics['taxonomy_view_count'] +
- $metrics['shop_view_count'];
-
- // Calculate karma
- $metrics['karma'] = $metrics['upvote_count'] - $metrics['downvote_count'];
-
- // Get top content
- $metrics['top_content'] = $this->getTopContent($date, $user_id);
-
- // Get source breakdown
- $metrics['source_breakdown'] = $this->getSourceBreakdown($date, $artist_id);
-
- // Get top favourites
- $metrics['top_favourites'] = $this->getTopFavourites($date, $user_id);
-
- // Calculate conversion rates
- if ($metrics['total_view_count'] > 0) {
- $metrics['favourite_conversion_rate'] = round($metrics['favourite_count'] / $metrics['total_view_count'], 4);
- } else {
- $metrics['favourite_conversion_rate'] = 0;
- }
-
- // Check if record exists for this date and user
- $exists = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT id FROM {$this->metrics_table}
- WHERE date = %s AND user_id = %d",
- $date,
- $user_id
- ));
-
- if ($exists) {
- // Update existing record
- $update_data = [
- 'profile_view_count' => $metrics['profile_view_count'],
- 'feed_view_count' => $metrics['feed_view_count'],
- 'taxonomy_view_count' => $metrics['taxonomy_view_count'],
- 'shop_view_count' => $metrics['shop_view_count'],
- 'total_view_count' => $metrics['total_view_count'],
- 'favourite_count' => $metrics['favourite_count'],
- 'upvote_count' => $metrics['upvote_count'],
- 'downvote_count' => $metrics['downvote_count'],
- 'karma' => $metrics['karma'],
- 'favourite_conversion_rate' => $metrics['favourite_conversion_rate'],
- 'top_content' => json_encode($metrics['top_content']),
- 'source_breakdown' => json_encode($metrics['source_breakdown']),
- 'top_favourites' => json_encode($metrics['top_favourites'])
- ];
-
- $result = $this->wpdb->update(
- $this->metrics_table,
- $update_data,
- [
- 'date' => $date,
- 'user_id' => $user_id
- ]
- );
- } else {
- // Insert new record
- $insert_data = [
- 'date' => $date,
- 'user_id' => $user_id,
- 'profile_view_count' => $metrics['profile_view_count'],
- 'feed_view_count' => $metrics['feed_view_count'],
- 'taxonomy_view_count' => $metrics['taxonomy_view_count'],
- 'shop_view_count' => $metrics['shop_view_count'],
- 'total_view_count' => $metrics['total_view_count'],
- 'favourite_count' => $metrics['favourite_count'],
- 'upvote_count' => $metrics['upvote_count'],
- 'downvote_count' => $metrics['downvote_count'],
- 'karma' => $metrics['karma'],
- 'favourite_conversion_rate' => $metrics['favourite_conversion_rate'],
- 'top_content' => json_encode($metrics['top_content']),
- 'source_breakdown' => json_encode($metrics['source_breakdown']),
- 'top_favourites' => json_encode($metrics['top_favourites'])
- ];
-
- $result = $this->wpdb->insert($this->metrics_table, $insert_data);
- }
-
- return $result !== false;
- }
-
- /**
- * Generate metrics for a specific shop
- *
- * @param int $shop_id Shop ID (term_id)
- * @param string $date Date to generate metrics for
- * @return bool Success or failure
- */
- protected function generateShopMetrics(int $shop_id, string $date):bool
- {
- // For now, we're just counting total views
- $view_count = $this->countEvents($date, 'view_shop', $shop_id, 'content_id');
-
- // Store in a separate shop metrics table or however you prefer
- // This is a placeholder for future implementation
-
- return true;
- }
-
- /**
- * Count events matching specific criteria
- *
- * @param string $date Date to query
- * @param string $event Event name
- * @param int $id ID to match
- * @param string $id_field Field to match ID against
- * @param array $extra_conditions Additional conditions
- * @return int Count of matching events
- */
- protected function countEvents(string $date, string $event, int $id, string $id_field, array $extra_conditions = []):int
- {
- $query = $this->wpdb->prepare(
- "SELECT COUNT(*) FROM {$this->events_table}
- WHERE date = %s AND event = %s AND $id_field = %d",
- $date,
- $event,
- $id
- );
-
- // Add additional conditions
- if (!empty($extra_conditions['metadata_condition'])) {
- $query .= " AND " . $extra_conditions['metadata_condition'];
- }
-
- return (int)$this->wpdb->get_var($query);
- }
-
- /**
- * Get top content for a user
- *
- * @param string $date Date to query
- * @param int $user_id User ID
- * @return array Top content by content type
- */
- protected function getTopContent(string $date, int $user_id):array
- {
- $top_content = [];
-
- // Get content types for this user
- $content_types = $this->wpdb->get_col($this->wpdb->prepare(
- "SELECT DISTINCT content_type FROM {$this->events_table}
- WHERE date = %s AND owner_id = %d AND content_type IS NOT NULL",
- $date,
- $user_id
- ));
-
- foreach ($content_types as $type) {
- // Get top content items for this type
- $items = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT content_id, COUNT(*) as view_count
- FROM {$this->events_table}
- WHERE date = %s AND owner_id = %d AND content_type = %s AND event IN ('view_content', 'view_feed')
- GROUP BY content_id
- ORDER BY view_count DESC
- LIMIT 5",
- $date,
- $user_id,
- $type
- ));
-
- if (!empty($items)) {
- $top_content[$type] = array_map(function ($item) {
- return [
- 'id' => $item->content_id,
- 'views' => $item->view_count
- ];
- }, $items);
- }
- }
-
- return $top_content;
- }
-
- /**
- * Get source breakdown for profile views
- *
- * @param string $date Date to query
- * @param int $artist_id Artist profile ID
- * @return array Source breakdown
- */
- protected function getSourceBreakdown(string $date, int $artist_id):array
- {
- $sources = [
- 'direct' => 0,
- 'feed' => 0,
- 'taxonomy' => 0,
- 'shop' => 0,
- 'search' => 0,
- 'other' => 0
- ];
-
- // Query for referrer distribution
- $results = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT referrer, COUNT(*) as count
- FROM {$this->events_table}
- WHERE date = %s AND event = 'view_profile' AND content_id = %d
- GROUP BY referrer",
- $date,
- $artist_id
- ));
-
- foreach ($results as $row) {
- $referrer = $row->referrer ?: 'direct';
-
- switch ($referrer) {
- case 'direct':
- $sources['direct'] = $row->count;
- break;
- case 'feed':
- $sources['feed'] = $row->count;
- break;
- case 'taxonomy':
- $sources['taxonomy'] = $row->count;
- break;
- case 'shop':
- $sources['shop'] = $row->count;
- break;
- case 'search':
- $sources['search'] = $row->count;
- break;
- default:
- $sources['other'] += $row->count;
- }
- }
-
- return $sources;
- }
-
- /**
- * Get top favourited content for a user
- *
- * @param string $date Date to query
- * @param int $user_id User ID
- * @return array Top favourited content
- */
- protected function getTopFavourites(string $date, int $user_id):array
- {
- $results = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT content_id, content_type, COUNT(*) as fav_count
- FROM {$this->events_table}
- WHERE date = %s AND owner_id = %d
- AND event = 'toggle_favourite'
- AND JSON_EXTRACT(metadata, '$.action') = 'add'
- GROUP BY content_id, content_type
- ORDER BY fav_count DESC
- LIMIT 10",
- $date,
- $user_id
- ));
-
- $favourites = [];
- foreach ($results as $row) {
- if (!isset($favourites[$row->content_type])) {
- $favourites[$row->content_type] = [];
- }
-
- $favourites[$row->content_type][] = [
- 'id' => $row->content_id,
- 'count' => $row->fav_count
- ];
- }
-
- return $favourites;
- }
-
- /**
- * Get metrics for a user over a period
- *
- * @param int $user_id User ID
- * @param string $start_date Start date (YYYY-MM-DD)
- * @param string $end_date End date (YYYY-MM-DD)
- * @return array Metrics for the period
- */
- public function getUserMetrics(int $user_id, string $start_date, string $end_date):array
- {
- // Try to get from cache first
- $cache_key = "user_metrics_{$user_id}_{$start_date}_{$end_date}";
- $cached = $this->cache->get($cache_key);
- if ($cached) {
- return $cached;
- }
-
- // Query metrics for the time period
- $metrics = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT * FROM {$this->metrics_table}
- WHERE user_id = %d AND date BETWEEN %s AND %s
- ORDER BY date",
- $user_id,
- $start_date,
- $end_date
- ));
-
- // Prepare aggregated data
- $aggregated = [
- 'period' => [
- 'start' => $start_date,
- 'end' => $end_date
- ],
- 'totals' => [
- 'profile_views' => 0,
- 'feed_views' => 0,
- 'taxonomy_views' => 0,
- 'shop_views' => 0,
- 'total_views' => 0,
- 'favourites' => 0,
- 'upvotes' => 0,
- 'downvotes' => 0,
- 'karma' => 0
- ],
- 'conversion_rates' => [
- 'favourite_rate' => 0
- ],
- 'daily' => [],
- 'top_content' => $this->aggregateTopContent($metrics),
- 'source_breakdown' => $this->aggregateSourceBreakdown($metrics),
- 'top_favourites' => $this->aggregateTopFavourites($metrics)
- ];
-
- // Process each day
- foreach ($metrics as $day) {
- // Add to totals
- $aggregated['totals']['profile_views'] += $day->profile_view_count;
- $aggregated['totals']['feed_views'] += $day->feed_view_count;
- $aggregated['totals']['taxonomy_views'] += $day->taxonomy_view_count;
- $aggregated['totals']['shop_views'] += $day->shop_view_count;
- $aggregated['totals']['total_views'] += $day->total_view_count;
- $aggregated['totals']['favourites'] += $day->favourite_count;
- $aggregated['totals']['upvotes'] += $day->upvote_count;
- $aggregated['totals']['downvotes'] += $day->downvote_count;
- $aggregated['totals']['karma'] += $day->karma;
-
- // Add daily data
- $aggregated['daily'][$day->date] = [
- 'profile_views' => $day->profile_view_count,
- 'feed_views' => $day->feed_view_count,
- 'taxonomy_views' => $day->taxonomy_view_count,
- 'shop_views' => $day->shop_view_count,
- 'total_views' => $day->total_view_count,
- 'favourites' => $day->favourite_count,
- 'upvotes' => $day->upvote_count,
- 'downvotes' => $day->downvote_count,
- 'karma' => $day->karma
- ];
- }
-
- // Calculate conversion rates for the period
- if ($aggregated['totals']['total_views'] > 0) {
- $aggregated['conversion_rates']['favourite_rate'] = round(
- $aggregated['totals']['favourites'] / $aggregated['totals']['total_views'],
- 4
- );
- }
-
- // Add growth metrics
- $aggregated['growth'] = $this->calculateGrowthMetrics($user_id, $start_date, $end_date);
-
- // Cache the results
- $this->cache->set($cache_key, $aggregated);
-
- return $aggregated;
- }
-
- /**
- * Calculate growth metrics comparing to previous period
- *
- * @param int $user_id User ID
- * @param string $start_date Start date
- * @param string $end_date End date
- * @return array Growth metrics
- */
- protected function calculateGrowthMetrics(int $user_id, string $start_date, string $end_date)
- {
- // Calculate the date range length
- $current_range_days = (strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS + 1;
-
- // Calculate previous period with same length
- $prev_end_date = date('Y-m-d', strtotime($start_date . ' -1 day'));
- $prev_start_date = date('Y-m-d', strtotime($prev_end_date . " -{$current_range_days} days +1 day"));
-
- // Get metrics for previous period
- $prev_metrics = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT
- SUM(profile_view_count) as profile_views,
- SUM(feed_view_count) as feed_views,
- SUM(taxonomy_view_count) as taxonomy_views,
- SUM(shop_view_count) as shop_views,
- SUM(total_view_count) as total_views,
- SUM(favourite_count) as favourites,
- SUM(upvote_count) as upvotes,
- SUM(downvote_count) as downvotes,
- SUM(karma) as karma
- FROM {$this->metrics_table}
- WHERE user_id = %d AND date BETWEEN %s AND %s",
- $user_id,
- $prev_start_date,
- $prev_end_date
- ));
-
- // Get current period totals
- $current_metrics = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT
- SUM(profile_view_count) as profile_views,
- SUM(feed_view_count) as feed_views,
- SUM(taxonomy_view_count) as taxonomy_views,
- SUM(shop_view_count) as shop_views,
- SUM(total_view_count) as total_views,
- SUM(favourite_count) as favourites,
- SUM(upvote_count) as upvotes,
- SUM(downvote_count) as downvotes,
- SUM(karma) as karma
- FROM {$this->metrics_table}
- WHERE user_id = %d AND date BETWEEN %s AND %s",
- $user_id,
- $start_date,
- $end_date
- ));
-
- $prev = !empty($prev_metrics) ? $prev_metrics[0] : null;
- $curr = !empty($current_metrics) ? $current_metrics[0] : null;
-
- // If no previous data, return zeros
- if (!$prev || !$curr) {
- return [
- 'profile_views' => 0,
- 'feed_views' => 0,
- 'taxonomy_views' => 0,
- 'shop_views' => 0,
- 'total_views' => 0,
- 'favourites' => 0,
- 'upvotes' => 0,
- 'downvotes' => 0,
- 'karma' => 0
- ];
- }
-
- // Calculate percentage changes
- $growth = [];
- $metrics = [
- 'profile_views', 'feed_views', 'taxonomy_views', 'shop_views',
- 'total_views', 'favourites', 'upvotes', 'downvotes', 'karma'
- ];
-
- foreach ($metrics as $metric) {
- if ($prev->$metric > 0) {
- $growth[$metric] = round((($curr->$metric - $prev->$metric) / $prev->$metric) * 100, 1);
- } else {
- $growth[$metric] = $curr->$metric > 0 ? 100 : 0; // If previous was 0, any value is 100% growth
- }
- }
-
- return $growth;
- }
-
- /**
- * Aggregate top content from multiple days
- *
- * @param array $metrics Array of metrics objects
- * @return array Aggregated top content
- */
- protected function aggregateTopContent(array $metrics):array
- {
- $all_content = [];
-
- // Collect content from all days
- foreach ($metrics as $day) {
- $daily_content = json_decode($day->top_content, true);
- if (!empty($daily_content)) {
- foreach ($daily_content as $type => $content_items) {
- if (!isset($all_content[$type])) {
- $all_content[$type] = [];
- }
-
- foreach ($content_items as $item) {
- $id = $item['id'];
- if (!isset($all_content[$type][$id])) {
- $all_content[$type][$id] = [
- 'id' => $id,
- 'views' => 0,
- 'title' => $this->getContentTitle($id, $type)
- ];
- }
-
- $all_content[$type][$id]['views'] += $item['views'];
- }
- }
- }
- }
-
- // Sort and trim each content type
- $result = [];
- foreach ($all_content as $type => $items) {
- // Convert to array and sort by views
- $items_array = array_values($items);
- usort($items_array, function ($a, $b) {
- return $b['views'] - $a['views'];
- });
-
- // Take top 10
- $result[$type] = array_slice($items_array, 0, 10);
- }
-
- return $result;
- }
-
- /**
- * Aggregate source breakdown from multiple days
- *
- * @param array $metrics Array of metrics objects
- * @return array Aggregated source breakdown
- */
- protected function aggregateSourceBreakdown(array $metrics):array
- {
- $totals = [
- 'direct' => 0,
- 'feed' => 0,
- 'taxonomy' => 0,
- 'shop' => 0,
- 'search' => 0,
- 'other' => 0
- ];
-
- foreach ($metrics as $day) {
- $sources = json_decode($day->source_breakdown, true);
- if (!empty($sources)) {
- foreach ($sources as $source => $count) {
- $totals[$source] += $count;
- }
- }
- }
-
- return $totals;
- }
-
- /**
- * Aggregate top favourites from multiple days
- *
- * @param array $metrics Array of metrics objects
- * @return array Aggregated top favourites
- */
- protected function aggregateTopFavourites(array $metrics):array
- {
- $all_favourites = [];
-
- // Collect favourites from all days
- foreach ($metrics as $day) {
- $daily_favs = json_decode($day->top_favourites, true);
- if (!empty($daily_favs)) {
- foreach ($daily_favs as $type => $fav_items) {
- if (!isset($all_favourites[$type])) {
- $all_favourites[$type] = [];
- }
-
- foreach ($fav_items as $item) {
- $id = $item['id'];
- if (!isset($all_favourites[$type][$id])) {
- $all_favourites[$type][$id] = [
- 'id' => $id,
- 'count' => 0,
- 'title' => $this->getContentTitle($id, $type)
- ];
- }
-
- $all_favourites[$type][$id]['count'] += $item['count'];
- }
- }
- }
- }
-
- // Sort and trim each content type
- $result = [];
- foreach ($all_favourites as $type => $items) {
- // Convert to array and sort by count
- $items_array = array_values($items);
- usort($items_array, function ($a, $b) {
- return $b['count'] - $a['count'];
- });
-
- // Take top 10
- $result[$type] = array_slice($items_array, 0, 10);
- }
-
- return $result;
- }
-
- /**
- * Get content title by ID and type
- *
- * @param int $id Content ID
- * @param string $type Content type
- * @return string Content title or ID if not found
- */
- protected function getContentTitle(int $id, string $type):string
- {
- // Try to get from cache first
- $cache_key = "content_title_{$type}_{$id}";
- $cached = $this->cache->get($cache_key);
- if ($cached) {
- return $cached;
- }
-
- $title = '';
-
- // Get title based on content type
- if (strpos($type, 'post_') === 0 ||
- in_array($type, ['tattoo', 'artist', 'piercing', 'artwork', 'event', 'offer'])) {
- // It's a post
- $post_id = $id;
- $post = get_post($post_id);
- if ($post) {
- $title = $post->post_title;
- }
- } elseif (in_array($type, ['shop', 'style', 'theme', 'city'])) {
- // It's a taxonomy term
- $term = get_term($id);
- if (!is_wp_error($term)) {
- $title = $term->name;
- }
- }
-
- // If still empty, use ID as fallback
- if (empty($title)) {
- $title = "#$id";
- }
-
- // Cache the result
- $this->cache->set($cache_key, $title, MONTH_IN_SECONDS);
-
- return $title;
- }
-
- /**
- * Get metrics for a shop over a period
- *
- * @param int $shop_id Shop ID (term_id)
- * @param string $start_date Start date (YYYY-MM-DD)
- * @param string $end_date End date (YYYY-MM-DD)
- * @return array Metrics for the period
- */
- public function getShopMetrics(int $shop_id, string $start_date, string $end_date):array
- {
- // Try to get from cache first
- $cache_key = "shop_metrics_{$shop_id}_{$start_date}_{$end_date}";
- $cached = $this->cache->get($cache_key);
- if ($cached) {
- return $cached;
- }
-
- // Query raw events for this shop
- $shop_views = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT COUNT(*) FROM {$this->events_table}
- WHERE event = 'view_shop'
- AND content_id = %d
- AND date BETWEEN %s AND %s",
- $shop_id,
- $start_date,
- $end_date
- ));
-
- // Get all artists for this shop
- $shop_artists = get_objects_in_term($shop_id, BASE . 'shop');
- $artist_user_ids = [];
-
- foreach ($shop_artists as $artist_post_id) {
- $user_id = get_post_meta($artist_post_id, BASE . 'link', true);
- if ($user_id) {
- $artist_user_ids[] = $user_id;
- }
- }
-
- // If no artists, return basic data
- if (empty($artist_user_ids)) {
- $shop_data = [
- 'shop_id' => $shop_id,
- 'period' => [
- 'start' => $start_date,
- 'end' => $end_date
- ],
- 'views' => $shop_views,
- 'artists' => 0,
- 'artist_metrics' => []
- ];
-
- $this->cache->set($cache_key, $shop_data);
- return $shop_data;
- }
-
- // Format artist IDs for SQL
- $artist_ids_sql = implode(',', array_map('intval', $artist_user_ids));
-
- // Get aggregated artist metrics
- $artist_metrics = $this->wpdb->get_results(
- "SELECT
- user_id,
- SUM(profile_view_count) as profile_views,
- SUM(feed_view_count) as feed_views,
- SUM(taxonomy_view_count) as taxonomy_views,
- SUM(shop_view_count) as shop_views,
- SUM(total_view_count) as total_views,
- SUM(favourite_count) as favourites,
- SUM(upvote_count) as upvotes,
- SUM(downvote_count) as downvotes,
- SUM(karma) as karma
- FROM {$this->metrics_table}
- WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$start_date}' AND '{$end_date}'
- GROUP BY user_id
- ORDER BY total_views DESC"
- );
-
- // Process artist metrics
- $formatted_artists = [];
- $totals = [
- 'profile_views' => 0,
- 'feed_views' => 0,
- 'taxonomy_views' => 0,
- 'shop_views' => 0,
- 'total_views' => 0,
- 'favourites' => 0,
- 'upvotes' => 0,
- 'downvotes' => 0,
- 'karma' => 0
- ];
-
- foreach ($artist_metrics as $artist) {
- // Get artist name
- $artist_name = '';
- $artist_post_id = get_user_meta($artist->user_id, BASE . 'link', true);
- if ($artist_post_id) {
- $artist_name = get_post_field('post_title', $artist_post_id);
- }
-
- $formatted_artists[] = [
- 'user_id' => $artist->user_id,
- 'name' => $artist_name ?: 'Artist #' . $artist->user_id,
- 'metrics' => [
- 'profile_views' => (int)$artist->profile_views,
- 'feed_views' => (int)$artist->feed_views,
- 'taxonomy_views' => (int)$artist->taxonomy_views,
- 'shop_views' => (int)$artist->shop_views,
- 'total_views' => (int)$artist->total_views,
- 'favourites' => (int)$artist->favourites,
- 'upvotes' => (int)$artist->upvotes,
- 'downvotes' => (int)$artist->downvotes,
- 'karma' => (int)$artist->karma
- ]
- ];
-
- // Add to totals
- $totals['profile_views'] += $artist->profile_views;
- $totals['feed_views'] += $artist->feed_views;
- $totals['taxonomy_views'] += $artist->taxonomy_views;
- $totals['shop_views'] += $artist->shop_views;
- $totals['total_views'] += $artist->total_views;
- $totals['favourites'] += $artist->favourites;
- $totals['upvotes'] += $artist->upvotes;
- $totals['downvotes'] += $artist->downvotes;
- $totals['karma'] += $artist->karma;
- }
-
- // Get daily view counts
- $daily_views = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT date, COUNT(*) as views
- FROM {$this->events_table}
- WHERE event = 'view_shop'
- AND content_id = %d
- AND date BETWEEN %s AND %s
- GROUP BY date
- ORDER BY date",
- $shop_id,
- $start_date,
- $end_date
- ));
-
- $views_by_day = [];
- foreach ($daily_views as $day) {
- $views_by_day[$day->date] = $day->views;
- }
-
- // Build final shop data
- $shop_data = [
- 'shop_id' => $shop_id,
- 'shop_name' => get_term_field('name', $shop_id, BASE . 'shop'),
- 'period' => [
- 'start' => $start_date,
- 'end' => $end_date
- ],
- 'views' => $shop_views,
- 'views_by_day' => $views_by_day,
- 'artists' => count($formatted_artists),
- 'artist_metrics' => $formatted_artists,
- 'totals' => $totals
- ];
-
- // Calculate growth compared to previous period
- $shop_data['growth'] = $this->calculateShopGrowth($shop_id, $shop_artists, $start_date, $end_date);
-
- // Cache the result
- $this->cache->set($cache_key, $shop_data);
-
- return $shop_data;
- }
-
- /**
- * Calculate shop growth metrics
- *
- * @param int $shop_id Shop ID
- * @param array $shop_artists Array of artist post IDs
- * @param string $start_date Start date
- * @param string $end_date End date
- * @return array Growth metrics
- */
- protected function calculateShopGrowth(int $shop_id, array $shop_artists, string $start_date, string $end_date):array
- {
- // Calculate the date range length
- $current_range_days = (strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS + 1;
-
- // Calculate previous period with same length
- $prev_end_date = date('Y-m-d', strtotime($start_date . ' -1 day'));
- $prev_start_date = date('Y-m-d', strtotime($prev_end_date . " -{$current_range_days} days +1 day"));
-
- // Get previous shop views
- $prev_shop_views = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT COUNT(*) FROM {$this->events_table}
- WHERE event = 'view_shop'
- AND content_id = %d
- AND date BETWEEN %s AND %s",
- $shop_id,
- $prev_start_date,
- $prev_end_date
- ));
-
- // Get current shop views
- $current_shop_views = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT COUNT(*) FROM {$this->events_table}
- WHERE event = 'view_shop'
- AND content_id = %d
- AND date BETWEEN %s AND %s",
- $shop_id,
- $start_date,
- $end_date
- ));
-
- // Calculate growth percentage for shop views
- $shop_view_growth = 0;
- if ($prev_shop_views > 0) {
- $shop_view_growth = round((($current_shop_views - $prev_shop_views) / $prev_shop_views) * 100, 1);
- } elseif ($current_shop_views > 0) {
- $shop_view_growth = 100; // If previously 0, any value is 100% growth
- }
-
- // Get artist user IDs
- $artist_user_ids = [];
- foreach ($shop_artists as $artist_post_id) {
- $user_id = get_post_meta($artist_post_id, BASE . 'link', true);
- if ($user_id) {
- $artist_user_ids[] = $user_id;
- }
- }
-
- // If no artists, just return shop views growth
- if (empty($artist_user_ids)) {
- return [
- 'shop_views' => $shop_view_growth
- ];
- }
-
- // Format artist IDs for SQL
- $artist_ids_sql = implode(',', array_map('intval', $artist_user_ids));
-
- // Get previous period metrics for artists
- $prev_metrics = $this->wpdb->get_row(
- "SELECT
- SUM(profile_view_count) as profile_views,
- SUM(feed_view_count) as feed_views,
- SUM(taxonomy_view_count) as taxonomy_views,
- SUM(shop_view_count) as shop_views,
- SUM(total_view_count) as total_views,
- SUM(favourite_count) as favourites,
- SUM(upvote_count) as upvotes,
- SUM(downvote_count) as downvotes,
- SUM(karma) as karma
- FROM {$this->metrics_table}
- WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$prev_start_date}' AND '{$prev_end_date}'"
- );
-
- // Get current period metrics for artists
- $current_metrics = $this->wpdb->get_row(
- "SELECT
- SUM(profile_view_count) as profile_views,
- SUM(feed_view_count) as feed_views,
- SUM(taxonomy_view_count) as taxonomy_views,
- SUM(shop_view_count) as shop_views,
- SUM(total_view_count) as total_views,
- SUM(favourite_count) as favourites,
- SUM(upvote_count) as upvotes,
- SUM(downvote_count) as downvotes,
- SUM(karma) as karma
- FROM {$this->metrics_table}
- WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$start_date}' AND '{$end_date}'"
- );
-
- // Calculate growth percentages
- $growth = [
- 'shop_views' => $shop_view_growth
- ];
-
- if ($prev_metrics && $current_metrics) {
- $metrics = [
- 'profile_views', 'feed_views', 'taxonomy_views', 'shop_views',
- 'total_views', 'favourites', 'upvotes', 'downvotes', 'karma'
- ];
-
- foreach ($metrics as $metric) {
- if ($prev_metrics->$metric > 0) {
- $growth[$metric] = round((($current_metrics->$metric - $prev_metrics->$metric) / $prev_metrics->$metric) * 100, 1);
- } else {
- $growth[$metric] = $current_metrics->$metric > 0 ? 100 : 0;
- }
- }
- }
-
- return $growth;
- }
-
- /**
- * Get dashboard summary for a user
- *
- * @param int $user_id User ID
- * @return array Dashboard summary data
- */
- public function getDashboardSummary(int $user_id):array
- {
- // Get metrics for different time periods
- $today = date('Y-m-d');
- $yesterday = date('Y-m-d', strtotime('-1 day'));
- $week_start = date('Y-m-d', strtotime('monday this week'));
- $month_start = date('Y-m-d', strtotime('first day of this month'));
-
- // Quick summary for today and yesterday
- $today_data = $this->getUserMetrics($user_id, $today, $today);
- $yesterday_data = $this->getUserMetrics($user_id, $yesterday, $yesterday);
- $week_data = $this->getUserMetrics($user_id, $week_start, $today);
- $month_data = $this->getUserMetrics($user_id, $month_start, $today);
-
- // Build quick summary for dashboard
- return [
- 'today' => [
- 'total_views' => $today_data['totals']['total_views'],
- 'profile_views' => $today_data['totals']['profile_views'],
- 'favourites' => $today_data['totals']['favourites'],
- 'karma' => $today_data['totals']['karma']
- ],
- 'yesterday' => [
- 'total_views' => $yesterday_data['totals']['total_views'],
- 'profile_views' => $yesterday_data['totals']['profile_views'],
- 'favourites' => $yesterday_data['totals']['favourites'],
- 'karma' => $yesterday_data['totals']['karma']
- ],
- 'this_week' => [
- 'total_views' => $week_data['totals']['total_views'],
- 'profile_views' => $week_data['totals']['profile_views'],
- 'favourites' => $week_data['totals']['favourites'],
- 'karma' => $week_data['totals']['karma']
- ],
- 'this_month' => [
- 'total_views' => $month_data['totals']['total_views'],
- 'profile_views' => $month_data['totals']['profile_views'],
- 'favourites' => $month_data['totals']['favourites'],
- 'karma' => $month_data['totals']['karma']
- ],
- 'growth' => [
- 'day_over_day' => [
- 'total_views' => $this->calculatePercentageChange(
- $yesterday_data['totals']['total_views'],
- $today_data['totals']['total_views']
- ),
- 'profile_views' => $this->calculatePercentageChange(
- $yesterday_data['totals']['profile_views'],
- $today_data['totals']['profile_views']
- ),
- 'favourites' => $this->calculatePercentageChange(
- $yesterday_data['totals']['favourites'],
- $today_data['totals']['favourites']
- ),
- 'karma' => $this->calculateAbsoluteChange(
- $yesterday_data['totals']['karma'],
- $today_data['totals']['karma']
- )
- ]
- ],
- 'top_content' => $this->simplifyTopContent($week_data['top_content']),
- 'sources' => $week_data['source_breakdown']
- ];
- }
-
- /**
- * Calculate percentage change between two values
- *
- * @param int $old Previous value
- * @param int $new Current value
- * @return float Percentage change
- */
- protected function calculatePercentageChange(int $old, int $new):float
- {
- if ($old == 0) {
- return $new > 0 ? 100 : 0;
- }
-
- return round((($new - $old) / $old) * 100, 1);
- }
-
- /**
- * Calculate absolute change between two values
- *
- * @param int $old Previous value
- * @param int $new Current value
- * @return int Absolute change
- */
- protected function calculateAbsoluteChange(int $old, int $new):int
- {
- return $new - $old;
- }
-
- /**
- * Simplify top content for dashboard view
- *
- * @param array $top_content Full top content data
- * @return array Simplified top content
- */
- protected function simplifyTopContent(array $top_content):array
- {
- $simplified = [];
-
- foreach ($top_content as $type => $items) {
- // Take just top 3 items
- $simplified[$type] = array_slice($items, 0, 3);
- }
-
- return $simplified;
- }
-
- /**
- * Update table schema with needed columns
- */
- public static function createTableSchema()
- {
- global $wpdb;
-
- $charset_collate = $wpdb->get_charset_collate();
-
- // Events table
- $events_table = $wpdb->prefix . BASE . 'umami_events';
- $events_schema = "CREATE TABLE IF NOT EXISTS {$events_table} (
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `date` date NOT NULL,
- `timestamp` datetime NOT NULL,
- `event` varchar(50) NOT NULL,
- `event_type` varchar(50) NOT NULL,
- `user_id` bigint(20) unsigned DEFAULT NULL,
- `content_id` bigint(20) unsigned DEFAULT NULL,
- `content_type` varchar(50) DEFAULT NULL,
- `source_id` bigint(20) unsigned DEFAULT NULL,
- `source_type` varchar(50) DEFAULT NULL,
- `owner_id` bigint(20) unsigned DEFAULT NULL,
- `owner_type` varchar(50) DEFAULT NULL,
- `referrer` varchar(100) DEFAULT NULL,
- `metadata` JSON DEFAULT NULL,
- PRIMARY KEY (`id`),
- KEY `date_idx` (`date`),
- KEY `event_idx` (`event`, `event_type`),
- KEY `content_idx` (`content_type`, `content_id`),
- KEY `user_idx` (`user_id`),
- KEY `owner_idx` (`owner_id`)
- ) {$charset_collate};";
-
- // Performance metrics table
- $metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
- $metrics_schema = "CREATE TABLE IF NOT EXISTS {$metrics_table} (
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `date` date NOT NULL,
- `user_id` bigint(20) unsigned DEFAULT NULL,
- `profile_view_count` bigint(20) unsigned DEFAULT 0,
- `feed_view_count` bigint(20) unsigned DEFAULT 0,
- `taxonomy_view_count` bigint(20) unsigned DEFAULT 0,
- `shop_view_count` bigint(20) unsigned DEFAULT 0,
- `total_view_count` bigint(20) unsigned DEFAULT 0,
- `favourite_count` bigint(20) unsigned DEFAULT 0,
- `top_content` json DEFAULT NULL,
- `source_breakdown` json DEFAULT NULL,
- `top_favourites` json DEFAULT NULL,
- `upvote_count` bigint(20) unsigned DEFAULT 0,
- `downvote_count` bigint(20) unsigned DEFAULT 0,
- `karma` bigint(20) unsigned DEFAULT 0,
- `favourite_conversion_rate` float DEFAULT 0,
- PRIMARY KEY (`id`),
- UNIQUE KEY `user_date_idx` (`user_id`, `date`),
- KEY `date_idx` (`date`)
- ) {$charset_collate};";
-
- // Execute schema creation
- require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
- dbDelta($events_schema);
- dbDelta($metrics_schema);
- }
-
- /**
- * Handle database upgrades for schema changes
- */
- public static function upgradeDatabase()
- {
- self::createTableSchema();
- }
-
- /**
- * Render the Umami credentials settings form
- */
- public function renderCredentialsForm()
- {
- // Check capability
- if (!current_user_can('manage_options')) {
- wp_die(__('You do not have sufficient permissions to access this page.'));
- }
-
- // Handle form submission
- if (isset($_POST['jvb_umami_credentials_nonce']) && wp_verify_nonce($_POST['jvb_umami_credentials_nonce'], 'jvb_saveUmamiCredentials')) {
- if (isset($_POST['jvb_umami_username']) && isset($_POST['jvb_umami_password'])) {
- $username = sanitize_text_field($_POST['jvb_umami_username']);
- $password = $_POST['jvb_umami_password']; // Don't sanitize password as it might contain special chars
-
- if ($this->saveUmamiCredentials($username, $password)) {
- // Clear token cache to force re-authentication
- delete_transient('jvb_umami_auth_token');
- echo '<div class="updated"><p>Credentials saved successfully!</p></div>';
-
- // Test connection
- $test_result = $this->testUmamiConnection();
- if (is_wp_error($test_result)) {
- echo '<div class="error"><p>Connection test failed: ' . esc_html($test_result->get_error_message()) . '</p></div>';
- } else {
- echo '<div class="updated"><p>Connection test successful!</p></div>';
- }
- } else {
- echo '<div class="error"><p>Failed to save credentials.</p></div>';
- }
- }
- }
-
- // Render form
- ?>
- <div class="wrap">
- <h2>Umami Analytics Credentials</h2>
- <form method="post" action="">
- <?php wp_nonce_field('jvb_saveUmamiCredentials', 'jvb_umami_credentials_nonce'); ?>
- <table class="form-table">
- <tr>
- <th scope="row"><label for="jvb_umami_username">Username</label></th>
- <td><input type="text" id="jvb_umami_username" name="jvb_umami_username" class="regular-text"></td>
- </tr>
- <tr>
- <th scope="row"><label for="jvb_umami_password">Password</label></th>
- <td><input type="password" id="jvb_umami_password" name="jvb_umami_password" class="regular-text"></td>
- </tr>
- <tr>
- <th scope="row"><label for="jvb_umami_website_id">Website ID</label></th>
- <td>
- <input type="text" id="jvb_umami_website_id" name="jvb_umami_website_id" class="regular-text" value="<?= esc_attr(get_option('jvb_umami_website_id', UMAMI_WEBSITE_ID)); ?>">
- <p class="description">The Umami website ID for tracking.</p>
- </td>
- </tr>
- </table>
- <p class="submit">
- <input type="submit" name="submit" id="submit" class="button button-primary" value="Save Credentials">
- </p>
- </form>
- </div>
- <?php
- }
-
- /**
- * Test connection to Umami API
- *
- * @return bool|WP_Error True on success, WP_Error on failure
- */
- public function testUmamiConnection()
- {
- $token = $this->getAuthToken();
- if (is_wp_error($token)) {
- return $token;
- }
-
- // Try a simple API call
- $test_response = wp_remote_get($this->api_url . '/me', [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $token
- ]
- ]);
-
- if (is_wp_error($test_response)) {
- return $test_response;
- }
-
- $response_code = wp_remote_retrieve_response_code($test_response);
- if ($response_code !== 200) {
- return new WP_Error(
- 'connection_failed',
- 'API responded with code: ' . $response_code
- );
- }
-
- return true;
- }
-}
diff --git a/inc/managers/UmamiTracker.php b/inc/managers/UmamiTracker.php
deleted file mode 100644
index 76e5bf6..0000000
--- a/inc/managers/UmamiTracker.php
+++ /dev/null
@@ -1,249 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-/**
- * Class for building and managing umami.js tracking data attributes
- */
-class UmamiTracker
-{
- private array $valid_events = [
- 'view_feed',
- 'view_taxonomy',
- 'view_profile',
- 'view_shop',
- 'view_content',
- 'toggle_favourite',
- 'click_profile',
- 'click_content',
- 'click_taxonomy',
- 'click_shop',
- ];
- private array $valid_types;
- private array $context;
-
- /**
- * Constructor
- *
- * @param array $context Optional context data to use for all tracking
- */
- public function __construct(array $context = [])
- {
- $this->context = $context;
- global $jvb_content;
- $this->valid_types = array_keys($jvb_content);
-
-
- // Add current page information if available
- if (empty($this->context['source_id']) && is_singular()) {
- $this->context['source_id'] = get_the_ID();
- $this->context['source_type'] = get_post_type();
- } elseif (empty($this->context['source_id']) && is_tax()) {
- $obj = get_queried_object();
- $this->context['source_id'] = $obj->term_id;
- $this->context['source_type'] = $obj->taxonomy;
- }
- }
-
- /**
- * Build tracking attributes for a specific event
- *
- * @param string $event Event type
- * @param string $type Content type
- * @param array $args Additional arguments
- *
- * @return array/string HTML attributes for tracking
- */
- public function buildAttributes(string $event, string $type, array $args = []):array
- {
- // Validate event and type
- if (!in_array($event, $this->valid_events)) {
- return [];
- }
-
- if (!in_array($type, $this->valid_types)) {
- return [];
- }
-
- // Merge with context
- $args = array_merge($this->context, $args);
-
- // Normalize type (remove jvb_ prefix)
- $normalized_type = str_replace(BASE, '', $type);
-
- // Start building attributes
- $attributes = [
- 'umamiEvent' => esc_attr($event),
- 'umamiEventType' => esc_attr($normalized_type)
- ];
-
- // Add content ID if available
- if (!empty($args['id'])) {
- $attributes['umamiEventId'] = esc_attr($args['id']);
- }
-
- // Add source info (where the action originated)
- if (!empty($args['source_id'])) {
- $attributes['umamiEventSource'] = esc_attr($args['source_id']);
-
- if (!empty($args['source_type'])) {
- $attributes['umamiEventSourceType'] = esc_attr(
- str_replace(BASE, '', $args['source_type'])
- );
- }
- }
-
- // Add owner info (usually artist or partner)
- if (!empty($args['owner_id'])) {
- $attributes['umamiEventOwner'] = esc_attr($args['owner_id']);
-
- if (!empty($args['owner_type'])) {
- $attributes['umamiEventOwnerType'] = esc_attr(
- str_replace(BASE, '', $args['owner_type'])
- );
- }
- }
-
- // Add referrer (how the user got here)
- if (!empty($args['from'])) {
- $attributes['umamiEventFrom'] = esc_attr($args['from']);
- }
-
- // Add item (used in views to see what people are clicking on)
- if (!empty($args['item'])) {
- $attributes['umamiEventItem'] = esc_attr($args['item']);
- }
-
- // Add any additional metadata
- if (!empty($args['meta']) && is_array($args['meta'])) {
- foreach ($args['meta'] as $key => $value) {
- $attributes['umamiEventMeta' . ucFirst(str_replace('-', '', sanitize_key($key)))] = esc_attr($value);
- }
- }
-
- return $attributes;
- }
-
- /**
- * Build tracking attributes for content clicks
- *
- * @param int $id Content ID
- * @param string $type Content type
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackContentClick(int $id, string $type, array $args = []):array
- {
- $args['id'] = $id;
-
- // Auto-detect owner for content types
- if (empty($args['owner_id']) && in_array($type, [ 'jvb_tattoo', 'jvb_artwork', 'jvb_piercing' ])) {
- $post = get_post($id);
- if ($post && ! empty($post->post_author)) {
- $args['owner_id'] = $post->post_author;
- $args['owner_type'] = 'user';
- }
- }
-
- return $this->buildAttributes('click_content', $type, $args);
- }
-
- /**
- * Build tracking attributes for profile clicks
- *
- * @param int $id Profile ID
- * @param string $type Profile type (usually jvb_artist or jvb_partner)
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackProfileClick(int $id, string $type, array $args = []):array
- {
- $args['id'] = $id;
- return $this->buildAttributes('click_profile', $type, $args);
- }
-
- /**
- * Build tracking attributes for taxonomy clicks
- *
- * @param int $id Term ID
- * @param string $taxonomy Taxonomy
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackTaxonomyClick(int $id, string $taxonomy, array $args = [])
- {
- $args['id'] = $id;
- return $this->buildAttributes('click_taxonomy', $taxonomy, $args);
- }
-
- /**
- * Build tracking attributes for shop clicks
- *
- * @param int $id Shop ID
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackShopClick(int $id, string $type = 'shop', array $args = []):array
- {
- $args['id'] = $id;
- return $this->buildAttributes('click_shop', $type, $args);
- }
-
- /**
- * Build tracking attributes for favourite toggles
- *
- * @param int $id Content ID
- * @param string $type Content type
- * @param bool $is_favourite Whether it's being favourited or unfavourited
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = [])
- {
- $args['id'] = $id;
- $args['meta']['action'] = $is_favourite ? 'add' : 'remove';
- return $this->buildAttributes('toggle_favourite', $type, $args);
- }
-
- /**
- * Track content view in feed
- *
- * @param int $id Content ID
- * @param string $type Content type
- * @param array $args Additional arguments
- *
- * @return array HTML attributes for tracking
- */
- public function trackFeedView(int $id, string $type, array $args = []):array
- {
- $args['id'] = $id;
- return $this->buildAttributes('view_feed', $type, $args);
- }
-
- /**
- * Convert an array of attributes to a string
- *
- * @param array $attributes Attributes array
- *
- * @return string Attributes string
- */
-
- public function attributesToString(array $attributes):string
- {
- $attr_strings = [];
-
- foreach ($attributes as $key => $value) {
- $attr_strings[] = $key . '="' . $value . '"';
- }
-
- return implode(' ', $attr_strings);
- }
-}
diff --git a/inc/managers/UploadManager2.php b/inc/managers/UploadManager2.php
deleted file mode 100644
index a322e24..0000000
--- a/inc/managers/UploadManager2.php
+++ /dev/null
@@ -1,1206 +0,0 @@
-<?php
-namespace JVBase\inc\managers;
-
-use JVBase\JVB;
-use JVBase\meta\MetaManager;
-use Exception;
-use Imagick;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-/**
- * Handles file uploads for edmonton.ink dashboard
- * Includes image processing, validation, optimization, and SEO-friendly naming
- */
-class UploadManager2
-{
- protected array $allowedTypes = [
- //Images
- 'image/jpeg' => 'image',
- 'image/png' => 'image',
- 'image/gif' => 'image',
- 'image/webp' => 'image',
- //Videos
- 'video/mp4' => 'video',
- 'video/webm' => 'video',
- 'video/ogg' => 'video',
- 'video/ogv' => 'video',
- 'video/quicktime'=> 'video', // .mov files
- 'video/x-msvideo'=> 'video',
- //Documents
- 'application/pdf' => 'document',
- 'application/msword' => 'document', // .doc
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'document', // .docx
- 'application/vnd.ms-excel' => 'document', // .xls
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'document', // .xlsx
- 'text/plain' => 'document', // .txt
- 'text/csv' => 'document',
- 'application/rtf' => 'document'
- ];
- /**
- * @var array Default configuration
- */
- protected array $config = [
- 'allowed_types' => [
- //Images
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'image/webp',
- //Videos
- 'video/mp4',
- 'video/webm',
- 'video/ogg',
- 'video/ogv',
- 'video/quicktime', // .mov files
- 'video/x-msvideo',
- //Documents
- 'application/pdf',
- 'application/msword', // .doc
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
- 'application/vnd.ms-excel', // .xls
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
- 'text/plain', // .txt
- 'text/csv',
- 'application/rtf'
- ],
- 'max_size' => [
- 'image' => 5242880, // 5MB
- 'video' => 104857600, // 100MB
- 'document' => 10485760 // 10MB
- ],
- //Image Specific Settings
- 'convert_to_webp' => true,
- 'webp_quality' => 80,
- 'optimize_images' => true,
- 'create_thumbnails' => true,
- 'use_imagick' => null, // Will be set in constructor
- //Video specific settings
- 'extract_video_thumbnail' => true,
- 'video_thumbnail_time' => 0,
- // Document-specific settings
- 'extract_document_preview' => false,
- // General settings
- 'original_retention' => 2592000, // 30 days in seconds
- ];
-
- protected string $post_type;
- protected int $user_id;
- protected int $term_id;
- protected string $upload_dir;
- protected string $upload_url;
- protected object $cache;
- protected object $notifications;
- protected int $max_retries = 3;
- protected int $retry_delay = 300; // 5 minutes
-
- public function __construct($post_type, $user_id, $config = [])
- {
- $this->post_type = $post_type;
- $this->user_id = $user_id;
- $this->config = wp_parse_args($config, $this->config);
- // Check for Imagick availability
- $this->config['use_imagick'] = extension_loaded('imagick');
-
- $this->setupUploadDirs();
-
-
- // Schedule cleanup of original files
-// if (!wp_next_scheduled('jvb_cleanup_original_uploads')) {
-// wp_schedule_event(time(), 'daily', 'jvb_cleanup_original_uploads');
-// }
-// add_action('jvb_cleanup_original_uploads', [$this, 'cleanupOriginalFiles']);
-
-
- // Track upload statistics
- add_action('jvb_upload_complete', [$this, 'trackUploadStats']);
- add_action('jvb_upload_failed', [$this, 'track_failed_upload']);
- }
-
- /**
- * @param array $file_data
- *
- * @return array
- * @throws Exception
- */
- public function secureUploadedFile(array $file_data): array
- {
- // Create a persistent temporary directory if it doesn't exist
- $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$this->user_id}";
- wp_mkdir_p($temp_dir);
-
- // Generate a unique filename for temporary storage
- $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
- $temp_path = "{$temp_dir}/{$temp_filename}";
-
- // Move the uploaded file to our temporary storage
- if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
- // Fallback to copy if needed
- if (!copy($file_data['tmp_name'], $temp_path)) {
- throw new Exception('Failed to store uploaded file');
- }
- }
-
- // Return metadata about the stored file
- return [
- 'temp_path' => $temp_path,
- 'original_name' => $file_data['name'],
- 'mime_type' => $file_data['type'],
- 'file_size' => $file_data['size'],
- 'stored_at' => current_time('mysql')
- ];
- }
-
- /**
- * Process file from secured storage
- */
- public function processFileFromStorage(string $temp_path, string $post_type, int $post_id, int $term_id, string $original_filename): array|WP_Error
- {
- if (!file_exists($temp_path)) {
- throw new Exception('Temporary file no longer exists: ' . $temp_path);
- }
-
- $this->term_id = $term_id;
- $this->post_type = $post_type;
-
- $rel_path = $this->getUploadDirectory();
- $base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
-
- return $this->processFile($temp_path, $rel_path, $base_filename, $post_id);
- }
-
- /**
- * Main entry point - detects file type and routes to appropriate processor
- * @throws Exception
- */
- public function processFile(string $file_path, string $rel_path, string $filename, int $post_id): array|WP_Error
- {
- if (!file_exists($file_path)) {
- throw new Exception('File no longer exists: ' . $file_path);
- }
-
- $mime_type = mime_content_type($file_path);
- if (!in_array($mime_type, $this->config['allowed_types'])) {
- throw new Exception('Invalid file type: ' . $mime_type);
- }
-
- $file_type = (array_key_exists($mime_type, $this->allowedTypes)) ? $this->allowedTypes[$mime_type] : 'unknown';
-
- // Route to appropriate processor
- switch ($file_type) {
- case 'image':
- return $this->processImage($file_path, $rel_path, $filename, $post_id);
- case 'video':
- return $this->processVideo($file_path, $rel_path, $filename, $post_id);
- case 'document':
- return $this->processDocument($file_path, $rel_path, $filename, $post_id);
- default:
- throw new Exception('Unknown file type category');
- }
- }
-
- /**
- * Validate file size based on type
- */
- protected function validateFileSize(string $file_path, string $file_type): bool
- {
- $file_size = filesize($file_path);
- $max_size = $this->config['max_size'][$file_type] ?? $this->config['max_size']['image'];
-
- if ($file_size > $max_size) {
- throw new Exception(sprintf(
- 'File size (%s) exceeds maximum allowed size (%s) for %s files',
- size_format($file_size),
- size_format($max_size),
- $file_type
- ));
- }
-
- return true;
- }
-
- /**
- * @param string $temp_path
- * @param string $post_type
- * @param int $post_id
- * @param int $term_id
- * @param string $original_filename
- *
- * @return array
- * @throws Exception
- */
- public function processImageFromStorage(string $temp_path, string $post_type, int $post_id = 0, int $term_id = 0, string $original_filename = ''): array
- {
- if (!file_exists($temp_path)) {
- throw new Exception('Stored file no longer exists: ' . $temp_path);
- }
-
- $this->term_id = $term_id;
- $this->post_type = $post_type;
-
- // Get base upload directory based on content type
- $rel_path = $this->getUploadDirectory();
-
- // Ensure the directory exists
- $full_upload_path = $this->upload_dir . '/' . $rel_path;
- if (!wp_mkdir_p($full_upload_path)) {
- throw new Exception("Failed to create upload directory: {$full_upload_path}");
- }
-
- // Generate filename WITHOUT extension (generateFilename should not include extension)
- $base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
-
- // Process the image directly from our temporary storage
- return $this->processImage($temp_path, $rel_path, $base_filename, $post_id);
- }
-
- /**
- * @return void
- * @throws Exception
- */
- protected function setupUploadDirs():void
- {
- $upload_info = wp_upload_dir();
- if ($upload_info['error']) {
- throw new Exception($upload_info['error']);
- }
-
- $this->upload_dir = $upload_info['basedir'];
- $this->upload_url = $upload_info['baseurl'];
- }
-
- /**
- * @return string
- */
- public function getUploadDirectory():string
- {
- // Default WordPress organization: year/month
- $default_path = date('Y/m');
-
- /**
- * Filter the upload directory structure
- *
- * @param string $path The default upload path
- * @param string $post_type The post type being uploaded
- * @param int $user_id The user ID
- * @param int $term_id The term ID (if applicable)
- */
- return apply_filters('jvb_upload_directory', $default_path, $this->post_type, $this->user_id, $this->term_id);
- }
-
- /**
- * Generate filename with extensible filtering
- * Default: generic SEO-friendly format
- *
- * @param string $original_name
- * @param object $user_data
- * @return string
- */
- public function generateFilename(string $original_name, object $user_data): string
- {
- // Default generic filename: {post_type}-{user_id}-{counter}
- $filename = sprintf(
- '%s-%s',
- sanitize_title($this->post_type),
- $this->user_id
- );
-
- /**
- * Filter the generated filename (without extension)
- *
- * @param string $filename The generated filename
- * @param string $original_name The original filename
- * @param object $user_data WordPress user data object
- * @param string $post_type The post type
- * @param int $user_id The user ID
- * @param int $term_id The term ID (if applicable)
- */
- return apply_filters('jvb_upload_filename', $filename, $original_name, $user_data, $this->post_type, $this->user_id, $this->term_id).'-'.$this->getNextFileCounter();
- }
-
- /**
- * @return string
- */
- protected function getNextFileCounter(): string
- {
- // Get counter key for this post type
- $counter_key = 'upload_counter_' . str_replace(['/', '\\'], '_', $this->post_type);
-
- // Get current counter value, default to 0
- $counter = (int)get_user_meta($this->user_id, $counter_key, true) ?: 0;
-
- // Increment counter
- $counter++;
-
- // Update counter in user meta
- update_user_meta($this->user_id, $counter_key, $counter);
-
- // Return formatted counter
- return sprintf('%08d', $counter);
- }
-
-
- /**
- * Generate alt text with filtering for customization
- * Default: basic or empty alt text
- *
- * @param string $file
- * @param object $user_data
- * @param int|null $post_id
- * @return string
- */
- protected function generateAltText(string $file, object $user_data, int|null $post_id = null): string
- {
- // Default: basic alt text or empty
- $alt_text = '';
-
- /**
- * Filter the generated alt text
- *
- * @param string $alt_text The generated alt text
- * @param string $file The file path
- * @param object $user_data WordPress user data object
- * @param int|null $post_id The post ID (if applicable)
- * @param string $post_type The post type
- * @param int $user_id The user ID
- * @param int $term_id The term ID (if applicable)
- */
- return apply_filters('jvb_upload_alt_text', $alt_text, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
- }
-
- /**
- * Generate image title with filtering for customization
- * Default: WordPress default behavior (filename-based)
- *
- * @param string $file
- * @param object $user_data
- * @param int|null $post_id
- * @return string
- */
- protected function generateImageTitle(string $file, object $user_data, int|null $post_id = null): string
- {
- // Default: Use filename without extension (WordPress default behavior)
- $title = pathinfo($file, PATHINFO_FILENAME);
-
- /**
- * Filter the generated image title
- *
- * @param string $title The generated title
- * @param string $file The file path
- * @param object $user_data WordPress user data object
- * @param int|null $post_id The post ID (if applicable)
- * @param string $post_type The post type
- * @param int $user_id The user ID
- * @param int $term_id The term ID (if applicable)
- */
- return apply_filters('jvb_upload_image_title', $title, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
- }
-
- /**
- * @param array $file_data
- * @param array $options
- *
- * @return array|WP_Error
- */
- public function handleContentUpload(array $file_data, array $options = []): array|WP_Error
- {
- try {
- if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) {
- throw new Exception('No valid file uploaded.');
- }
-
- $user_data = get_userdata($this->user_id);
- $post_id = $options['post_id'] ?? 0;
- $this->term_id = $options['term_id'] ?? 0;
-
- // Get base upload directory based on content type
- $base_dir = $this->getUploadDirectory();
- $original_dir = 'originals';
-
- $rel_path = $base_dir;
- $this->ensureUploadDirs($rel_path, $original_dir);
-
- $filename = $this->generateFilename($file_data['name'], $user_data);
-
- // Store original file
- $original_file = $this->storeOriginalFile(
- $file_data['tmp_name'],
- $base_dir,
- $original_dir,
- $filename
- );
-
- // Process immediately
- return $this->processImage($original_file, $rel_path, $filename, $post_id);
-
- } catch (Exception $e) {
- return new WP_Error('upload_failed', $e->getMessage());
- }
- }
-
- /**
- * @param string $rel_path
- * @param string $original_dir
- *
- * @return void
- * @throws Exception
- */
- protected function ensureUploadDirs($rel_path, $original_dir): void
- {
- $dirs = [
- "{$this->upload_dir}/{$rel_path}",
- "{$this->upload_dir}/{$rel_path}/{$original_dir}"
- ];
-
- foreach ($dirs as $dir) {
- if (!wp_mkdir_p($dir)) {
- throw new Exception("Failed to create directory: {$dir}");
- }
- }
- }
-
-
- /**
- * @param string $tmp_file
- * @param string $base_dir
- * @param string $original_dir
- * @param string $filename
- * @return string
- * @throws Exception
- */
- protected function storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string
- {
- $ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
- $original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}";
-
- if (!move_uploaded_file($tmp_file, $original_path)) {
- throw new Exception('Failed to store original file');
- }
-
- return $original_path;
- }
-
- /**
- * Process image with better error handling
- * @throws Exception
- */
- protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array
- {
- // Validate the original file still exists
- if (!file_exists($original_file)) {
- throw new Exception('Original file no longer exists: ' . $original_file);
- }
- $this->validateFileSize($original_file, 'image');
-
- // Verify file type before processing
- $mime_type = mime_content_type($original_file);
- if (!in_array($mime_type, $this->config['allowed_types'])) {
- throw new Exception('Invalid file type detected during processing: ' . $mime_type);
- }
-
- // Ensure the upload directory exists
- $full_upload_dir = $this->upload_dir . '/' . $rel_path;
- if (!wp_mkdir_p($full_upload_dir)) {
- throw new Exception("Failed to create upload directory: {$full_upload_dir}");
- }
-
- // Convert to WebP if enabled
- if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') {
- $final_path = "{$full_upload_dir}/{$filename}.webp";
-
- if ($this->config['use_imagick']) {
- $this->convertWithImagick($original_file, $final_path);
- } else {
- $this->convertWithGd($original_file, $final_path);
- }
- } else {
- // Just copy the original with its extension
- $original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
- $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
- if (!copy($original_file, $final_path)) {
- throw new Exception("Failed to copy file from {$original_file} to {$final_path}");
- }
- }
-
- // Verify the final file was created
- if (!file_exists($final_path)) {
- throw new Exception("Final processed file was not created: {$final_path}");
- }
-
- // Generate title text
- $title = $this->generateImageTitle(
- $final_path,
- get_userdata($this->user_id),
- $post_id
- );
-
- // Create attachment with title
- $attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
- // Generate thumbnails
- if ($this->config['create_thumbnails']) {
- $this->generateThumbnails($attachment_id);
- }
-
- // Update post attachments with new file info
- $this->updatePostAttachments($attachment_id, $final_path);
-
- return [
- 'success' => true,
- 'attachment_id' => $attachment_id,
- 'url' => wp_get_attachment_url($attachment_id),
- 'file' => $final_path
- ];
- }
-
- /**
- * @param string $source
- * @param string $destination
- *
- * @return void
- * @throws Exception
- */
- protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void
- {
- $allowed = ['webp', 'jpeg', 'jpg', 'png'];
- if (!in_array($toType, $allowed)) {
- return;
- }
- try {
- $image = new Imagick($source);
- $image->setImageFormat('webp');
- $image->setImageCompressionQuality($this->config['webp_quality']);
- $image->writeImage($destination);
- $image->clear();
- } catch (Exception $e) {
- throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage());
- }
- }
-
- /**
- * Fixed convertWithGd method with better error handling
- */
- protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void
- {
- $mime_type = mime_content_type($source);
-
- // Ensure destination directory exists
- $dest_dir = dirname($destination);
- if (!wp_mkdir_p($dest_dir)) {
- throw new Exception("Failed to create destination directory: {$dest_dir}");
- }
-
- switch ($mime_type) {
- case 'image/webp':
- $image = imagecreatefromwebp($source);
- break;
- case 'image/jpeg':
- $image = imagecreatefromjpeg($source);
- break;
- case 'image/png':
- $image = imagecreatefrompng($source);
- if ($image !== false) {
- imagepalettetotruecolor($image);
- imagealphablending($image, true);
- imagesavealpha($image, true);
- }
- break;
- case 'image/gif':
- $image = imagecreatefromgif($source);
- if ($image !== false) {
- imagepalettetotruecolor($image);
- }
- break;
- default:
- throw new Exception('Unsupported image type for GD conversion: ' . $mime_type);
- }
-
- if ($image === false) {
- throw new Exception('Failed to create image resource from source file: ' . $source);
- }
-
-
- // Convert to WebP
- $result = imagewebp($image, $destination, $this->config['webp_quality']);
-
- // Clean up memory
- imagedestroy($image);
-
- if (!$result) {
- throw new Exception('WebP conversion with GD failed - imagewebp returned false');
- }
-
- // Verify the file was actually created
- if (!file_exists($destination)) {
- throw new Exception('WebP file was not created despite imagewebp returning true');
- }
- }
-
- /**
- * @return void
- */
- public function cleanupOriginalFiles(): void
- {
- $cutoff = time() - $this->config['original_retention'];
-
- // Get upload directory and find original directories
- $pattern = "{$this->upload_dir}/**/originals";
- $original_dirs = glob($pattern, GLOB_ONLYDIR);
-
- foreach ($original_dirs as $original_dir) {
- $files = glob("{$original_dir}/*");
- foreach ($files as $file) {
- if (filemtime($file) < $cutoff) {
- unlink($file);
- }
- }
- }
- }
-
- /**
- * @param string|null $temp_dir
- * @return void
- */
- protected function cleanupTempFiles(string|null $temp_dir = null): void
- {
- if (is_null($temp_dir)) {
- $temp_dir = $this->upload_dir . '/tmp';
- }
-
- if (is_dir($temp_dir)) {
- $files = glob($temp_dir . '/*');
- foreach ($files as $file) {
- if (is_file($file)) {
- @unlink($file);
- }
- }
- @rmdir($temp_dir);
- }
- }
-
-
- /**
- * @param int $retention_days
- * @return void
- */
- public function cleanupUserFiles(int $retention_days = 30): void
- {
- $cutoff = time() - ($retention_days * DAY_IN_SECONDS);
- $user_dir = $this->getUploadDirectory();
-
- if (!is_dir($user_dir)) {
- return;
- }
-
- $this->cleanupDirectory($user_dir, $cutoff);
- }
-
- /**
- * @param string $dir
- * @param int $cutoff
- * @return void
- */
- protected function cleanupDirectory(string $dir, int $cutoff): void
- {
- $files = glob($dir . '/*');
- foreach ($files as $file) {
- if (is_dir($file)) {
- $this->cleanupDirectory($file, $cutoff);
- } elseif (filemtime($file) < $cutoff) {
- @unlink($file);
- }
- }
- }
-
- /**
- * @param string $file
- * @param string $title
- * @param int $post_id
- * @return int|WP_Error
- * @throws Exception
- */
- protected function createAttachment(string $file, string $title, int $post_id): int|WP_Error
- {
- $file_url = str_replace($this->upload_dir, $this->upload_url, $file);
-
- $attachment = [
- 'post_mime_type' => mime_content_type($file),
- 'post_title' => $title,
- 'post_name' => sanitize_title($title),
- 'post_content' => '',
- 'post_status' => 'inherit',
- 'guid' => $file_url
- ];
-
- $attach_id = wp_insert_attachment($attachment, $file, $post_id);
- if (is_wp_error($attach_id)) {
- throw new Exception($attach_id->get_error_message());
- }
-
- // Generate and set alt text for images only
- if (str_starts_with(mime_content_type($file), 'image/')) {
- $alt_text = $this->generateAltText($file, get_userdata($this->user_id), $post_id);
- update_post_meta($attach_id, '_wp_attachment_image_alt', $alt_text);
- }
-
- return $attach_id;
- }
-
- /**
- * Generate thumbnails using WordPress's built-in image size system
- * This will create all registered image sizes (thumbnail, medium, large, and any custom sizes)
- * Sites can register custom image sizes using add_image_size()
- *
- * @param int $attachment_id
- * @return void
- */
- protected function generateThumbnails(int $attachment_id): void
- {
- require_once(ABSPATH . 'wp-admin/includes/image.php');
- $metadata = wp_generate_attachment_metadata($attachment_id, get_attached_file($attachment_id));
- wp_update_attachment_metadata($attachment_id, $metadata);
- }
-
- /**
- * @param $result
- * @return void
- */
- protected function trackUploadStats($result)
- {
- $stats_key = "upload_stats_{$this->user_id}";
- $stats = wp_cache_get($stats_key) ?: [
- 'total_uploads' => 0,
- 'successful_uploads' => 0,
- 'failed_uploads' => 0,
- 'total_size' => 0,
- 'last_upload' => null
- ];
-
- if ($result['success']) {
- $stats['successful_uploads']++;
- $stats['total_size'] += filesize($result['file']);
- } else {
- $stats['failed_uploads']++;
- }
-
- $stats['total_uploads']++;
- $stats['last_upload'] = current_time('mysql');
-
- wp_cache_set($stats_key, $stats, '', DAY_IN_SECONDS);
- }
-
- /**
- * @param int $attachment_id
- * @param string $new_file_path
- * @return void
- */
- protected function updatePostAttachments(int $attachment_id, string $new_file_path): void
- {
- // Update attachment post
- $file_url = str_replace($this->upload_dir, $this->upload_url, $new_file_path);
- $filename = basename($new_file_path);
-
- wp_update_post([
- 'ID' => $attachment_id,
- 'guid' => $file_url,
- 'post_mime_type' => mime_content_type($new_file_path),
- 'post_title' => $filename
- ]);
-
- // Update attachment metadata
- update_post_meta($attachment_id, '_wp_attached_file', str_replace($this->upload_dir . '/', '', $new_file_path));
-
- // Update attachment metadata including sizes
- $metadata = wp_get_attachment_metadata($attachment_id);
- if ($metadata) {
- $metadata['file'] = str_replace($this->upload_dir . '/', '', $new_file_path);
-
- // Update thumbnail paths if they exist
- if (!empty($metadata['sizes'])) {
- foreach ($metadata['sizes'] as $size => $info) {
- $old_file = $info['file'];
- $new_file = preg_replace(
- '/\.(jpe?g|png|gif)$/i',
- '.webp',
- $old_file
- );
- $metadata['sizes'][$size]['file'] = $new_file;
- $metadata['sizes'][$size]['mime-type'] = 'image/webp';
- }
- }
-
- wp_update_attachment_metadata($attachment_id, $metadata);
- }
-
- // If this is a profile/featured image, update those references
- $post_id = wp_get_post_parent_id($attachment_id);
- if ($post_id) {
- $featured_image_id = get_post_thumbnail_id($post_id);
- if ($featured_image_id === $attachment_id) {
- // Re-set the featured image to trigger any necessary updates
- set_post_thumbnail($post_id, $attachment_id);
- }
- }
-
- // Clear any caches
- clean_attachment_cache($attachment_id);
- clean_post_cache($post_id);
- }
-
- /**
- * Clean up empty temporary directories
- *
- * @param int $user_id
- * @return void
- */
- public function cleanupEmptyTempDirs(int $user_id): void
- {
- $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}";
-
- if (is_dir($temp_dir)) {
- // Check if directory is empty
- $files = scandir($temp_dir);
- $is_empty = (count($files) <= 2); // Only . and .. entries
-
- if ($is_empty) {
- // Try to remove the empty directory
- @rmdir($temp_dir);
-
- // Also check if parent temp directory is empty
- $parent_temp_dir = "{$this->upload_dir}/jvb_temp_uploads";
- $parent_files = scandir($parent_temp_dir);
- $parent_is_empty = (count($parent_files) <= 2); // Only . and .. entries
-
- if ($parent_is_empty) {
- @rmdir($parent_temp_dir);
- }
- }
- }
- }
-
- /**
- * Process video files
- */
- protected function processVideo(string $original_file, string $rel_path, string $filename, int $post_id): array
- {
- $this->validateFileSize($original_file, self::TYPE_VIDEO);
-
- $full_upload_dir = $this->upload_dir . '/' . $rel_path;
- if (!wp_mkdir_p($full_upload_dir)) {
- throw new Exception("Failed to create upload directory: {$full_upload_dir}");
- }
-
- // Keep original extension for videos
- $original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
- $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
- if (!copy($original_file, $final_path)) {
- throw new Exception("Failed to copy video file");
- }
-
- if (!file_exists($final_path)) {
- throw new Exception("Final video file was not created: {$final_path}");
- }
-
- // Generate title
- $title = $this->generateMediaTitle($final_path, get_userdata($this->user_id), $post_id, 'video');
- $attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
- // Extract video metadata
- $video_metadata = $this->extractVideoMetadata($final_path);
- if ($video_metadata) {
- update_post_meta($attachment_id, '_jvb_video_metadata', $video_metadata);
- }
-
- // Generate video thumbnail if enabled
- if ($this->config['extract_video_thumbnail']) {
- $thumbnail_id = $this->generateVideoThumbnail($final_path, $attachment_id, $post_id);
- if ($thumbnail_id) {
- update_post_meta($attachment_id, '_jvb_video_thumbnail', $thumbnail_id);
- }
- }
-
- return [
- 'success' => true,
- 'type' => self::TYPE_VIDEO,
- 'attachment_id' => $attachment_id,
- 'url' => wp_get_attachment_url($attachment_id),
- 'file' => $final_path,
- 'metadata' => $video_metadata ?? null
- ];
- }
-
- /**
- * Process document files
- */
- protected function processDocument(string $original_file, string $rel_path, string $filename, int $post_id): array
- {
- $this->validateFileSize($original_file, self::TYPE_DOCUMENT);
-
- $full_upload_dir = $this->upload_dir . '/' . $rel_path;
- if (!wp_mkdir_p($full_upload_dir)) {
- throw new Exception("Failed to create upload directory: {$full_upload_dir}");
- }
-
- // Keep original extension for documents
- $original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
- $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
- if (!copy($original_file, $final_path)) {
- throw new Exception("Failed to copy document file");
- }
-
- if (!file_exists($final_path)) {
- throw new Exception("Final document file was not created: {$final_path}");
- }
-
- // Generate title
- $title = $this->generateMediaTitle($final_path, get_userdata($this->user_id), $post_id, 'document');
- $attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
- // Extract document metadata
- $doc_metadata = $this->extractDocumentMetadata($final_path);
- if ($doc_metadata) {
- update_post_meta($attachment_id, '_jvb_document_metadata', $doc_metadata);
- }
-
- return [
- 'success' => true,
- 'type' => self::TYPE_DOCUMENT,
- 'attachment_id' => $attachment_id,
- 'url' => wp_get_attachment_url($attachment_id),
- 'file' => $final_path,
- 'metadata' => $doc_metadata ?? null
- ];
- }
-
- /**
- * Extract video metadata using getID3 or FFmpeg if available
- */
- protected function extractVideoMetadata(string $file_path): ?array
- {
- $metadata = [
- 'filesize' => filesize($file_path),
- 'mime_type' => mime_content_type($file_path)
- ];
-
- // Try FFmpeg first (more reliable)
- if ($this->hasFFmpeg()) {
- $ffmpeg_data = $this->getVideoMetadataFFmpeg($file_path);
- if ($ffmpeg_data) {
- return array_merge($metadata, $ffmpeg_data);
- }
- }
-
- // Fallback to getID3 if available
- if (class_exists('getID3')) {
- $getID3 = new \getID3();
- $file_info = $getID3->analyze($file_path);
-
- if (isset($file_info['video'])) {
- $metadata['duration'] = $file_info['playtime_seconds'] ?? null;
- $metadata['width'] = $file_info['video']['resolution_x'] ?? null;
- $metadata['height'] = $file_info['video']['resolution_y'] ?? null;
- $metadata['codec'] = $file_info['video']['codec'] ?? null;
- $metadata['bitrate'] = $file_info['bitrate'] ?? null;
- }
- }
-
- return $metadata;
- }
-
- /**
- * Get video metadata using FFmpeg
- */
- protected function getVideoMetadataFFmpeg(string $file_path): ?array
- {
- $ffprobe_path = $this->getFFprobePath();
- if (!$ffprobe_path) {
- return null;
- }
-
- $command = sprintf(
- '%s -v quiet -print_format json -show_format -show_streams %s',
- escapeshellarg($ffprobe_path),
- escapeshellarg($file_path)
- );
-
- $output = shell_exec($command);
- if (!$output) {
- return null;
- }
-
- $data = json_decode($output, true);
- if (!$data) {
- return null;
- }
-
- $metadata = [];
-
- // Get duration
- if (isset($data['format']['duration'])) {
- $metadata['duration'] = (float) $data['format']['duration'];
- }
-
- // Get video stream info
- foreach ($data['streams'] ?? [] as $stream) {
- if ($stream['codec_type'] === 'video') {
- $metadata['width'] = $stream['width'] ?? null;
- $metadata['height'] = $stream['height'] ?? null;
- $metadata['codec'] = $stream['codec_name'] ?? null;
- $metadata['bitrate'] = $stream['bit_rate'] ?? null;
- break;
- }
- }
-
- return $metadata;
- }
-
- /**
- * Check if FFmpeg is available
- */
- protected function hasFFmpeg(): bool
- {
- return $this->getFFprobePath() !== null;
- }
-
- /**
- * Get FFprobe path (companion tool to FFmpeg)
- */
- protected function getFFprobePath(): ?string
- {
- $paths = ['ffprobe', '/usr/bin/ffprobe', '/usr/local/bin/ffprobe'];
-
- foreach ($paths as $path) {
- if (shell_exec("which {$path}")) {
- return $path;
- }
- }
-
- return null;
- }
-
- /**
- * Generate video thumbnail
- */
- protected function generateVideoThumbnail(string $video_path, int $video_attachment_id, int $post_id): ?int
- {
- if (!$this->hasFFmpeg()) {
- return null;
- }
-
- $ffmpeg_path = str_replace('ffprobe', 'ffmpeg', $this->getFFprobePath());
- if (!$ffmpeg_path) {
- return null;
- }
-
- // Generate thumbnail filename
- $thumbnail_dir = dirname($video_path) . '/thumbnails';
- wp_mkdir_p($thumbnail_dir);
-
- $thumbnail_filename = pathinfo($video_path, PATHINFO_FILENAME) . '-thumb.jpg';
- $thumbnail_path = $thumbnail_dir . '/' . $thumbnail_filename;
-
- // Extract frame at specified time
- $time = $this->config['video_thumbnail_time'];
- $command = sprintf(
- '%s -i %s -ss %d -vframes 1 -q:v 2 %s 2>&1',
- escapeshellarg($ffmpeg_path),
- escapeshellarg($video_path),
- $time,
- escapeshellarg($thumbnail_path)
- );
-
- shell_exec($command);
-
- if (!file_exists($thumbnail_path)) {
- return null;
- }
-
- // Create attachment for thumbnail
- $title = get_the_title($video_attachment_id) . ' - Thumbnail';
- $thumbnail_id = $this->createAttachment($thumbnail_path, $title, $post_id);
-
- return $thumbnail_id;
- }
-
- /**
- * Extract document metadata
- */
- protected function extractDocumentMetadata(string $file_path): array
- {
- $metadata = [
- 'filesize' => filesize($file_path),
- 'mime_type' => mime_content_type($file_path),
- 'extension' => pathinfo($file_path, PATHINFO_EXTENSION)
- ];
-
- // PDF-specific metadata
- if ($metadata['mime_type'] === 'application/pdf') {
- $pdf_metadata = $this->extractPdfMetadata($file_path);
- if ($pdf_metadata) {
- $metadata = array_merge($metadata, $pdf_metadata);
- }
- }
-
- return $metadata;
- }
-
- /**
- * Extract PDF metadata
- */
- protected function extractPdfMetadata(string $file_path): ?array
- {
- $metadata = [];
-
- // Try using pdfinfo if available
- if (shell_exec('which pdfinfo')) {
- $output = shell_exec('pdfinfo ' . escapeshellarg($file_path));
- if ($output) {
- if (preg_match('/Pages:\s+(\d+)/', $output, $matches)) {
- $metadata['pages'] = (int) $matches[1];
- }
- if (preg_match('/Title:\s+(.+)/', $output, $matches)) {
- $metadata['title'] = trim($matches[1]);
- }
- if (preg_match('/Author:\s+(.+)/', $output, $matches)) {
- $metadata['author'] = trim($matches[1]);
- }
- }
- }
-
- // Fallback to basic file parsing if pdfinfo not available
- if (empty($metadata)) {
- $content = file_get_contents($file_path, false, null, 0, 1024);
- if (preg_match('/\/Count\s+(\d+)/', $content, $matches)) {
- $metadata['pages'] = (int) $matches[1];
- }
- }
-
- return $metadata ?: null;
- }
-
- /**
- * Generate media title (for videos and documents)
- */
- protected function generateMediaTitle(string $file_path, object $user_data, int $post_id, string $type): string
- {
- $filename = pathinfo($file_path, PATHINFO_FILENAME);
- $post_title = $post_id ? get_the_title($post_id) : '';
-
- $title = sprintf(
- '%s %s by %s',
- ucfirst($type),
- $post_title ? "for {$post_title}" : $filename,
- $user_data->display_name
- );
-
- /**
- * Filter the generated media title
- */
- return apply_filters('jvb_media_title', $title, $file_path, $user_data, $post_id, $type);
- }
-}
diff --git a/inc/managers/UserTermsManager.php b/inc/managers/UserTermsManager.php
index b900dff..4673568 100644
--- a/inc/managers/UserTermsManager.php
+++ b/inc/managers/UserTermsManager.php
@@ -1,8 +1,6 @@
<?php
namespace JVBase\managers;
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
use WP_Post;
use WP_Error;
use Exception;
@@ -13,7 +11,7 @@
class UserTermsManager
{
private string $table_name;
- private CacheManager $cache;
+ private Cache $cache;
private string $cacheGroup = 'user_terms_';
private int $ttl = DAY_IN_SECONDS; // 1 day default
protected \wpdb $wpdb;
@@ -41,12 +39,8 @@
*/
public function clearUserCache(int $user_id, string|null $taxonomy = null):void
{
- $cache = CacheManager::for($user_id.'_term_relationships');
- if ($taxonomy) {
- $cache->delete(jvbNoBase($taxonomy));
- } else {
- $cache->invalidate();
- }
+ $cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
+ $cache->flush();
}
// Update term usage when a post is saved
@@ -592,10 +586,9 @@
private function fetchUserTerms(int $user_id, string $taxonomy, array $args):array
{
$taxonomy = jvbCheckBase($taxonomy);
- $cache = CacheManager::for($user_id.'_term_relationships');
+ $cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
$key = $cache->generateKey(array_merge(
[
- 'user' => $user_id,
'taxonomy' => $taxonomy,
],
$args
@@ -683,59 +676,4 @@
}, $terms);
}
- /**
- * @param int $user_id
- *
- * @return bool
- */
- public function warmCache(int $user_id):bool
- {
- // Get all taxonomies
- $taxonomies = getTaxonomies(['_builtin' => false], 'names');
-
- foreach ($taxonomies as $taxonomy) {
- if (str_starts_with($taxonomy, BASE)) {
- // Pre-cache the most common queries
- $common_args = [
- // Most frequently used terms
- [
- 'orderby' => 'count',
- 'order' => 'DESC',
- 'limit' => 20,
- 'include_parents' => true
- ],
- // Recently used terms
- [
- 'orderby' => 'last_used',
- 'order' => 'DESC',
- 'limit' => 20,
- 'include_parents' => true
- ],
- // Alphabetical list
- [
- 'orderby' => 'name',
- 'order' => 'ASC',
- 'limit' => 0,
- 'include_parents' => true
- ],
- // Direct terms only (no parents)
- [
- 'orderby' => 'count',
- 'order' => 'DESC',
- 'limit' => 0,
- 'only_direct' => true
- ]
- ];
-
- foreach ($common_args as $args) {
- // Force skip_cache to ensure we get fresh data
- $args['skip_cache'] = true;
-
- // Warm the cache by executing the query
- $this->getUserTerms($user_id, $taxonomy, $args);
- }
- }
- }
- return true;
- }
}
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 84cad06..27b90b7 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -1,15 +1,21 @@
<?php
+use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
use JVBase\utility\Features;
-
require(JVB_DIR . '/inc/managers/ScriptLoader.php');
-require(JVB_DIR . '/inc/managers/CacheManager.php');
+//require(JVB_DIR . '/inc/managers/CacheManager.php');
+require(JVB_DIR . '/inc/managers/Cache.php');
+class_alias('JVBase\managers\Cache', 'JVBase\managers\CacheManager');
+
+
require(JVB_DIR . '/inc/managers/IconsManager.php');
-add_action('init', 'jvbInitIconsManager', 1); // Priority 1 - very early
-function jvbInitIconsManager(): void
+add_action('init', 'jvbInit', 1); // Priority 1 - very early
+function jvbInit(): void
{
+
+ Cache::registerHooks();
// Initialize base sources (this registers hooks and includes defaults)
IconsManager::for('icons');
IconsManager::for('forms');
@@ -27,7 +33,7 @@
if (Features::forSite()->has('magicLink')) {
require(JVB_DIR . '/inc/managers/MagicLinkManager.php');
}
-require(JVB_DIR . '/inc/managers/AjaxRateLimiter.php');
+
require(JVB_DIR . '/inc/managers/LoginManager.php');
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
index 66ceed0..ce213f2 100644
--- a/inc/managers/queue/Queue.php
+++ b/inc/managers/queue/Queue.php
@@ -61,6 +61,36 @@
try {
$incoming = $this->buildOperation($type, $userId, $data, $options);
$mergeable = $this->registry->getMergeable($type);
+ $existingById = $this->storage->find($incoming->id);
+
+ if ($existingById) {
+ // Operation with this ID already exists
+ if (in_array($existingById->state, ['pending', 'scheduled']) && $mergeable) {
+ // Still pending and mergeable, merge into it
+ $merged = $mergeable->merge($existingById, $incoming);
+ $this->storage->save($merged);
+ $this->runQueueOnShutdown();
+
+ return [
+ 'success' => true,
+ 'operation_id' => $merged->id,
+ 'updated_existing' => true,
+ ];
+ } else {
+ // Already processing/completed, or not mergeable - generate new ID
+ $incoming->id = 'u' . $userId . '_' . time() . '_' . uniqid();
+
+ JVB()->error()->log(
+ '[Queue]:add',
+ 'Duplicate ID for non-mergeable operation, generated new ID',
+ [
+ 'type' => $type,
+ 'existing_state' => $existingById->state,
+ ],
+ 'warning'
+ );
+ }
+ }
if ($mergeable) {
$existing = $this->storage->findMergeable($type, $userId);
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
index 2aa9ab6..0075d3a 100644
--- a/inc/managers/queue/Storage.php
+++ b/inc/managers/queue/Storage.php
@@ -4,15 +4,14 @@
exit;
}
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
class Storage
{
private \wpdb $wpdb;
private string $table;
- private CacheManager $cache;
+ private Cache $cache;
- private const CACHE_USER_PREFIX = 'user_queue_';
private const CACHE_QUEUE_INFO = 'queue_info';
public function __construct()
@@ -20,7 +19,7 @@
global $wpdb;
$this->wpdb = $wpdb;
$this->table = $wpdb->prefix . BASE . '_operation_queue';
- $this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
+ $this->cache = Cache::for('queue', DAY_IN_SECONDS);
}
public function hasProcessingOperations(): bool
@@ -360,13 +359,11 @@
public function invalidateQueueCache(): void
{
- $this->cache->delete(self::CACHE_QUEUE_INFO);
- $this->cache->touch();
+ $this->cache->forget(self::CACHE_QUEUE_INFO);
}
private function invalidateUser(int $userId): void
{
- CacheManager::invalidateAll("user_{$userId}");
- $this->cache->delete(self::CACHE_QUEUE_INFO);
+ $this->cache->forget($userId);
}
}
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index cf3936d..dd4c80c 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -1,7 +1,6 @@
<?php
namespace JVBase\managers\queue\executors;
-use JVBase\managers\CacheManager;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
@@ -104,7 +103,11 @@
}
$this->savePostFields($newId, $postData);
- $results[$id] = ['success' => true, 'new_id' => $newId];
+ $results[$id] = [
+ 'success' => true,
+ 'new_id' => $newId,
+ 'processed_fields' => array_keys($postData)
+ ];
if (Features::forContent($content)->has('is_timeline')) {
$this->updateTimelineLatestDate($newId);
@@ -117,6 +120,7 @@
// Existing post update
if (!$this->verifyOwnership((int)$id)) {
$progress->failItem($id, 'No permission to modify this post');
+ $errors[$id] = 'No permission';
continue;
}
// Check if this is a timeline post
@@ -156,23 +160,34 @@
$this->updateTimelineLatestDate($parentId);
}
- $results[$id] = ['success' => true];
+ $results[$id] = [
+ 'success' => true,
+ 'processed_fields' => array_keys($postData)
+ ];
$progress->advance(1);
- // Clear caches
- CacheManager::for($content)->clear();
- if (jvbSiteUsesFeedBlock()) {
- CacheManager::for('feed')->clear();
- }
-
} catch (Exception $e) {
$progress->failItem($id, $e->getMessage());
$errors[$id] = $e->getMessage();
+ $results[$id] = [
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ];
}
}
if (!empty($updateTimelineOrder)) {
- foreach ($updateTimelineOrder as $parentID) {
- $this->reorderTimelineByDate($parentID);
+ $processedParents = []; // Track to avoid duplicate processing
+
+ foreach ($updateTimelineOrder as $oldParentID) {
+ if (in_array($oldParentID, $processedParents)) continue;
+
+ $actualParentId = $this->reorderTimelineByDate($oldParentID);
+ $processedParents[] = $actualParentId;
+
+ // If parent changed, mark the new parent as processed too
+ if ($actualParentId !== $oldParentID) {
+ $processedParents[] = $oldParentID;
+ }
}
}
@@ -193,7 +208,12 @@
return new Result(
outcome: $outcome,
- result: $results
+ result: [
+ 'posts' => $results,
+ 'errors' => $errors,
+ 'updated_count' => count(array_filter($results, fn($r) => $r['success'] ?? false)),
+ 'failed_count' => count($errors)
+ ]
);
}
@@ -247,10 +267,10 @@
}
}
- private function reorderTimelineByDate(int $parentId): void
+ private function reorderTimelineByDate(int $parentId): int
{
$parent = get_post($parentId);
- if (!$parent) return;
+ if (!$parent) return $parentId;
// Get all posts in this timeline (parent + children)
$children = get_posts([
@@ -269,6 +289,7 @@
});
$newParent = $allPosts[0];
+ $actualParentId = $newParent->ID; // Track the actual parent
// If parent changed, restructure
if ($newParent->ID !== $parentId) {
@@ -320,13 +341,16 @@
$timelineTerm = $this->calculateTimelineTerm($previousPost, $post);
if ($timelineTerm) {
- $this->getorCreateTerm($post->ID, $timelineTerm, 'timeline');
+ $this->getOrCreateTerm($post->ID, $timelineTerm, 'timeline');
}
$previousPost = $post;
}
- $this->updateTimelineLatestDate($newParent->ID);
+ // Update latest_date AFTER reordering with the actual parent
+ $this->updateTimelineLatestDate($actualParentId);
+
+ return $actualParentId; // Return the actual parent ID
}
private function updateTimelineLatestDate(int $parentId): void
@@ -345,6 +369,9 @@
'fields' => 'ids'
]);
+ // Count: parent + children
+ $number = count($children) + 1;
+
$allPostIds = array_merge([$parentId], $children);
// Get all timestamps
@@ -355,7 +382,8 @@
$latestTimestamp = max($timestamps);
- // Store as UNIX timestamp
+ // Update both meta fields
+ update_post_meta($parentId, BASE . 'number', $number);
update_post_meta($parentId, BASE . 'latest_date', $latestTimestamp);
}
@@ -462,10 +490,6 @@
$results = $this->createFromDirect($operation, $data, $images, $progress);
}
- // Clear caches
- CacheManager::for($data['content'])->clear();
- CacheManager::for('feed')->clear();
-
return new Result(
outcome: !empty($results) ? 'success' : 'failed',
result: $results
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index c71e680..7461cfd 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -385,6 +385,7 @@
$defaultTitle = 'New '.$config['singular']. ' ';
foreach($data['posts'] as $index => $post) {
+ $progress->advance();
$title = array_key_exists('post_title', $post['fields'])
? sanitize_text_field($post['fields']['post_title'])
: $defaultTitle . ($index + 1);
@@ -405,6 +406,7 @@
$parent = wp_insert_post($args);
$progress->advance();
if ($parent && !is_wp_error($parent)) {
+
$childPosts = [];
$featured = $post['fields']['featured']??null;
$featuredID = null;
@@ -442,6 +444,8 @@
}
}
}
+
+ $this->updateTimelineMetadata($parent);
}
}
return new Result(
@@ -450,6 +454,30 @@
);
}
+ /**
+ * Update timeline parent post with count and latest date
+ * @param int $parentId Parent timeline post ID
+ */
+ private function updateTimelineMetadata(int $parentId): void
+ {
+ // Get all child posts
+ $children = get_children([
+ 'post_parent' => $parentId,
+ 'post_type' => get_post_type($parentId),
+ 'post_status' => ['publish', 'draft'],
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'fields' => 'ids'
+ ]);
+
+ // Count includes parent + children
+ $number = count($children) + 1;
+
+ // Update both meta fields
+ update_post_meta($parentId, BASE . 'number', $number);
+ update_post_meta($parentId, BASE . 'latest_date', time());
+ }
+
// ─────────────────────────────────────────────────────────────
// Helper methods
// ─────────────────────────────────────────────────────────────
diff --git a/inc/meta/MetaFormOld.php b/inc/meta/MetaFormOld.php
index 7682a19..22851a5 100644
--- a/inc/meta/MetaFormOld.php
+++ b/inc/meta/MetaFormOld.php
@@ -525,7 +525,7 @@
$term = get_term($termId, $taxonomy);
if ($term && !is_wp_error($term)) {
$processedSelected[$term->term_id] = [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'path' => TaxonomySelector::getTermPath($term)
];
}
@@ -909,7 +909,7 @@
$term = get_term($item_id, $taxonomy);
if (!is_wp_error($term) && $term) {
$item_type = 'term';
- $item_title = $term->name;
+ $item_title = html_entity_decode($term->name);
$item_object = $term->taxonomy;
break;
}
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index e3f5e1f..62e641d 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -1081,6 +1081,9 @@
wp_update_term($this->object_id, $this->data->taxonomy, $setFields);
break;
}
+ } elseif ($this->object_type === 'post' && !empty($this->object_id)) {
+ //Update the 'post modified' date with meta updates, for filtering
+ wp_update_post(['ID' => $this->object_id]);
}
} catch (Exception $e) {
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index 13a75f8..f30eb55 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -6,7 +6,6 @@
use JVBase\utility\Features;
use WP_Post;
use JVBase\meta\MetaRegistry;
-use JVBase\managers\CacheManager;
if (!defined('ABSPATH')) {
exit;
}
diff --git a/inc/registry/TaxonomyRegistrar.php b/inc/registry/TaxonomyRegistrar.php
index c1bf6e2..67fed28 100644
--- a/inc/registry/TaxonomyRegistrar.php
+++ b/inc/registry/TaxonomyRegistrar.php
@@ -3,10 +3,6 @@
use JVBase\meta\MetaManager;
use JVBase\meta\MetaRegistry;
-use JVBase\managers\CacheManager;
-use JVBase\utility\Checker;
-use Exception;
-use WP_Error;
if (!defined('ABSPATH')) {
exit;
}
@@ -31,7 +27,6 @@
if ($this->config['is_content'] ?? false) {
$this->setupContentTaxonomyHooks();
}
- $this->registerHooks();
}
public function register(): void
@@ -217,7 +212,7 @@
// Prepare data for insertion/update
$data = [
'term_id' => $term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'slug' => $term->slug,
'updated_at' => current_time('mysql')
];
@@ -346,30 +341,4 @@
{
return jvbContentTaxonomiesTableFields($this->slug)['fields'] ?? [];
}
-
- protected function registerHooks():void
- {
- $actions = ['created_term', 'edited_term', 'delete_term'];
- $taxonomy = $this->taxonomy;
- foreach ($actions as $action) {
- add_action($action, function($term_id, $tt_id, $tax) use ($taxonomy, $action) {
- if ($tax !== $taxonomy) return;
-
- $term = get_term($term_id, $tax);
-
- CacheManager::for(jvbNoBase($taxonomy))->invalidate();
- CacheManager::for(jvbNoBase($taxonomy))->clear();
-
- // Clear cache for associated content types
- $checker = Checker::getInstance();
- $content_types = $checker->getContentForTaxonomy($taxonomy);
-
- foreach ($content_types as $content_type) {
- CacheManager::for($content_type)->invalidate();
- }
-
- do_action("jvb_taxonomy_cache_invalidated_{$taxonomy}", $term, $action);
- }, 10, 3);
- }
- }
}
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 37db58e..0bf3041 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -6,7 +6,7 @@
use JVBase\JVB;
use JVBase\rest\RateLimiter;
use JVBase\managers\OperationQueue;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\managers\NotificationManager;
use JVBase\utility\Features;
use WP_REST_Request;
@@ -35,7 +35,7 @@
protected array $callback; //route->callback array
protected string $operation_type; // from QueueManager.js and OperationQueue.php
protected OperationQueue $queue;
- protected CacheManager $cache;
+ protected Cache $cache;
protected NotificationManager $notifications;
protected string $cache_name ='';
protected int $cache_ttl = 3600; //1 hour default
@@ -53,7 +53,7 @@
$this->base = BASE;
$this->rate_limiter = new RateLimiter();
if ($this->cache_name !== '') {
- $this->cache = CacheManager::for($this->cache_name, $this->cache_ttl);
+ $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
}
add_action('rest_api_init', [$this, 'registerRoutes']);
}
@@ -229,11 +229,11 @@
*/
protected function checkUser(int $userID): bool
{
- $cache = CacheManager::for('users');
+ $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
- return $cache->remember("user_exists_{$userID}", function() use ($userID) {
+ return $cache->remember($userID, function() use ($userID) {
return (bool)get_userdata($userID);
- }, DAY_IN_SECONDS);
+ });
}
/**
@@ -241,11 +241,11 @@
*/
protected function checkShop(int $shopID): bool
{
- $cache = CacheManager::for('shop');
+ $cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy');
- return $cache->remember("shop_exists_{$shopID}", function() use ($shopID) {
+ return $cache->remember($shopID, function() use ($shopID) {
return (bool)term_exists($shopID, BASE . 'shop');
- }, DAY_IN_SECONDS);
+ });
}
/**
@@ -264,11 +264,11 @@
}
$taxonomy = jvbCheckBase($taxonomy);
- $cache = CacheManager::for($taxonomy);
+ $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
- return $cache->remember("term_exists_{$termID}", function() use ($termID, $taxonomy) {
+ return $cache->remember($termID, function() use ($termID, $taxonomy) {
return (bool)term_exists($termID, $taxonomy);
- }, DAY_IN_SECONDS);
+ });
}
/**
@@ -276,63 +276,95 @@
*/
public function isVerifiedUser(int $user_id): bool
{
- $cache = CacheManager::forUser($user_id);
+ $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
- return $cache->remember('is_verified', function() use ($user_id) {
+ return $cache->remember($user_id, function() use ($user_id) {
return user_can($user_id, 'skip_moderation');
- }, DAY_IN_SECONDS);
+ });
}
- protected function applyTaxonomyFilters(array $args, array $data):array
- {
- $taxQuery = [];
- foreach($data['taxonomies']??[] as $taxonomy => $terms) {
- if (array_key_exists(jvbNoBase($taxonomy), JVB_TAXONOMY)) {
- $taxQuery[] = [
- 'taxonomy' => jvbCheckBase($taxonomy),
- 'terms' => array_map(
- 'absint',
- is_array($terms) ? $terms : explode(',', $terms)
- )
- ];
- }
+ protected function applyTaxonomyFilters(array $args, array $data):array
+ {
+ // Handle JSON-encoded taxonomy data
+ if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
+ $data['taxonomy'] = json_decode($data['taxonomy'], true);
}
- if (!empty($taxQuery)) {
- $args['tax_query'] = array_merge([
- 'relation' => (array_key_exists('match', $data)) ? 'AND' : 'OR',
- ], $taxQuery);
- }
+ $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
+ $taxQuery = [];
- $authorQuery = [];
- foreach (jvbAuthorUsers() as $type) {
- if (array_key_exists($type, $data)) {
- $artist_ids = array_map(
- 'absint',
- is_array($data[$type]) ?
- $data[$type] :
- explode(',', $data[$type])
- );
- $authorQuery = array_merge($authorQuery, $artist_ids);
- }
- }
- if (!empty($authorQuery)) {
- $args['author__in'] = array_unique($authorQuery);
- }
+ foreach($taxonomies as $taxonomy => $terms) {
+ // Better validation: check if taxonomy actually exists
+ if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+ continue;
+ }
- return $args;
- }
+ $taxQuery[] = [
+ 'taxonomy' => jvbCheckBase($taxonomy),
+ 'field' => 'term_id',
+ 'terms' => array_map(
+ 'absint',
+ is_array($terms) ? $terms : explode(',', $terms)
+ ),
+ 'operator' => 'IN'
+ ];
+ }
+
+ if (!empty($taxQuery)) {
+ // Match 'all' = AND, anything else = OR
+ $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+ $args['tax_query'] = array_merge([
+ 'relation' => $relation,
+ ], $taxQuery);
+ }
+
+ // Keep existing author filtering logic
+ $authorQuery = [];
+ foreach (jvbAuthorUsers() as $type) {
+ if (array_key_exists($type, $data)) {
+ $artist_ids = array_map(
+ 'absint',
+ is_array($data[$type]) ?
+ $data[$type] :
+ explode(',', $data[$type])
+ );
+ $authorQuery = array_merge($authorQuery, $artist_ids);
+ }
+ }
+ if (!empty($authorQuery)) {
+ $args['author__in'] = array_unique($authorQuery);
+ }
+
+ return $args;
+ }
protected function applyOrderFilters(array $args, array $data):array
{
+ // Check for custom order first
+ $customArgs = $this->applyCustomOrder($args, $data);
+ if ($customArgs !== null) {
+ $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+ $customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+ return $customArgs;
+ }
+
+ //Handle random
if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
$current_seed = jvbGetRandomSeed();
$args['orderby'] = 'RAND(' . $current_seed . ')';
unset($args['order']);
return $args;
}
- if (in_array($data['orderby'], ['date', 'title', 'alphabetical'])) {
- $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+
+ if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
+ if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
+ $args['meta_key'] = BASE . 'latest_date';
+ $args['orderby'] = 'meta_value_num';
+ } else {
+ $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+ }
+
} else {
switch ($data['orderby']) {
case 'popularity':
@@ -343,13 +375,18 @@
$args['meta_key'] = BASE.'karma';
$args['orderby'] = 'meta_value_num';
break;
- default:
- if ($this->isTimeline($args, $data)) {
- $args['meta_key'] = BASE . 'latest_date';
- $args['orderby'] = 'meta_value_num';
- }else {
- $args['orderby'] = 'date';
- }
+ case 'unpopularity':
+ $args['meta_key'] = BASE.'downvotes';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'favourites':
+ $args['meta_key'] = BASE.'total_favourites';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'date':
+ default:
+ $args['orderby'] = 'date';
+ break;
}
}
$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
@@ -358,6 +395,85 @@
return $args;
}
+ /**
+ * Apply custom order if defined in content/taxonomy/user config
+ *
+ * @param array $args WP_Query args
+ * @param array $data Request data
+ * @return array|null Modified args if custom order found, null otherwise
+ */
+ protected function applyCustomOrder(array $args, array $data): ?array
+ {
+ $orderby = $data['orderby'] ?? '';
+
+ // Skip if no orderby or it's a standard order
+ if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
+ return null;
+ }
+
+ // Determine content type
+ $post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+ $content = jvbNoBase($post_type);
+
+ // Get config for this content type
+ $config = Features::getConfig($content);
+ if (!$config) {
+ return null;
+ }
+
+ // Check if this orderby is a custom order
+ $customOrders = $config['custom_order'] ?? [];
+ if (empty($customOrders) || !isset($customOrders[$orderby])) {
+ return null;
+ }
+
+ // Get field definition
+ $fields = $config['fields'] ?? [];
+ if (!isset($fields[$orderby])) {
+ return null;
+ }
+
+ $field = $fields[$orderby];
+
+ // Set meta_key
+ $args['meta_key'] = BASE . $orderby;
+
+ // Determine orderby and meta_type based on field type
+ $fieldType = $field['type'] ?? 'text';
+ $subtype = $field['subtype'] ?? '';
+
+ switch ($fieldType) {
+ case 'number':
+ $args['orderby'] = 'meta_value_num';
+ break;
+
+ case 'text':
+ $args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
+ break;
+
+ case 'date':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'DATE';
+ break;
+
+ case 'datetime':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'DATETIME';
+ break;
+
+ case 'true_false':
+ case 'checkbox':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'BINARY';
+ break;
+
+ default:
+ $args['orderby'] = 'meta_value';
+ }
+
+ return $args;
+ }
+
protected function isTimeline($args, $data):bool
{
$post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
@@ -460,55 +576,52 @@
*/
protected function checkHeaders(
WP_REST_Request $request,
- string|array $content_types,
- array $additional_params = []
- ): WP_REST_Response|null {
-
- // Get latest timestamp for the content type(s)
- $last_modified = CacheManager::getTimestamp($content_types);
-
- // Generate ETag from request params + timestamp
- $etag = $this->generateETag($request->get_params(), $additional_params, $last_modified);
-
- // Check If-None-Match (ETag) header
- $if_none_match = $request->get_header('If-None-Match');
- if ($if_none_match === $etag) {
- return $this->createNotModifiedResponse($etag, $last_modified);
- }
-
- // Check If-Modified-Since header
- $if_modified_since = $request->get_header('If-Modified-Since');
- if ($if_modified_since) {
- $if_modified_timestamp = strtotime($if_modified_since);
- if ($last_modified <= $if_modified_timestamp) {
- return $this->createNotModifiedResponse($etag, $last_modified);
- }
- }
-
- // Content has changed - store headers to add to successful response
- $this->response_headers = $this->buildCacheHeaders($etag, $last_modified);
-
- return null; // Continue processing
- }
-
- /**
- * Generate ETag from request parameters and timestamp
- *
- * @param array $params Request parameters
- * @param array $additional Additional parameters for uniqueness
- * @param int $timestamp Last modified timestamp
- * @return string ETag value with quotes
- */
- private function generateETag(array $params, array $additional, int $timestamp): string
+ int|string|array $key,
+ string|array $group = ''
+ ): WP_REST_Response|false
{
- // Combine all data that makes this response unique
- $etag_data = array_merge(
- $params,
- $additional,
- ['t' => $timestamp]
- );
+ $group = ($group!=='') ? $group : $this->cache_name;
+ $cache = $this->cache_name !== $group ? Cache::for($group) : $this->cache;
+ if (!$cache) {
+ return false;
+ }
+ if (is_array($key)) {
+ $key = $cache->generateKey($key);
+ }
- return '"' . md5(serialize($etag_data)) . '"';
+ // Prefer tag freshness if available
+ $tags = $cache->getTags();
+
+ $lastModified = $tags
+ ? $cache->getLastModifiedForTags($tags)
+ : $cache::lastModified($group);
+
+ if (!$lastModified) {
+ return false;
+ }
+
+
+ $etag = '"' . sha1($group . ':' . $key . ':' . $lastModified) . '"';
+
+ // ETag check
+ $ifNoneMatch = $request->get_header('if-none-match');
+ if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
+ return new WP_REST_Response(null, 304);
+ }
+
+ // Last-Modified check
+ $ifModifiedSince = $request->get_header('if-modified-since');
+ if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
+ return new WP_REST_Response(null, 304);
+ }
+
+ // Store headers for response phase
+ $this->response_headers = [
+ 'ETag' => $etag,
+ 'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
+ ];
+
+ return false;
}
/**
@@ -551,35 +664,16 @@
protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
{
if (!empty($this->response_headers)) {
- $response->set_headers($this->response_headers);
- $this->response_headers = []; // Clear after use
+ foreach ($this->response_headers as $name => $value) {
+ $response->header($name, $value);
+ }
+ $this->response_headers = [];
}
+
return $response;
}
/**
- * Helper: Check headers for user-specific endpoints
- * Automatically includes user_id in ETag
- *
- * @param WP_REST_Request $request The REST request
- * @param int $user_id User ID
- * @param string|array $content_types Content type(s)
- * @return WP_REST_Response|null
- */
- protected function checkUserHeaders(
- WP_REST_Request $request,
- int $user_id,
- string|array $content_types = 'user'
- ): WP_REST_Response|null {
-
- // Include user-specific timestamp
- $types = is_array($content_types) ? $content_types : [$content_types];
- $types[] = "user_{$user_id}";
-
- return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
- }
-
- /**
* Helper to return error response
*/
protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
@@ -967,121 +1061,3 @@
return JVB()->connect('cloudflare')->verifyTurnstile($token);
}
}
-//
-//Simple example:
-//public function getTattoos(WP_REST_Request $request): WP_REST_Response
-//{
-// // Check HTTP cache headers first
-// $cache_check = $this->checkHeaders($request, 'tattoo');
-// if ($cache_check) {
-// return $cache_check; // Returns 304 Not Modified
-// }
-//
-// // Get data (use CacheManager for data caching too!)
-// $filters = $request->get_params();
-// $cache = CacheManager::for('tattoo');
-//
-// $tattoos = $cache->remember($filters, function() use ($filters) {
-// return $this->queryTattoos($filters);
-// }, 300);
-//
-// $response = new WP_REST_Response(['items' => $tattoos]);
-// return $this->addCacheHeaders($response); // Add ETag and Last-Modified
-//}
-//
-//Multiple Content Types:
-//public function getTermsWithContent(WP_REST_Request $request): WP_REST_Response
-//{
-// $taxonomy = $request->get_param('taxonomy');
-//
-// // Check both taxonomy and its content types
-// $cache_check = $this->checkHeaders($request, [$taxonomy, 'tattoo', 'artwork']);
-// if ($cache_check) {
-// return $cache_check;
-// }
-//
-// // ... fetch data ...
-//
-// $response = new WP_REST_Response($data);
-// return $this->addCacheHeaders($response);
-//}
-//
-//User-specific:
-//public function getUserFavorites(WP_REST_Request $request): WP_REST_Response
-//{
-// $user_id = $request->get_param('user');
-//
-// // Automatically checks user_{$user_id} timestamp + includes user_id in ETag
-// $cache_check = $this->checkUserHeaders($request, $user_id);
-// if ($cache_check) {
-// return $cache_check;
-// }
-//
-// // Get user's favorites (cached per user)
-// $favorites = CacheManager::forUser($user_id)->remember('favorites', function() use ($user_id) {
-// return $this->getUserFavorites($user_id);
-// }, 1800);
-//
-// $response = new WP_REST_Response(['items' => $favorites]);
-// return $this->addCacheHeaders($response);
-//}
-//
-//Complex with additional params:
-//public function getFilteredContent(WP_REST_Request $request): WP_REST_Response
-//{
-// $user_id = get_current_user_id();
-// $filters = $request->get_params();
-//
-// // Include custom params in ETag for uniqueness
-// $cache_check = $this->checkHeaders(
-// $request,
-// 'tattoo',
-// [
-// 'user_id' => $user_id,
-// 'is_verified' => $this->isVerifiedUser($user_id)
-// ]
-// );
-//
-// if ($cache_check) {
-// return $cache_check;
-// }
-//
-// // ... fetch filtered data ...
-//
-// $response = new WP_REST_Response($data);
-// return $this->addCacheHeaders($response);
-//}
-
-
-
-/**
- * Use operation lock in your methods like this:
- *
- * public function updateContent(WP_REST_Request $request): WP_REST_Response
- * {
- * $user_id = get_current_user_id();
- * $content_id = $request->get_param('content_id');
- *
- * // Prevent concurrent updates
- * $lock_key = "update_{$user_id}_{$content_id}";
- * if (!$this->acquireOperationLock($lock_key)) {
- * return $this->error(
- * 'An update is already in progress. Please wait.',
- * 'concurrent_operation',
- * 409
- * );
- * }
- *
- * try {
- * // Do your operation
- * $result = $this->doUpdate($content_id);
- *
- * $this->releaseOperationLock($lock_key);
- * return $this->success($result);
- *
- * } catch (\Exception $e) {
- * $this->releaseOperationLock($lock_key);
- * return $this->error($e->getMessage(), 'operation_failed');
- * }
- * }
- */
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
index a5593cb..a32211c 100644
--- a/inc/rest/routes/AdminRoutes.php
+++ b/inc/rest/routes/AdminRoutes.php
@@ -670,7 +670,7 @@
break;
}
if ($hierarchical && $key === 'term_name') {
- $item[$key.'_path'] = JVB()->routes('term')->getTermPath($ID, $term->name, $term->taxonomy);
+ $item[$key.'_path'] = JVB()->routes('term')->getTermPath($ID, html_entity_decode($term->name), $term->taxonomy);
}
}
diff --git a/inc/rest/routes/ApprovalRoutes.php b/inc/rest/routes/ApprovalRoutes.php
index f795dac..62fc5e4 100644
--- a/inc/rest/routes/ApprovalRoutes.php
+++ b/inc/rest/routes/ApprovalRoutes.php
@@ -4,7 +4,7 @@
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\utility\Features;
use WP_User;
use WP_REST_Request;
@@ -946,7 +946,7 @@
}
// Clear caches
- CacheManager::invalidateGroup('approvals');
+ $this->cache->flush();
}
public function getApprovals(WP_REST_Request $request)
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 3fc50bb..46c7d45 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -6,7 +6,7 @@
use JVBase\managers\queue\executors\ContentExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use WP_Post;
@@ -37,7 +37,7 @@
$this->cache_name = 'user_content_' . get_current_user_id();
parent::__construct();
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
$this->action = 'dash-';
@@ -166,7 +166,6 @@
public function handleContentUpdate(WP_REST_Request $request): WP_REST_Response
{
$data = $request->get_params();
- error_log('Received data: ' . print_r($data, true));
$user_id = $data['user'];
if (!$this->userCheck($user_id)) {
@@ -257,6 +256,7 @@
{
$params = $request->get_params();
$user_id = $params['user'];
+
if (!$this->userCheck($user_id)) {
return new WP_REST_Response([
'success' => false,
@@ -316,12 +316,10 @@
$args['s'] = sanitize_text_field($params['search']);
}
+
$key = $this->cache->generateKey($args);
// Check HTTP cache headers with the specific content type
- $content_type = $params['content'] ?? $params['type'];
- $cache_check = $this->checkHeaders($request, $content_type, [
- 'filter_hash' => $key,
- ]);
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
@@ -347,7 +345,6 @@
$this->taxonomies = $this->getTaxonomies($this->post_type);
$posts = array_map([$this, 'prepareItem'], $query->posts);
-
$data = [
'items' => $posts,
'total' => $query->found_posts,
@@ -580,11 +577,6 @@
];
}
-
- CacheManager::for($post_data['content'])->clear();
- if (jvbSiteUsesFeedBlock()) {
- CacheManager::for('feed')->clear();
- }
}
if (jvbSiteHasNotifications()) {
@@ -786,20 +778,20 @@
$meta = new MetaManager($timeline['id'], 'post');
$oldValues = $meta->getAll(array_keys($allowedFields));
- // Set number taxonomy to menu_order (always update for reordering)
- if (!$is_parent) {
- $number_value = $order;
- $term = get_term_by('name', (string)$number_value, BASE . 'number');
- if (!$term) {
- $result = wp_insert_term((string)$number_value, BASE . 'number');
- if ($result && !is_wp_error($result)) {
- $term = $result['term_id'];
- }
- } else {
- $term = $term->term_id;
- }
- $allowedFields['number'] = $term;
- }
+// // Set number taxonomy to menu_order (always update for reordering)
+// if (!$is_parent) {
+// $number_value = $order;
+// $term = get_term_by('name', (string)$number_value, BASE . 'number');
+// if (!$term) {
+// $result = wp_insert_term((string)$number_value, BASE . 'number');
+// if ($result && !is_wp_error($result)) {
+// $term = $result['term_id'];
+// }
+// } else {
+// $term = $term->term_id;
+// }
+// $allowedFields['number'] = $term;
+// }
// Auto-timeline logic
if ($prevDate) {
@@ -828,7 +820,7 @@
$updateValues = array_filter($allowedFields, function ($value, $key) use ($oldValues) {
return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
}, ARRAY_FILTER_USE_BOTH);
- error_log('Setting values for ' . $timeline['id'] . ': ' . print_r($updateValues, true));
+
$meta->setAll($updateValues);
$timeline['id'] = (int)$timeline['id'];
@@ -845,8 +837,8 @@
}
if ($clearParent) {
- $this->cache->clear();
- CacheManager::onPostSave($parent_id, $parent_post);
+ $this->cache->flush();
+ Cache::onPostChange($parent_id, $parent_post);
}
@@ -1029,7 +1021,7 @@
}
$item['fields']['timeline'] = $subFields;
$item['images'] = $item['images'] + $images;
-
+ $item['number'] = $mainMeta->getValue('number');
return $item;
}
@@ -1179,10 +1171,6 @@
// $queue->updateOperationProgress($operation->id, $key + 1, $total);
}
}
-
- //Clear cache
- CacheManager::for($data['content'])->clear();
- CacheManager::for('feed')->clear();
}
return [
@@ -1203,4 +1191,121 @@
return $result;
}
+ // Add to ContentRoutes.php
+
+ /**
+ * One-time migration: Set latest_date meta for all timeline posts
+ * Call this once via WP-CLI or a temporary admin page
+ *
+ * Usage: add_action('admin_init', function() {
+ * if (current_user_can('manage_options')) {
+ * JVB()->routes('content')->migrateTimelineLatestDates();
+ * }
+ * });
+ */
+ public function migrateTimelineLatestDates(): array
+ {
+ global $wpdb;
+
+ $results = [
+ 'processed' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'errors' => []
+ ];
+
+ // Get all timeline post types
+ $timeline_types = [];
+ foreach (JVB_CONTENT as $type => $config) {
+ if (Features::forContent($type)->has('is_timeline')) {
+ $timeline_types[] = BASE . $type;
+ }
+ }
+
+ if (empty($timeline_types)) {
+ return $results;
+ }
+
+ // Get all parent timeline posts
+ $args = [
+ 'post_type' => $timeline_types,
+ 'post_status' => ['publish', 'draft'],
+ 'post_parent' => 0,
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ ];
+
+ $parent_ids = get_posts($args);
+
+ foreach ($parent_ids as $parent_id) {
+ $results['processed']++;
+
+ try {
+ // Get all children including the parent
+ $children = get_children([
+ 'post_parent' => $parent_id,
+ 'post_status' => ['publish', 'draft'],
+ 'orderby' => 'menu_order',
+ 'order' => 'ASC',
+ 'fields' => 'ids'
+ ]);
+
+ // Add parent to the list
+ array_unshift($children, $parent_id);
+
+ // Find latest date among all posts
+ $latest_timestamp = 0;
+
+ foreach ($children as $post_id) {
+ $date = get_post_meta($post_id, BASE . 'date', true);
+
+ if ($date) {
+ $timestamp = strtotime($date);
+ if ($timestamp > $latest_timestamp) {
+ $latest_timestamp = $timestamp;
+ }
+ }
+ }
+
+ // Update parent with latest date
+ if ($latest_timestamp > 0) {
+ update_post_meta($parent_id, BASE . 'latest_date', $latest_timestamp);
+ $results['updated']++;
+ error_log("Updated post {$parent_id} with latest_date: {$latest_timestamp}");
+ } else {
+ // Fallback to parent post's post_date
+ $parent_post = get_post($parent_id);
+ $fallback_timestamp = strtotime($parent_post->post_date);
+
+ if ($fallback_timestamp > 0) {
+ update_post_meta($parent_id, BASE . 'latest_date', $fallback_timestamp);
+ $results['updated']++;
+ error_log("Updated post {$parent_id} with fallback latest_date: {$fallback_timestamp} (from post_date)");
+ } else {
+ $results['skipped']++;
+ error_log("No dates found for post {$parent_id}");
+ }
+ }
+
+ } catch (Exception $e) {
+ $results['errors'][] = [
+ 'post_id' => $parent_id,
+ 'error' => $e->getMessage()
+ ];
+ }
+ }
+
+ error_log('Timeline migration complete: ' . print_r($results, true));
+ return $results;
+ }
}
+
+
+//add_action('init', function() {
+//// delete_option('jvb_timeline_migrated');
+// if (get_option('jvb_timeline_migrated')) {
+// return;
+// }
+// JVB()->routes('content')->migrateTimelineLatestDates();
+// update_option('jvb_timeline_migrated', true);
+//});
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index a5de911..19121b2 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -2,8 +2,8 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
+use JVBase\managers\Cache;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -16,16 +16,21 @@
class FavouritesRoutes extends RestRouteManager
{
protected array $valid_types;
- protected int $user_id;
+ protected Cache $listsCache;
+ protected Cache $sharedListsCache;
+ protected Cache $favouritesCache;
public function __construct()
{
$this->cache_name = 'favourites';
parent::__construct();
+ $this->cache->connect('post')->connect('user')->connect('taxonomy');
+ $this->listsCache = Cache::for('lists')->connect('favourites', true);
+ $this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true);
+ $this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true);
$this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY));
- $this->user_id = get_current_user_id();
$this->action = 'favourites-';
@@ -118,13 +123,14 @@
{
$args = $this->buildParams($request);
if (!$args['user'] || $args['user'] === ''){
- $result = [
+ return $this->addCacheHeaders(new WP_REST_Response([
'success' => false,
'message' => 'No user set'
- ];
+ ]));
}
+ $key = $this->cache->generateKey($args);
// Check HTTP cache headers for user-specific data
- $cache_check = $this->checkUserHeaders($request, $args['user'], 'favourites');
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
@@ -133,10 +139,9 @@
$result = $this->getAllFavourites($args['user']);
} else {
$result = $this->cache->remember(
- $args,
+ $this->cache->generateKey($args),
function() use ($args) {
- $response = new WP_REST_Response($this->getFilteredFavourites($args));
- return $this->addCacheHeaders($response);
+ return $this->getFilteredFavourites($args);
}
);
}
@@ -237,7 +242,7 @@
}
$result = $this->cache->remember(
- 'user_'.$user_id.'_all_favourites',
+ $user_id,
function() use ($user_id) {
return $this->fetchAllFavourites($user_id);
}
@@ -398,8 +403,15 @@
]);
}
+ $params = [
+ 'user' => $user_id,
+ ];
+ if ($request->get_param('id')) {
+ $params['list'] = sanitize_text_field($request->get_param('id'));
+ }
+ $key = $this->listsCache->generateKey($params);
// Check HTTP cache headers
- $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_lists');
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
@@ -427,28 +439,21 @@
if (!$this->checkUser($user_id)) {
return [];
}
- $key = sprintf(
- 'user_%d_lists',
- $user_id
- );
- if ($include_shared) {
- $key = $key.'_shared';
- }
- $cache = $this->cache->get($key, 'favourites_lists');
- if ($cache) {
- return $cache;
- }
- global $wpdb;
- error_log('Attempting to get available lists..');
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
+ $cache = ($include_shared) ? $this->sharedListsCache : $this->listsCache;
+ return $cache->remember(
+ $user_id,
+ function() use ($user_id, $include_shared) {
+ global $wpdb;
+ error_log('Attempting to get available lists..');
+ $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
+ $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
+ $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
- try {
- // Get owned lists
- $owned_query = $wpdb->prepare(
- "SELECT l.*,
+ try {
+ // Get owned lists
+ $owned_query = $wpdb->prepare(
+ "SELECT l.*,
COUNT(DISTINCT i.id) as item_count,
TRUE as is_owner,
FALSE as is_shared
@@ -457,16 +462,16 @@
WHERE l.user_id = %d
GROUP BY l.id
ORDER BY l.created_at DESC",
- $user_id
- );
+ $user_id
+ );
- $lists = $wpdb->get_results($owned_query);
- error_log('Lists result: '.print_r($lists, true));
+ $lists = $wpdb->get_results($owned_query);
+ error_log('Lists result: '.print_r($lists, true));
- // Add shared lists if requested
- if ($include_shared) {
- $shared_query = $wpdb->prepare(
- "SELECT l.*,
+ // Add shared lists if requested
+ if ($include_shared) {
+ $shared_query = $wpdb->prepare(
+ "SELECT l.*,
u.display_name as owner_name,
COUNT(DISTINCT i.id) as item_count,
s.permission_type,
@@ -479,35 +484,33 @@
WHERE s.user_id = %d
GROUP BY l.id
ORDER BY l.created_at DESC",
- $user_id
- );
+ $user_id
+ );
- $shared_lists = $wpdb->get_results($shared_query);
- error_log('Shared lists: '.print_r($shared_lists, true));
- $lists = [
- 'owned' => $lists,
- 'shared'=> $shared_lists,
- ];
- }
+ $shared_lists = $wpdb->get_results($shared_query);
+ error_log('Shared lists: '.print_r($shared_lists, true));
+ $lists = [
+ 'owned' => $lists,
+ 'shared'=> $shared_lists,
+ ];
+ }
+ error_log('Lists: '.print_r($lists, true));
+ return [
+ 'success' => true,
+ 'lists' => $lists
+ ];
+ } catch (Exception $e) {
+ JVB()->error()->log(
+ 'favourites',
+ 'Error getting available lists: ' . $e->getMessage(),
+ ['user_id' => $user_id],
+ 'error'
+ );
- // Cache result
- $this->cache->set($key, ['success' => true, 'lists'=>$lists], 'favourites_lists');
-
- error_log('Lists: '.print_r($lists, true));
- return [
- 'success' => true,
- 'lists' => $lists
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error getting available lists: ' . $e->getMessage(),
- ['user_id' => $user_id],
- 'error'
- );
-
- return [];
- }
+ return [];
+ }
+ }
+ );
}
/**
@@ -524,7 +527,7 @@
$user_id,
$list_id
);
- $cache = $this->cache->get($key, 'favourites_lists');
+ $cache = $this->listsCache->get($key);
if ($cache) {
return new WP_REST_Response($cache);
}
@@ -649,7 +652,7 @@
]
];
- $this->cache->set($key, $response_data, 'favourites_lists');
+ $this->listsCache->set($key, $response_data);
return new WP_REST_Response($response_data);
}
@@ -830,27 +833,30 @@
]);
}
+ $list_id = $request->get_param('list_id');
+
+ if (!$list_id) {
+ return $this->createErrorResponse(
+ self::ERROR_MISSING_PARAMS,
+ 'List ID is required',
+ 400
+ );
+ }
+
+ $args = [
+ 'user' => $user_id,
+ 'list' => sanitize_text_field($list_id),
+ ];
+ $key = $this->sharedListsCache->generateKey($args);
// Check HTTP cache headers
- $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_shares');
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
- $list_id = $request->get_param('list_id');
- if (!$list_id) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID is required',
- 400
- );
- }
- $key = sprintf(
- 'user_%d_shares_for_list_%d',
- $user_id,
- $list_id
- );
- $cache = $this->cache->get($key, 'favourites_list_shares');
+
+ $cache = $this->sharedListsCache->get($key);
if ($cache) {
return new WP_REST_Response($cache);
}
@@ -925,7 +931,7 @@
];
// Cache the results
- $this->cache->set($key, $response_data, 'favourites_list_shares');
+ $this->sharedListsCache->set($key, $response_data);
$response = new WP_REST_Response($response_data);
return $this->addCacheHeaders($response);
@@ -1265,7 +1271,7 @@
'target_id' => $term_id,
'date_added' => $item->date_added ?? current_time('mysql'),
'notes' => $item->notes ?? '',
- 'title' => $term->name,
+ 'title' => html_entity_decode($term->name),
'url' => get_term_link($term)
];
@@ -1442,8 +1448,8 @@
}
error_log('Results: '.print_r($results, true));
- $this->cache->invalidate('favourite_counts_by_type_' . $user_id.'_all');
- $this->cache->invalidate('favourite_counts_by_type_' . $user_id.'_not_all');
+ $this->cache->forget('favourite_counts_by_type_' . $user_id.'_all');
+ $this->cache->forget('favourite_counts_by_type_' . $user_id.'_not_all');
return [
'success' => true,
'result' => $results
@@ -1637,8 +1643,10 @@
$this->removeRelatedNotifications($user_id, $type, $target_id);
// Invalidate cache
- CacheManager::invalidateGroup($this->cache_name);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->cache->flush();
+ $this->listsCache->flush();
+ $this->sharedListsCache->flush();
+ $this->favouritesCache->flush();
return [
'success' => true,
@@ -1722,9 +1730,9 @@
}
// Invalidate notification cache for this user
- if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
- JVB()->notification()->clearNotificationCache($owner_id);
- }
+// if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
+// JVB()->notification()->clearNotificationCache($owner_id);
+// }
}
} catch (Exception $e) {
// Log but continue
@@ -2011,7 +2019,7 @@
$wpdb->query('COMMIT');
// Invalidate relevant caches
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return [
'success' => true,
@@ -3302,47 +3310,47 @@
switch ($operation->type) {
case 'favourites_batch':
$response = $this->processBatches($user_id, $data);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->flush();
return $response;
case 'favourite_notes':
$response = $this->processNote($user_id, $data);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->flush();
return $response;
case 'favourite_list_create':
$response = $this->processListCreate($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return $response;
case 'favourite_list_update':
$response = $this->processUpdateList($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return $response;
case 'favourite_list_delete':
$response = $this->processListDeletion($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return $response;
case 'favourite_list_add':
$response = $this->processAddToList($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return $response;
case 'favourite_list_remove':
$response = $this->removeFromList($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists');
+ $this->listsCache->flush();
return $response;
case 'favourite_list_share':
$response = $this->shareList($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists_shares');
+ $this->sharedListsCache->flush();
return $response;
case 'favourite_list_unshare':
$response = $this->unshareList($user_id, $data);
- CacheManager::invalidateGroup('favourites_lists_shares');
+ $this->sharedListsCache->flush();
return $response;
default:
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 9988cf2..2c83a63 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\rest\RestRouteManager;
use JVBase\integrations\Umami;
use JVBase\meta\MetaManager;
@@ -32,8 +32,13 @@
$this->cache_name = 'feed';
$this->cache_ttl = 86400;
parent::__construct();
+ $this->cache
+ ->connect('post')
+ ->connect('taxonomy')
+ ->connect('user');
+
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
@@ -45,28 +50,6 @@
if (Features::hasIntegration('umami')) {
$this->tracker = JVB()->connect('umami');
}
- $this->setupCacheConnections();
- }
-
- /**
- * Set up cache connections for automatic invalidation
- */
- protected function setupCacheConnections(): void
- {
- // Connect to all content types with show_feed
- $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
- foreach ($contentTypes as $type) {
- CacheManager::for('feed_item_'.$type)->connectTo('post');
- $this->cache->connectTo('post', $type);
- }
-
- // Connect to all taxonomies with show_feed
- $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
- foreach ($taxonomies as $tax) {
- CacheManager::for('feed_item_'.$tax)->connectTo('taxonomy');
- $this->cache->connectTo('taxonomy', $tax);
- }
-
}
/**
@@ -101,21 +84,20 @@
$post = get_post($postID);
$type = jvbNoBase($post->post_type);
$metaType = 'post';
- $cache = CacheManager::for('feed_item_'.$type);
break;
default:
$post = get_term($postID, jvbCheckBase($type));
$type = jvbNoBase($type);
$metaType = 'term';
- $cache = CacheManager::for('feed_item_'.$type);
break;
}
if (!$post || is_wp_error($post)) {
return [];
}
-//
-// return $cache->remember($postID,
-// function() use ($postID, $type, $metaType, $post, $skip) {
+
+ return $this->cache->remember(
+ $postID,
+ function() use ($postID, $type, $metaType, $post, $skip) {
$config = null;
switch ($metaType) {
case 'post':
@@ -184,17 +166,19 @@
$out['user_id'] = $owner;
}
$out['url'] = get_term_link($postID, $type);
+ $out['title'] = html_entity_decode($post->name);
break;
case 'post':
$out['date'] = $post->post_date;
+ $out['modified'] = $post->post_modified;
$out['user_id'] = (int)$post->post_author;
$out['url'] = get_the_permalink($postID);
+ $out['title']= get_the_title($postID);
break;
}
-// return $out;
-// }
-// );
- return $out;
+ return $out;
+ }
+ );
}
@@ -251,13 +235,14 @@
$item['taxonomies'] = array_merge($item['taxonomies'], $this->extractTaxonomies($f, $postID, jvbNoBase($post->post_type)));
$images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
}
- $item['fields']['number'] = count($children);
+ $item['number'] = (int)get_post_meta($post->ID,BASE.'number', true);
$item['fields']['before'] = get_post_thumbnail_id($children[0]);
$item['fields']['after'] = get_post_thumbnail_id($children[array_key_last($children)]);
$item['fields']['timeline'] = $subFields;
$item['images'] = $item['images'] + $images;
+
return $item;
}
protected function extractTaxonomies(array $fields, int $postID, string $content):array {
@@ -292,13 +277,12 @@
protected function formatTaxonomy(WP_Term|int $term, int $postID, string $type)
{
- $cache = CacheManager::for(jvbNoBase($term->taxonomy));
- return $cache->remember(
- 'feed_link_'.$term->term_id,
+ return $this->cache->remember(
+ $term->term_id,
function () use ($term, $postID, $type) {
$base = [
'ID' => $term->term_id,
- 'title' => htmlspecialchars_decode($term->name),
+ 'title' => html_entity_decode($term->name),
'url' => get_term_link($term->term_id, $term->taxonomy),
];
if ($this->tracker) {
@@ -313,18 +297,26 @@
protected function getAuthorData(WP_Post $post)
{
- $author = $this->cache->get($post->post_author, 'author_data');
- if (!$author) {
- $author = [
- 'id' => $post->post_author,
- 'label' => 'Artist',
- 'value' => get_the_author_meta('display_name', $post->post_author),
- 'icon' => 'artist',
- 'url' => get_the_permalink(get_user_meta($post->post_author, BASE . 'link', true)),
- ];
- $this->cache->set($post->post_author, $author, 'author_data');
- }
- return $author;
+ $author = $post->post_author;
+ $userLink = get_user_meta($author, BASE.'link', true);
+ return $this->cache->remember(
+ $userLink,
+ function () use ($userLink, $author) {
+ $label = jvbUserRole($author);
+ if (array_key_exists($label, JVB_USER)) {
+ $label = JVB_USER[$label]['label'];
+ } else {
+ $label = 'Artist';
+ }
+ return [
+ 'id' => $userLink,
+ 'label' => $label,
+ 'value' => get_the_title($userLink),
+ 'icon' => 'user',
+ 'url' => get_the_permalink($userLink),
+ ];
+ }
+ );
}
protected function getTaxonomies(int $postID, string $content): array
@@ -340,16 +332,23 @@
'icon' => $config,
'title' => JVB_TAXONOMY[$config]['plural'],
'terms' => array_map(function ($term) use ($tax, $postID, $content) {
- return [
- 'ID' => $term->term_id,
- 'title' => htmlspecialchars_decode($term->name),
- 'url' => get_term_link($term->term_id, $tax),
- 'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
- 'from' => $content . '_' . $postID
- ])
- ];
+ $item = $this->cache->remember(
+ $term->term_id,
+ function() use ($term, $tax, $content, $postID) {
+ return [
+ 'ID' => $term->term_id,
+ 'title' => html_entity_decode($term->name),
+ 'url' => get_term_link($term->term_id, $tax),
+ ];
+ }
+ );
+ $item['umami_click'] = $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
+ 'from' => $content.'_'.$postID
+ ]);
+ return $item;
}, $terms),
];
+
}
}
return $out;
@@ -386,43 +385,43 @@
return $this->applyFavouritesFilter($args, $data);
}
- protected function applyTaxonomyFilters(array $args, array $data): array
- {
- if (!isset($data['taxonomy']) || empty($data['taxonomy'])) {
- return $args;
- }
-
- $taxonomyFilters = $data['taxonomy'];
-
- // Validate taxonomies exist and sanitize
- $validFilters = [];
- foreach ($taxonomyFilters as $taxonomy => $terms) {
- if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
- continue;
- }
-
- $validFilters[] = [
- 'taxonomy' => jvbCheckBase($taxonomy),
- 'field' => 'term_id',
- 'terms' => array_map('absint', (array)$terms),
- 'operator' => 'IN'
- ];
- }
-
- if (empty($validFilters)) {
- return $args;
- }
-
- // Determine relation based on match filter
- $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
-
- $args['tax_query'] = array_merge(
- ['relation' => $relation],
- $validFilters
- );
-
- return $args;
- }
+// protected function applyTaxonomyFilters(array $args, array $data): array
+// {
+// if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) {
+// return $args;
+// }
+//
+// $taxonomyFilters = $data['taxonomy'];
+//
+// // Validate taxonomies exist and sanitize
+// $validFilters = [];
+// foreach ($taxonomyFilters as $taxonomy => $terms) {
+// if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+// continue;
+// }
+//
+// $validFilters[] = [
+// 'taxonomy' => jvbCheckBase($taxonomy),
+// 'field' => 'term_id',
+// 'terms' => array_map('absint', (array)$terms),
+// 'operator' => 'IN'
+// ];
+// }
+//
+// if (empty($validFilters)) {
+// return $args;
+// }
+//
+// // Determine relation based on match filter
+// $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+//
+// $args['tax_query'] = array_merge(
+// ['relation' => $relation],
+// $validFilters
+// );
+//
+// return $args;
+// }
/**
* @param WP_REST_Request $request
@@ -432,19 +431,17 @@
public function handleFeedRequest(WP_REST_Request $request): WP_REST_Response
{
$args = $this->buildRequestArgs($request);
- $cacheContext = $this->buildCacheContext($args, $request);
+ $key = $this->cache->generateKey($args);
// Check HTTP cache headers first
$cache_check = $this->checkHeaders(
$request,
- $cacheContext['content_types'],
- $cacheContext['additional_params']
+ $key
);
if ($cache_check) {
return $cache_check; // Returns 304 Not Modified
}
- $key = $this->cache->generateKey($args);
$cached = $this->cache->get($key);
if ($cached) {
if ($request->get_param('highlight')) {
@@ -458,7 +455,7 @@
// Fetch and format items
$items = $this->fetchFeedItems($args);
- $ttl = (str_contains($args['orderby'], 'RAND')) ? 1800 : $this->cache_ttl;
+ $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl;
$this->cache->set($key, $items, $ttl);
if ($request->get_param('highlight')) {
@@ -1262,6 +1259,7 @@
// Get content types with show_feed
$contentTypes = Features::getTypesWithFeature('show_feed', 'content');
foreach ($contentTypes as $slug) {
+ $this->cache->tag('content:'.$slug);
$contentConfig = JVB_CONTENT[$slug] ?? null;
if (!$contentConfig) continue;
@@ -1282,6 +1280,8 @@
continue;
}
+ $this->cache->tag('taxonomy:'.$slug);
+
$config[$slug] = [
'type' => 'taxonomy',
'singular' => $taxConfig['singular'] ?? ucfirst($slug),
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index c5c0d36..c2cd228 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -2,7 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use JVBase\blocks\FormBlock;
@@ -23,7 +23,7 @@
*/
class FormRoutes extends RestRouteManager
{
- protected CacheManager $cache;
+ protected Cache $cache;
protected FormBlock $form_block;
protected CloudflareTurnstile|null $turnstile;
@@ -31,7 +31,7 @@
{
parent::__construct();
$this->action = 'form-';
- $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS);
+ $this->cache = Cache::for('forms', HOUR_IN_SECONDS);
// Add query vars
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 5c7638a..6fc4a2a 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -748,7 +748,7 @@
$toContentTax[] = sprintf(
"<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
$inviter_name,
- $term->name,
+ html_entity_decode($term->name),
$taxonomy
);
}
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 8c5d9d0..e775960 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -649,7 +649,7 @@
}
// Lockout expired - clear attempts
- $this->cache->delete($cache_key);
+ $this->cache->forget($cache_key);
return true;
}
@@ -689,7 +689,7 @@
protected function clearFailedAttempts(string $username): void
{
$cache_key = 'login_attempts_' . md5($username);
- $this->cache->delete($cache_key);
+ $this->cache->forget($cache_key);
}
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index 0811cec..57f875a 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/inc/rest/routes/NewsRoutes.php
@@ -1,9 +1,7 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
use JVBase\managers\NewsRelationships;
use WP_Query;
@@ -376,7 +374,7 @@
}
}
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->flush();
return [
'success' => true,
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index eb39e8b..0ac3889 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -386,22 +386,24 @@
]);
}
+
+ $params = $this->getSanitizedData($user_id, $data);
+ $params['user'] = $user_id;
+ $key = $this->cache->generateKey($params);
// Check HTTP cache headers (includes notification types in timestamp check)
- $cache_check = $this->checkUserHeaders($request, $user_id, 'notifications');
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
// Step 1: Build status/order/filter params
- $params = $this->getSanitizedData($user_id, $data);
$status = $params['status'];
$limit = $params['limit'];
$offset = $params['page'];
$type = $params['type'];
// Try cache first with validated parameters
- $cache_key = "user_{$user_id}_merged_notifications_{$status}_{$type}_{$limit}_{$offset}";
- $cached = $this->cache->get($cache_key);
+ $cached = $this->cache->get($key);
if ($cached) {
$response = new WP_REST_Response($cached);
return $this->addCacheHeaders($response);
@@ -447,7 +449,7 @@
];
// Cache the result
- $this->cache->set($cache_key, $response, 'notifications_' . $user_id);
+ $this->cache->set($key, $response);
$response = new WP_REST_Response($response);
return $this->addCacheHeaders($response);
} catch (Exception $e) {
diff --git a/inc/rest/routes/OptionsRoutes.php b/inc/rest/routes/OptionsRoutes.php
index ebf18d6..a4a1ead 100644
--- a/inc/rest/routes/OptionsRoutes.php
+++ b/inc/rest/routes/OptionsRoutes.php
@@ -3,7 +3,7 @@
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\meta\MetaSanitizer;
use WP_REST_Request;
@@ -109,8 +109,8 @@
do_action('jvbOptionsRoute', $data);
- $cache = CacheManager::for('options', 1800);
- $cache->invalidate();
+ $cache = Cache::for('options', 1800);
+ $cache->flush();
return [
'success' => true,
'result' => $results
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index b26bc70..db567e3 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -3,7 +3,7 @@
use Exception;
use JVBase\JVB;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\rest\RestRouteManager;
use WP_REST_Request;
use WP_REST_Response;
@@ -23,7 +23,7 @@
parent::__construct();
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
@@ -94,7 +94,8 @@
$ids = $request->get_param('ids');
$limit = intval($request->get_param('limit'));
// Use base class user-specific header checking
- $cache_check = $this->checkUserHeaders($request, $user_id, 'queue');
+ $key = $this->cache->generateKey(['user'=> $user_id, 'status'=> $status, 'ids'=> $ids, 'limit'=> $limit]);
+ $cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
@@ -285,7 +286,7 @@
$result = $this->processQueueAction($action, $operations, $user_id);
if ($result['success']) {
- CacheManager::updateTimestamp("user_{$user_id}");
+ Cache::touch($user_id);
}
return new WP_REST_Response($result);
diff --git a/inc/rest/routes/ReferralRoutes.php b/inc/rest/routes/ReferralRoutes.php
index fd0627b..6f920c8 100644
--- a/inc/rest/routes/ReferralRoutes.php
+++ b/inc/rest/routes/ReferralRoutes.php
@@ -312,7 +312,7 @@
}
}
- $this->cache->clear();
+ $this->cache->flush();
return $this->success(['message' => "Referral marked as {$status}"]);
}
@@ -348,7 +348,7 @@
}
$this->wpdb->delete($this->referrals_table, ['id' => $referral_id], ['%d']);
- $this->cache->clear();
+ $this->cache->flush();
return $this->success(['message' => 'Referral removed']);
}
@@ -508,7 +508,7 @@
];
update_option(BASE . 'referral_settings', $settings);
- $this->cache->clear();
+ $this->cache->flush();
return $this->success([
'message' => 'Settings updated',
@@ -638,7 +638,7 @@
$data['message']
);
if ($result['success']) {
- $this->cache->clear();
+ $this->cache->flush();
}
// Build summary message
@@ -735,7 +735,7 @@
}
// Clear cache
- $this->cache->clear();
+ $this->cache->flush();
return new WP_REST_Response([
'success' => true,
@@ -805,7 +805,7 @@
}
// Clear cache
- $this->cache->clear();
+ $this->cache->flush();
return new WP_REST_Response([
'success' => true,
diff --git a/inc/rest/routes/ResponseRoutes.php b/inc/rest/routes/ResponseRoutes.php
index 0c430a7..31cbd9d 100644
--- a/inc/rest/routes/ResponseRoutes.php
+++ b/inc/rest/routes/ResponseRoutes.php
@@ -3,7 +3,7 @@
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -245,9 +245,8 @@
}
error_log('Sanitized action. Here we go!');
- $this->queue = JVB()->queue();
- // Add to queue
- $operation = $this->queue->queueOperation(
+ // Add to queue
+ $operation = JVB()->queue()->queueOperation(
$action.'_response',
$user_id,
$queue_data,
@@ -271,7 +270,6 @@
*/
public function updateResponse(WP_REST_Request $request):WP_REST_Response
{
- $this->queue = JVB()->queue();
$id = (int) $request->get_param('id');
$data = $request->get_params();
$user_id = (int) $data['user'] ?? get_current_user_id();
@@ -308,7 +306,7 @@
];
// Add to queue
- $operation = $this->queue->queueOperation(
+ $operation = JVB()->queue()->queueOperation(
'update_response',
$user_id,
$queue_data,
@@ -329,7 +327,6 @@
*/
public function deleteResponse(WP_REST_Request $request):WP_REST_Response
{
- $this->queue = JVB()->queue();
$id = (int) $request->get_param('id');
$user_id = get_current_user_id();
$operation_id = $request->get_param('id') ?? uniqid('response_delete_');
@@ -355,7 +352,7 @@
}
// Add to queue
- $operation = $this->queue->queueOperation(
+ $operation = JVB()->queue()->queueOperation(
'delete_response',
$user_id,
['response_id' => $id],
@@ -457,8 +454,7 @@
}
}
- $this->clearItemCache($data['item_id'], $data['content']);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->forget($data['item_id']);
return ['success' => true, 'result' => $response_id];
case 'update_response':
@@ -502,8 +498,8 @@
];
}
- $this->clearItemCache($data['item_id'], $data['content']);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->forget($data['item_id']);
+ $this->cache->flush();
return ['success' => true, 'result' => $updated];
case 'delete_response':
@@ -536,7 +532,7 @@
['%s', '%s', '%s'],
['%d']
);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->flush();
return ['success' => true, 'result' => $updated ];
} else {
// No replies, safe to actually delete
@@ -559,8 +555,8 @@
];
}
- $this->clearItemCache($data['item_id'], $data['content']);
- CacheManager::invalidateGroup($this->cache_name);
+ $this->cache->forget($data['item_id']);
+ $this->cache->flush();
return ['success' => true, 'result' => $deleted];
}
}
diff --git a/inc/rest/routes/SEORoutes.php b/inc/rest/routes/SEORoutes.php
index f75dc1a..e1da79c 100644
--- a/inc/rest/routes/SEORoutes.php
+++ b/inc/rest/routes/SEORoutes.php
@@ -2,7 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\managers\SEO\ConfigManager;
use JVBase\managers\SEO\SchemaBuilder;
use WP_REST_Request;
@@ -138,7 +138,7 @@
}
// Invalidate cache
- $this->cache->invalidate();
+ $this->cache->flush();
return new WP_REST_Response([
'success' => true,
@@ -187,7 +187,7 @@
}
// Invalidate cache
- $this->cache->invalidate();
+ $this->cache->flush();
return new WP_REST_Response([
'success' => true,
@@ -224,7 +224,7 @@
}
// Invalidate cache
- $this->cache->invalidate();
+ $this->cache->flush();
return new WP_REST_Response([
'success' => true,
diff --git a/inc/rest/routes/SettingsRoutes.php b/inc/rest/routes/SettingsRoutes.php
index 811984a..ff4faaa 100644
--- a/inc/rest/routes/SettingsRoutes.php
+++ b/inc/rest/routes/SettingsRoutes.php
@@ -3,7 +3,7 @@
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\meta\MetaSanitizer;
use WP_REST_Request;
@@ -60,8 +60,6 @@
]);
}
- $this->queue = JVB()->queue();
-
$fields = JVB()->getFields('user');
$meta = new MetaSanitizer();
@@ -81,7 +79,7 @@
//Sanitize values
$data[$name] = $meta->sanitize($value, $fields[$name]);
if ($name === 'notify') {
- CacheManager::for('usernames')->delete($user_id);
+ Cache::for('usernames')->forget($user_id);
}
}
}
@@ -89,7 +87,7 @@
$data['notification_preferences'] = $add;
}
- $this->queue->queueOperation(
+ JVB()->queue()->queueOperation(
'user_settings',
$user_id,
$data,
@@ -149,7 +147,7 @@
}
}
- CacheManager::for($this->cache_name)->invalidate();
+ $this->cache->flush();
}
return [
'success' => true,
@@ -218,7 +216,7 @@
// Success - commit transaction
$wpdb->query('COMMIT');
- CacheManager::for($this->cache_name)->invalidate();
+ $this->cache->flush();
return [
'success' => true,
'result' => 'Notification preferences updated successfully'
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index 5dc3b87..438139e 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -23,7 +23,7 @@
$this->cache_name = 'terms';
parent::__construct();
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
$this->per_page = 20;
@@ -112,7 +112,7 @@
$data = [
'id' => $term->term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'slug' => $term->slug,
'taxonomy' => $term->taxonomy,
];
@@ -134,7 +134,7 @@
$term = get_term($rel->related_term_id, $rel->related_taxonomy);
return [
'id' => $rel->related_term_id,
- 'name' => $term ? $term->name : 'Unknown',
+ 'name' => $term ? html_entity_decode($term->name) : 'Unknown',
'count' => $rel->relationship_count
];
}, $relationships);
@@ -498,7 +498,7 @@
return $this->cache->remember($cache_key, function() use ($term, $taxonomy) {
$data = [
'id' => $term->term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'slug' => $term->slug,
'parent' => $term->parent,
'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
@@ -912,7 +912,7 @@
'message' => 'Term already exists',
'term' => [
'id' => $term->term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy)
]
]);
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 1da0412..5735208 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -1139,6 +1139,9 @@
$files = $request->get_file_params();
$args = $this->buildUploadArgs($request);
+ error_log('handleGroupingRequest Files: '.print_r($files, true));
+ error_log('handleGroupingRequest args: '.print_r($args, true));
+
if (!$args['content'] || !$args['user'] || !$args['posts']) {
$this->logError('Missing required data');
return new WP_REST_Response([
diff --git a/inc/users/UserSettings.php b/inc/users/UserSettings.php
index 1a12bac..1ee08e9 100644
--- a/inc/users/UserSettings.php
+++ b/inc/users/UserSettings.php
@@ -313,7 +313,7 @@
break;
case 'term':
$term = get_term($result->target_id, BASE.$type);
- $name = $term ? $term->name : '';
+ $name = $term ? html_entity_decode($term->name) : '';
break;
default:
$name = '';
diff --git a/inc/utility/Image.php b/inc/utility/Image.php
index 4901435..df5c2f3 100644
--- a/inc/utility/Image.php
+++ b/inc/utility/Image.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\utility;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
exit;
@@ -12,20 +12,22 @@
*/
class Image
{
- protected ?CacheManager $cache = null;
+ protected ?Cache $cache = null;
+ protected ?Cache $imgData = null;
public function __construct()
{
- $this->cache = CacheManager::for('images')->connectTo('post', 'attachment');
+ $this->cache = Cache::for('images')->connect('post');
+ $this->imgData = Cache::for('imageData')->connect('post');
if (JVB_TESTING) {
- $this->cache->clear();
+ $this->cache->flush();
}
}
public function formatImage(int $ID, string $start = 'tiny', string $replace = 'large', bool $addLink = true, ?string $postSlug = null):string
{
$return = $this->cache->remember(
- ['ID' => $ID, 'start' => $start, 'replace' => $replace],
+ $this->cache->generateKey(['ID' => $ID, 'start' => $start, 'replace' => $replace]),
function() use ($ID, $start, $replace) {
// Define size order for progressive enhancement
$sizeOrder = ['tiny', 'directory-preview', 'thumbnail', 'medium', 'large', 'full'];
@@ -121,4 +123,25 @@
}
return '';
}
+
+ public function getImageData(int $imgID):array
+ {
+ return $this->imgData->remember(
+ $imgID,
+ function() use ($imgID) {
+ if (!wp_get_attachment_image($imgID, 'tiny')) {
+ return [];
+ }
+ return [
+ 'tiny' => wp_get_attachment_image_src($imgID, 'tiny')[0],
+ 'small' => wp_get_attachment_image_src($imgID, 'medium')[0],
+ 'medium' => wp_get_attachment_image_src($imgID, 'large')[0],
+ 'large' => wp_get_attachment_image_src($imgID, 'full')[0],
+ 'image-alt-text'=> get_post_meta($imgID, '_wp_attachment_image_alt', true),
+ 'image-title' => get_the_title($imgID),
+ 'image-caption' => get_the_excerpt($imgID),
+ ];
+ }
+ );
+ }
}
diff --git a/jvb.php b/jvb.php
index 67b2174..70a9342 100644
--- a/jvb.php
+++ b/jvb.php
@@ -84,6 +84,11 @@
add_filter('show_admin_bar', '__return_false');
define('JVB_TESTING', str_contains(get_home_url(),'.test'));
+//if (JVB_TESTING) {
+// error_log('In testing mode...');
+//} else {
+// error_log('Not in testing mode...');
+//}
const JVB_DIR = WP_PLUGIN_DIR . '/jvb';
define('JVB_URL', plugin_dir_url(__FILE__));
@@ -134,7 +139,6 @@
require(JVB_DIR . '/inc/meta/_setup.php');
require(JVB_DIR . '/inc/importers/_setup.php');
require(JVB_DIR . '/inc/managers/_setup.php');
-
/**
* Get an icon element
*
diff --git a/src/drawer-menu/render.php b/src/drawer-menu/render.php
index d0826a3..05da41a 100644
--- a/src/drawer-menu/render.php
+++ b/src/drawer-menu/render.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\ui\Navigation;
$menu_id = $attributes['menuId'] ?? '';
@@ -13,8 +13,7 @@
return '<p>Please configure the drawer menu in block settings.</p>';
}
-$cache = CacheManager::for('drawer');
-$cache->clear();
+$cache = Cache::for('drawer');
if (!is_front_page()) {
$menu_items[] = [
diff --git a/src/feed/style.scss b/src/feed/style.scss
index 583aa82..de52421 100644
--- a/src/feed/style.scss
+++ b/src/feed/style.scss
@@ -823,13 +823,7 @@
.item-grid:has([data-timeline]) {
- grid-template-columns: repeat(1, 1fr);
-}
-
-@media (min-width:768px) {
- .item-grid:has([data-timeline]) {
- grid-template-columns: repeat(2, 1fr);
- }
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.items-wrap [type=radio],
diff --git a/src/feed/view.js b/src/feed/view.js
index 103423b..1248322 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -77,13 +77,17 @@
this.ui.content = this.ui.filters.container.querySelectorAll('[name="content"]');
if (this.ui.content.length === 0) this.ui.content = false;
this.ui.taxonomies = this.ui.filters.container.querySelectorAll('[data-taxonomy]');
- if (this.ui.taxonomies.length === 0) this.ui.content = false;
+ if (this.ui.taxonomies.length === 0) this.ui.taxonomies = false;
this.ui.orderbyWrap = this.ui.filters.container.querySelector('[data-for-order]');
- if (this.ui.orderbyWrap.length === 0) this.ui.content = false;
+ if (this.ui.orderbyWrap.length === 0) this.ui.orderbyWrap = false;
this.ui.order = this.ui.filters.container.querySelectorAll('[data-filter="order"]');
- if (this.ui.order.length === 0) this.ui.content = false;
+ if (this.ui.order.length === 0) this.ui.order = false;
this.ui.orderby = this.ui.filters.container.querySelectorAll('[data-filter="orderby"]');
- if (this.ui.orderby.length === 0) this.ui.content = false;
+ if (this.ui.orderby.length === 0) this.ui.orderby = false;
+
+ this.orderbyFilters = (this.ui.orderby)
+ ? Array.from(this.ui.orderby).map(o => o.value)
+ : [];
this.contentTypes = (this.ui.content)
? Array.from(this.ui.content).map(c => c.value)
@@ -179,6 +183,12 @@
this.store.clearCache();
this.store.fetch();
}
+
+ let orderbyButton = window.targetCheck(e, '[data-filter="orderby"]');
+ if (orderbyButton && orderbyButton.value === 'random' && orderbyButton.checked) {
+ // Already selected random, just re-render to trigger new shuffle
+ this.renderItems();
+ }
}
nextPage() {
@@ -433,6 +443,11 @@
}
}
initStore() {
+ let extraOrderby = this.orderbyFilters.filter(v => !['date','modified','title','random'].includes(v));
+ let extraIndexes = [];
+ extraOrderby.forEach(orderby =>{
+ extraIndexes.push({name:orderby, keyPath: orderby});
+ });
const store = window.jvbStore.register(
'feed',
{
@@ -443,8 +458,10 @@
{ name: 'content', keyPath: 'content'},
{ name: 'taxonomy', keyPath: 'taxonomy'},
{ name: 'user', keyPath: 'user'},
- { name: 'date', keyPath: 'modified'},
- { name: 'title', keyPath: 'title'}
+ { name: 'date', keyPath: 'date'},
+ { name: 'modified', keyPath: 'modified'},
+ { name: 'title', keyPath: 'title'},
+ ... extraIndexes
],
filters: this.filters,
TTL: 6 * 60 * 60 * 1000, //6 hours
@@ -452,12 +469,13 @@
required: 'content',
}
);
+
this.store = store.feed;
this.store.subscribe((event, data) => {
switch (event) {
case 'data-loaded':
- this.renderItems();
+ this.renderItems(data.items);
this.ui.buttons.loadMore.hidden = true;
if (this.store.lastResponse && this.store.lastResponse?.has_more) {
this.ui.buttons.loadMore.hidden = !this.store.lastResponse?.has_more??true;
@@ -471,8 +489,8 @@
return this.store.filters.page === 1;
}
- renderItems() {
- let items = this.store.getFiltered();
+ renderItems(items = null) {
+ items = items??this.store.getFiltered();
if (this.isFirstPage()) {
window.removeChildren(this.ui.grid);
}
@@ -503,6 +521,10 @@
}
createItemElement(item) {
+ if (typeof item !== 'object') {
+ item = this.store.get(item);
+ if (!item) return;
+ }
return this.templates.create(`feedItem${window.uppercaseFirst(item.content)}`, item);
}
splitIDs(value) {
@@ -616,7 +638,7 @@
];
if (afterEl) {
- afterEl.textContent = `After ${item.fields.number - 1} Tx`;
+ afterEl.textContent = `After ${item.number} Tx`;
}
if (number) {
number.textContent = item.fields.number;
diff --git a/src/list/render.php b/src/list/render.php
index f8ef568..489c379 100644
--- a/src/list/render.php
+++ b/src/list/render.php
@@ -60,7 +60,7 @@
if ($terms && !is_wp_error($terms)) {
$term = $terms[0];
$extra[] = [
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'url' => get_term_link($term->term_id, $item),
'id' => $term->term_id,
'type' => $item,
@@ -90,7 +90,7 @@
$extra = false;
$list = jvbAlphabetizeMe(
$list,
- $term->name,
+ html_entity_decode($term->name),
get_term_link($term->term_id, $selected_type['slug']),
$term->term_id,
$extra
diff --git a/src/summary/render.php b/src/summary/render.php
index 4c22c7a..ca5cde1 100644
--- a/src/summary/render.php
+++ b/src/summary/render.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -31,10 +31,9 @@
function jvbRenderArtistSummary():string
{
$current = get_queried_object();
- $cache = CacheManager::for('artists', WEEK_IN_SECONDS);
- $key = 'artist-bio-'.$current->ID;
+ $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
+ $key = $current->ID;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
@@ -97,8 +96,8 @@
$link = get_term_link((int)$style, BASE.'style');
?>
<li>
- <a href="<?=$link?>" title="Learn more about <?=$term->name?>">
- <?=strtolower($term->name)?>
+ <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
+ <?=strtolower(html_entity_decode($term->name))?>
</a>
</li>
<?php
@@ -158,8 +157,8 @@
{
$current = get_queried_object();
- $cache = CacheManager::for('shops', WEEK_IN_SECONDS);
- $key = 'shop-bio-'.$current->term_id;
+ $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
+ $key = $current->term_id;
$cached = $cache->get($key);
$cached = false;
if ($cached !== false) {
@@ -167,8 +166,6 @@
}
ob_start();
- $handler = JVB()->getContent('shop');
-
$meta = new JVBase\meta\MetaManager($current->term_id, 'term');
$rating = $meta->getValue('average_rating');
@@ -281,7 +278,7 @@
<?php
$finished = ob_get_clean();
-// $cache->set($key, $finished);
+ $cache->set($key, $finished);
return $finished;
}
@@ -289,10 +286,9 @@
function jvbRenderTermSummary()
{
$current = get_queried_object();
- $cache = CacheManager::for(jvbNoBase($current->taxonomy), WEEK_IN_SECONDS);
+ $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
$key = $current->ID;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
diff --git a/templates/dashboard/sections/news.php b/templates/dashboard/sections/news.php
index ae4d46f..242f4c5 100644
--- a/templates/dashboard/sections/news.php
+++ b/templates/dashboard/sections/news.php
@@ -1,6 +1,6 @@
<?php
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -9,7 +9,7 @@
wp_redirect(get_home_url(null, '/dash'));
exit;
}
-$cache = CacheManager::for('news', 3600);
+$cache = Cache::for('news', 3600);
$check = $cache->get('type-options');
if ($check) {
@@ -24,7 +24,7 @@
foreach ($terms as $term) {
$typeOptions[] = [
'id' => $term->term_id,
- 'name' => $term->name,
+ 'name' => html_entity_decode($term->name),
'count' => $term->count,
];
}
--
Gitblit v1.10.0