Jake Vanderwerf
2026-08-25 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a
=It's been a minute. I went through the Routes and associated Managers and ensured they played together well. Reworked the Cache.php, and I think I caught all the references. Moving away from JVBase() root class, to individuall classes that are Instanced. Double checked for the ABSPATH check in every file, and added index.php to every directory that didn't already have it. Also still working on the MenuBlock to work with WooCommerce for now for Northeh
218 files modified
1 files deleted
47 files added
10 files renamed
9830 ■■■■■ changed files
JVBase.php 342 ●●●●● patch | view | raw | blame | history
activate.php 12 ●●●●● patch | view | raw | blame | history
assets/css/admin/index.php 3 ●●●●● patch | view | raw | blame | history
assets/css/icons.css 2 ●●●●● patch | view | raw | blame | history
assets/css/index.php 3 ●●●●● patch | view | raw | blame | history
assets/index.php 3 ●●●●● patch | view | raw | blame | history
assets/js/admin/index.php 3 ●●●●● patch | view | raw | blame | history
assets/phosphor-icons/bold/index.php 2 ●●●●● patch | view | raw | blame | history
base/Login.php 3 ●●●●● patch | view | raw | blame | history
base/Membership.php 4 ●●●●● patch | view | raw | blame | history
base/SchemaHelper.php 14 ●●●●● patch | view | raw | blame | history
base/Site.php 2 ●●●●● patch | view | raw | blame | history
base/_setup.php 6 ●●●●● patch | view | raw | blame | history
base/colours.php 4 ●●●●● patch | view | raw | blame | history
base/content.php 3 ●●●●● patch | view | raw | blame | history
base/email.php 3 ●●●●● patch | view | raw | blame | history
base/options.php 3 ●●●●● patch | view | raw | blame | history
base/taxonomies.php 3 ●●●●● patch | view | raw | blame | history
base/users.php 3 ●●●●● patch | view | raw | blame | history
build/drawer-menu/index.php 2 ●●●●● patch | view | raw | blame | history
checks.php 30 ●●●●● patch | view | raw | blame | history
inc/Instance.php 3 ●●●●● patch | view | raw | blame | history
inc/admin/ContentTaxonomy.php 2 ●●●●● patch | view | raw | blame | history
inc/admin/Integrations.php 24 ●●●●● patch | view | raw | blame | history
inc/admin/SEOAdmin.php 9 ●●●●● patch | view | raw | blame | history
inc/admin/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 22 ●●●●● patch | view | raw | blame | history
inc/blocks/DrawerMenuBlock.php 7 ●●●●● patch | view | raw | blame | history
inc/blocks/FAQBlock.php 10 ●●●●● patch | view | raw | blame | history
inc/blocks/FeedBlock.php 11 ●●●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 10 ●●●●● patch | view | raw | blame | history
inc/blocks/GMBReviewBlock.php 25 ●●●●● patch | view | raw | blame | history
inc/blocks/GlossaryBlock.php 2 ●●●●● patch | view | raw | blame | history
inc/blocks/MenuBlock.php 130 ●●●●● patch | view | raw | blame | history
inc/blocks/SimpleCalendarBlock.php 5 ●●●●● patch | view | raw | blame | history
inc/blocks/SummaryBlock.php 12 ●●●●● patch | view | raw | blame | history
inc/blocks/TimelineBlock.php 3 ●●●●● patch | view | raw | blame | history
inc/forms/PostSelector.php 5 ●●●●● patch | view | raw | blame | history
inc/helpers/breadcrumbs.php 4 ●●●●● patch | view | raw | blame | history
inc/helpers/crud.php 8 ●●●●● patch | view | raw | blame | history
inc/helpers/dashboard.php 2 ●●●●● patch | view | raw | blame | history
inc/helpers/directory.php 5 ●●●●● patch | view | raw | blame | history
inc/helpers/formatting.php 2 ●●●●● patch | view | raw | blame | history
inc/helpers/members.php 15 ●●●●● patch | view | raw | blame | history
inc/helpers/renderFields.php 17 ●●●●● patch | view | raw | blame | history
inc/helpers/time.php 5 ●●●●● patch | view | raw | blame | history
inc/importers/JaneAppSalesImporter.php 6 ●●●●● patch | view | raw | blame | history
inc/importers/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/integrations/Admin.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 7 ●●●●● patch | view | raw | blame | history
inc/integrations/OAuth.php 12 ●●●●● patch | view | raw | blame | history
inc/integrations/ServiceMap.php 35 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncFromPost.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncFromTerm.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncFromUser.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncHelpers.php 15 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncToPost.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncToTerm.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncToUser.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/UserConnection.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/_Base.php 12 ●●●●● patch | view | raw | blame | history
inc/integrations/_setup.php 73 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Cloudflare.php 5 ●●●●● patch | view | raw | blame | history
inc/integrations/services/CloudflareOld.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Facebook.php 354 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMaps.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusiness.php 15 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusinessOld.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Helcim.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/HelcimOld.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Instagram.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/PostMark.php 301 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Square.php 48 ●●●●● patch | view | raw | blame | history
inc/integrations/services/SquareOld.php 10 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Umami.php 210 ●●●●● patch | view | raw | blame | history
inc/integrations/services/UmamiOld.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/AdminPages.php 6 ●●●●● patch | view | raw | blame | history
inc/managers/Approvals.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 8 ●●●●● patch | view | raw | blame | history
inc/managers/Cache.php 613 ●●●●● patch | view | raw | blame | history
inc/managers/CacheBack.php 684 ●●●●● patch | view | raw | blame | history
inc/managers/CustomTable.php 94 ●●●●● patch | view | raw | blame | history
inc/managers/Dashboard/Dashboard.php 35 ●●●●● patch | view | raw | blame | history
inc/managers/Dashboard/DashboardPage.php 6 ●●●●● patch | view | raw | blame | history
inc/managers/Dashboard/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/Dashboard/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/DashboardManager.php 45 ●●●●● patch | view | raw | blame | history
inc/managers/DirectoryManager.php 15 ●●●●● patch | view | raw | blame | history
inc/managers/Emails.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/Errors.php 63 ●●●●● patch | view | raw | blame | history
inc/managers/Favourites.php 118 ●●●●● patch | view | raw | blame | history
inc/managers/IconsManager.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/ImageGenerator.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/InvitationsManager.php 33 ●●●●● patch | view | raw | blame | history
inc/managers/KarmaManager.php 118 ●●●●● patch | view | raw | blame | history
inc/managers/Login.php 42 ●●●●● patch | view | raw | blame | history
inc/managers/MagicLinks.php 70 ●●●●● patch | view | raw | blame | history
inc/managers/NewsRelationships.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/NotificationManager.php 10 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/Content.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/EmailDigests.php 25 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/OperationQueue.php 40 ●●●●● patch | view | raw | blame | history
inc/managers/Referrals.php 791 ●●●●● patch | view | raw | blame | history
inc/managers/ResponseManager.php 64 ●●●●● patch | view | raw | blame | history
inc/managers/Responses.php 413 ●●●●● patch | view | raw | blame | history
inc/managers/Roles.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/BreadcrumbManager.php 73 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SEOAdminPage.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/_edmonotonink.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/DataType/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/DataType/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/SchemaOutput.php 31 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Article/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Article/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Event/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Event/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Brand/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Brand/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ContactPoint/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ContactPoint/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Enumeration/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Enumeration/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ItemList/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ItemList/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Quantity/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Quantity/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Rating/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Rating/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Reservation/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Reservation/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/MedicalEntity/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/MedicalEntity/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/LocalBusiness/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Organization/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Person/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Person/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/AdministrativeArea/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/AdministrativeArea/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Product/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Product/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Properties/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Properties/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/Organization/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/Organization/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/Place/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/Place/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/ThingSchema.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/index.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/ScriptLoader.php 209 ●●●●● patch | view | raw | blame | history
inc/managers/TaxonomyRelationships.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/UploadManager.php 651 ●●●●● patch | view | raw | blame | history
inc/managers/UserTermsManager.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/VerifyEntryManager.php 15 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 27 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Progress.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Queue.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Storage.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/queue/TypeRegistry.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/ContentExecutor.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/ContentTermExecutor.php 8 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/IntegrationExecutor.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/InvitationExecutor.php 6 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/UploadExecutor.php 181 ●●●●● patch | view | raw | blame | history
inc/managers/queue/mergers/DefaultMerger.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/queue/mergers/UploadMerger.php 4 ●●●●● patch | view | raw | blame | history
inc/meta/Form.php 7 ●●●●● patch | view | raw | blame | history
inc/meta/Meta.php 124 ●●●●● patch | view | raw | blame | history
inc/meta/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/meta/index.php 3 ●●●●● patch | view | raw | blame | history
inc/registrar/Labels.php 4 ●●●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 62 ●●●●● patch | view | raw | blame | history
inc/registrar/Users.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Breadcrumbs.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Integration.php 9 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Management.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Responses.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/config/TrackChanges.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Verification.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/config/index.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Helpers.php 3 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Resolver.php 4 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Schema.php 20 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/index.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/Calendar.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/index.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/AddIntegrationFields.php 9 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/index.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/index.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/RegisterRoutes.php 4 ●●●●● patch | view | raw | blame | history
inc/rest/Rest.php 10 ●●●●● patch | view | raw | blame | history
inc/rest/RestRouteManager.php 7 ●●●●● patch | view | raw | blame | history
inc/rest/_setup.php 7 ●●●●● patch | view | raw | blame | history
inc/rest/index.php 3 ●●●●● patch | view | raw | blame | history
inc/rest/routes/AdminRoutes.php 28 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ApprovalRoutes.php 66 ●●●●● patch | view | raw | blame | history
inc/rest/routes/CRUDRoutes.php 4 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ContentRoutes.php 38 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ContentTermsRoutes.php 28 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ErrorRoutes.php 5 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 254 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 77 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FormRoutes.php 29 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ImporterRoutes.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsHelcimRoutes.php 48 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsRoutes.php 35 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsSquareRoutes.php 10 ●●●●● patch | view | raw | blame | history
inc/rest/routes/Invitations.php 17 ●●●●● patch | view | raw | blame | history
inc/rest/routes/LoginRoutes.php 13 ●●●●● patch | view | raw | blame | history
inc/rest/routes/MagicLinkRoutes.php 14 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NewsRoutes.php 4 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NotificationsRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/OptionsRoutes.php 4 ●●●●● patch | view | raw | blame | history
inc/rest/routes/QueueRoutes.php 48 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ReferralRoutes.php 178 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ResponseRoutes.php 402 ●●●●● patch | view | raw | blame | history
inc/rest/routes/SEORoutes.php 91 ●●●●● patch | view | raw | blame | history
inc/rest/routes/SettingsRoutes.php 5 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ShopRoutes.php 23 ●●●●● patch | view | raw | blame | history
inc/rest/routes/TermRoutes.php 313 ●●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 539 ●●●●● patch | view | raw | blame | history
inc/rest/routes/UserRoutes.php 5 ●●●●● patch | view | raw | blame | history
inc/rest/routes/VoteRoutes.php 18 ●●●●● patch | view | raw | blame | history
inc/rest/routes/index.php 3 ●●●●● patch | view | raw | blame | history
inc/ui/Checkout.php 8 ●●●●● patch | view | raw | blame | history
inc/ui/_setup.php 4 ●●●●● patch | view | raw | blame | history
inc/ui/index.php 3 ●●●●● patch | view | raw | blame | history
inc/utility/helpers.php 4 ●●●●● patch | view | raw | blame | history
inc/utility/setup.php 4 ●●●●● patch | view | raw | blame | history
jvb.php 402 ●●●●● patch | view | raw | blame | history
src/drawer-menu/render.php 4 ●●●●● patch | view | raw | blame | history
src/faq/render.php 5 ●●●●● patch | view | raw | blame | history
src/glossary/render.php 4 ●●●●● patch | view | raw | blame | history
src/gmbreviews/render.php 4 ●●●●● patch | view | raw | blame | history
src/menu/render.php 4 ●●●●● patch | view | raw | blame | history
src/simple-calendar/render.php 4 ●●●●● patch | view | raw | blame | history
src/timeline/render.php 4 ●●●●● patch | view | raw | blame | history
src/video/render.php 4 ●●●●● patch | view | raw | blame | history
templates/dashboard/sections/notifications.php 5 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -5,20 +5,14 @@
use JVBase\base\SchemaHelper;
use JVBase\base\Site;
use JVBase\blocks\CustomBlocks;
use JVBase\managers\AdminPages;
use JVBase\managers\ApprovalManager;
use JVBase\managers\Dashboard\DashboardManager;
use JVBase\managers\Approvals;
use JVBase\managers\DirectoryManager;
use JVBase\managers\EmailManager;
use JVBase\managers\ErrorHandler;
use JVBase\managers\FavouritesManager;
use JVBase\managers\Favourites;
use JVBase\managers\InvitationsManager;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
use JVBase\managers\Login;
use JVBase\managers\NotificationManager;
use JVBase\managers\queue\Queue;
use JVBase\managers\ReferralManager;
use JVBase\managers\RoleManager;
use JVBase\managers\Referrals;
use JVBase\managers\SEO\render\SchemaOutput;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
@@ -31,15 +25,11 @@
use JVBase\rest\routes\FavouritesRoutes;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FormRoutes;
use JVBase\rest\routes\IntegrationsHelcimRoutes;
use JVBase\rest\routes\IntegrationsRoutes;
use JVBase\rest\routes\IntegrationsSquareRoutes;
use JVBase\rest\routes\Invitations;
use JVBase\rest\routes\LoginRoutes;
use JVBase\rest\routes\NewsRoutes;
use JVBase\rest\routes\NotificationsRoutes;
use JVBase\rest\routes\OptionsRoutes;
use JVBase\rest\routes\QueueRoutes;
use JVBase\rest\routes\ReferralRoutes;
use JVBase\rest\routes\ResponseRoutes;
use JVBase\rest\routes\SEORoutes;
@@ -59,23 +49,11 @@
    protected array $managers = [];
    protected array $content = [];
    protected array $taxonomies = [];
    protected array $integrations = [];
    protected array $blocks = [];
    protected array $routes = [];
    protected CustomBlocks $customBlocks;
    protected array $serviceMap = [
        'maps' => 'JVBase\integrations\services\GoogleMaps',
        'square' => 'JVBase\integrations\services\Square',
        'facebook' => 'JVBase\integrations\services\Facebook',
        'helcim' => 'JVBase\integrations\services\Helcim',
        'instagram' => 'JVBase\integrations\services\Instagram',
        'bluesky' => 'JVBase\integrations\services\BlueSky',
        'gmb' => 'JVBase\integrations\services\GoogleMyBusiness',
        'cloudflare' => 'JVBase\integrations\services\Cloudflare',
        'umami' => 'JVBase\integrations\services\Umami',
        'postmark' => 'JVBase\integrations\services\PostMark',
    ];
    public static function getInstance(): JVB
    {
@@ -88,177 +66,6 @@
    private function __construct()
    {
        $this->customBlocks = new CustomBlocks();
        $this->managers = [
            'errors'    => new ErrorHandler(),
            'queue'     => new Queue(),
//            'dash'    => new DashboardManager(),
            'roles'     => new RoleManager(),
//            'forms'   => new FormManager(),
//          'schema'    => new SchemaOutputManager(),
            'admin'     => new AdminPages(),
            'seoAdmin'  => new SEOAdmin(),
            'seo'       => new SchemaOutput(),
            'schemaHelper' => new SchemaHelper(),
//          'uploads'   => new UploadManager(),
//          'userTerms' => new UserTermsManager(),
            'email'     => new EmailManager(),
//          'terms'     => new TaxonomyRelationships(),
        ];
        if (Site::has('feed_block')) {
            $this->managers['terms'] = new TaxonomyRelationships();
        }
        if (Site::hasAll(['dashboard', 'membership', 'feed_block'])) {
            $this->managers['userTerms'] = new UserTermsManager();
        }
        $this->routes = [
            'login' => new LoginRoutes(),
            'integrations' => new IntegrationsRoutes(),
            'seo' => new SEORoutes(),
            'queue' => new QueueRoutes(),
            'settings' => new SettingsRoutes(),
            'upload' => new UploadRoutes(),
            'forms' => new FormRoutes(),
            'user'  => new UserRoutes()
        ];
        if (Site::has('magic_link')) {
//          $this->routes['magicLink'] = new MagicLinkRoutes();
            $this->managers['magicLink'] = new MagicLinkManager();
        }
        if (Site::has('referrals')) {
            $this->managers['referral'] = new ReferralManager();
            $this->routes['referral'] = new ReferralRoutes();
        }
        if (Site::hasIntegration('square')) {
            $this->routes['square'] = new IntegrationsSquareRoutes();
        }
        if (Site::hasIntegration('helcim')) {
            $this->routes['helcim'] = new IntegrationsHelcimRoutes();
        }
        if (Site::has('feed_block')) {
            $this->routes['feed'] = new FeedRoutes();
        }
        $membership = Site::membership();
        if ($membership && $membership->has('notifications')) {
            $this->managers['notifications'] = new NotificationManager();
            $this->routes['notifications'] = new NotificationsRoutes();
        }
        if (!empty(Registrar::withFeature('approve_new'))) {
            $this->managers['approvals'] = new ApprovalManager();
        }
        if (Site::has('feed_block') || Site::has('dashboard')) {
            $this->routes['term'] = new TermRoutes();
        }
        if (Site::has('is_directory')) {
            $this->managers['directory'] = new DirectoryManager();
        }
        if (Site::has('dashboard')) {
            $this->routes['error'] = new ErrorRoutes();
            $this->routes['admin']  = new AdminRoutes();
            $this->routes['content'] = new ContentRoutes();
//            $this->routes['bio']    = new BioRoutes();
//          $this->routes['shop'] = new ShopRoutes();
            $this->routes['contentTax'] = new ContentTermsRoutes();
            $this->routes['options'] = new OptionsRoutes();
        }
        if (Site::has('favourites')) {
            $this->managers['favourites'] = new FavouritesManager();
            $this->routes['favourites'] = new FavouritesRoutes();
        }
        if ($membership && $membership->has('forum')) {
            $this->routes['news'] = new NewsRoutes();
        }
        if ($membership && $membership->has('invitable')) {
            $this->managers['invitations'] = new InvitationsManager();
        }
        if (!empty(Registrar::withFeature('has_responses'))) {
            $this->routes['comments'] = new ResponseRoutes();
        }
        if (!empty(Registrar::withFeature('karma'))) {
            $this->routes['vote'] = new VoteRoutes();
        }
        if (!empty(Registrar::withFeature('karma'))
            || ($membership && $membership->has('member_verified')) ||
            ($membership && $membership->has('term_approval'))) {
            $this->routes['approvals'] = new ApprovalRoutes();
        }
        if ($membership && $membership->has('can_invite')) {
            $this->routes['invites'] = new Invitations();
        }
        if (Site::has('dashboard')) {
            $this->managers['dash'] = new DashboardManager();
        }
        $this->setupIntegrations();
        add_action('wp_footer', [$this, 'additionalActions']);
//        $this->managers['notifications'] = new NotificationManager();
        // Register activation hook
        register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']);
    }
    protected function setupIntegrations(): void
    {
        foreach(array_keys(Site::getIntegrations()) as $integration) {
            $this->integrations[$integration] = $this->serviceMap[$integration]::getInstance();
        }
    }
    public function registeredContent(): array
    {
        return array_merge(array_keys($this->content), array_keys($this->taxonomies));
    }
    public function favourites(): FavouritesManager|false
    {
        return $this->managers['favourites'] ?? false;
    }
    public function dashboard(): DashboardManager|false
    {
        return $this->managers['dash'] ?? false;
    }
    public function directories(): DirectoryManager|false
    {
        return $this->managers['directory'] ?? false;
    }
    public function error(): ErrorHandler
    {
        return $this->managers['errors'];
    }
    public function file()
    {
        return $this->managers['file'];
    }
    public function queue(): Queue
    {
        return $this->managers['queue'];
    }
//    public function forms()
//    {
//        return $this->managers['forms'];
//    }
    public function notification(): NotificationManager|false
    {
        return $this->managers['notifications'] ?? false;
    }
    public function routes($route): mixed
@@ -269,149 +76,20 @@
        return false;
    }
    public function roles(): RoleManager
    {
        return $this->managers['roles'];
    }
    public function admin()
    {
        return $this->managers['admin'];
    }
    public function seoAdmin()
    {
        return $this->managers['seoAdmin'];
    }
    public function getFields($type): array
    {
        return Registrar::getFieldsFor($type)??[];
    }
    /**
     * @deprecated Should use the Registrar::getInstance
     * @param $type
     * @return mixed
     */
    public function getContent($type): mixed
    {
        return $this->content[$type] ?? $this->taxonomies[$type] ?? $this->blocks[$type] ?? null;
    }
    public function connect(string $service, ?int $userID = null): mixed
    {
//      if ($userID) {
//          if (!$this->userCanConnect($service, $userID)) {
//              return null;
//          }
//
//          if (!array_key_exists($service, $this->integrations)) {
//              return null;
//          }
//
//          return $this->serviceMap[$service]::getInstance($userID);
//      }
        $instance = $this->integrations[$service]??null;
        if ($instance) {
            $instance->switchUser($userID);
        }
        return $instance;
    }
    public function userCanConnect(string $service, int $userID): bool
    {
        $role = jvbUserRole($userID);
        $registrar = Registrar::getInstance($role);
        return user_can($userID, 'manage_options') || $registrar->hasIntegration($service);
    }
    public function getAvailableServices(bool $keys = true): array
    {
        return ($keys) ? array_keys($this->integrations) : $this->integrations;
    }
    public function activate(): void
    {
        // Activate roles - will be properly initialized after post types are registered
        $this->roles()->activate();
    }
    public function addRoute($slug, $class): void
    {
        $this->routes[$slug] = $class;
    }
    public function email(): EmailManager
    {
        return $this->managers['email'];
    }
    public function referrals(): ReferralManager|false
    {
        return $this->managers['referral'] ?? false;
    }
    public function magicLink(): MagicLinkManager|false
    {
        return $this->managers['magicLink'] ?? false;
    }
    public function invitations(): InvitationsManager|false
    {
        return $this->managers['invitations'] ?? false;
    }
    public function additionalActions():void
    {
        if (LoginManager::isLogin()) {
            return;
        }
        $extras = apply_filters('jvbAdditionalActions', []);
        $extras = array_filter($extras, function ($extra) {
            return is_array($extra) && array_key_exists('button', $extra) && array_key_exists('content', $extra);
        });
        if (empty ($extras)) {
            return;
        }
        $buttons = array_map(function ($extra) {
            return $extra['button'];
        }, $extras);
        $contents = array_map(function($extra) {
            return $extra['content'];
        }, $extras);
        if (!empty ($buttons)) {
            ?>
            <section class="main-actions">
                <div class="buttons col">
                    <?= implode('', $buttons); ?>
                </div>
                <div class="actions">
                    <?= implode('', $contents); ?>
                </div>
            </section>
            <?php
        }
    }
    public function seo():SchemaOutput
    {
        return $this->managers['seo'];
    }
    public function blocks():CustomBlocks|bool
    {
        return $this->customBlocks??false;
    }
    public function schemaHelper():SchemaHelper
    {
        return $this->managers['schemaHelper'];
    }
    public function termRelationships():TaxonomyRelationships
    {
        return $this->managers['terms'];
    }
    public function approvals ():ApprovalManager|false
    {
        return $this->managers['approvals']??false;
    }
}
activate.php
@@ -6,8 +6,8 @@
use JVBase\managers\DashboardManager;
use JVBase\managers\DirectoryManager;
use JVBase\managers\queue\Queue;
use JVBase\managers\ReferralManager;
use JVBase\managers\RoleManager;
use JVBase\managers\Referrals;
use JVBase\managers\Roles;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
@@ -42,7 +42,7 @@
    jvbSchedules();
    error_log('Schedules done!');
    RoleManager::activate();
    Roles::activate();
    jvbRegisterAdminPages();
//  if (array_key_exists('integrations', JVB_SITE) && array_key_exists('umami', JVB_SITE['integrations']) && JVB_SITE['integrations']['umami']=== true) {
@@ -59,7 +59,7 @@
function jvbAddAdminCaps()
{
    $roleManager = new RoleManager();
    $roleManager = new Roles();
    $role = get_role('administrator');
@@ -102,7 +102,7 @@
        return;
    }
    $roleManager = new RoleManager();
    $roleManager = new Roles();
    $users = get_users(['role' => 'administrator']);
    foreach (array_merge(Registrar::getRegistered('post'), Registrar::withFeature('is_content', 'term')) as $slug) {
@@ -269,7 +269,7 @@
function jvbRegisterAdminPages():void
{
    if (Site::has('referrals')){
        ReferralManager::addSubpage();
        Referrals::addSubpage();
    }
//  SEOAdminPage::addSubpage();
}
assets/css/admin/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
assets/css/icons.css
@@ -1 +1 @@
.icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRsNDAuMTgsNDQuMmMtLjQ1Ljg3LS45LDEuNzUtMS4zMiwyLjY0QTYyLDYyLDAsMSwwLDcyLDIxNGg4OGE4NS4yMyw4NS4yMywwLDAsMCwzMi4zNS02LjNMMjAzLjU2LDIyMGE2LDYsMCwwLDAsOC44OC04LjA4Wk0xNjAsMjAySDcyYTUwLDUwLDAsMSwxLDUuOS05OS42NEE4Ni4yNSw4Ni4yNSwwLDAsMCw3NCwxMjhhNiw2LDAsMCwwLDEyLDAsNzMuOTIsNzMuOTIsMCwwLDEsNi40NC0zMC4ybDkxLjIyLDEwMC4zNEE3My42NSw3My42NSwwLDAsMSwxNjAsMjAyWm04Ni03NGE4NS44NSw4NS44NSwwLDAsMS0yMS44NSw1Ny4yNyw2LDYsMCwwLDEtNC40NywyLDYsNiwwLDAsMS00LjQ3LTEwLDc0LDc0LDAsMCwwLTk5LTEwOC45Miw2LDYsMCwxLDEtNy4xMS05LjY3QTg2LDg2LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDIsMjAwYTE0LDE0LDAsMSwxLTE0LTE0QTE0LDE0LDAsMCwxLDE0MiwyMDBabS0xNC00MmE2LDYsMCwwLDAsNi02VjQ4YTYsNiwwLDAsMC0xMiwwVjE1MkE2LDYsMCwwLDAsMTI4LDE1OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTg1LjI3LDg1LjI3LDAsMCwxLTE3LjIsNTEuNiw2LDYsMCwxLDEtOS42LTcuMkE3NCw3NCwwLDEsMCw4NiwxMjhhNiw2LDAsMCwxLTEyLDAsODUuNTQsODUuNTQsMCwwLDEsMy45MS0yNS42NEE1MC42OCw1MC42OCwwLDAsMCw3MiwxMDJhNTAsNTAsMCwwLDAsMCwxMDBIOTZhNiw2LDAsMCwxLDAsMTJINzJBNjIsNjIsMCwxLDEsODIuNDMsOTAuODgsODYsODYsMCwwLDEsMjQ2LDEyOFptLTY2LjI0LDQzLjc2TDE1OCwxOTMuNTFWMTI4YTYsNiwwLDAsMC0xMiwwdjY1LjUxbC0yMS43Ni0yMS43NWE2LDYsMCwwLDAtOC40OCw4LjQ4bDMyLDMyYTYsNiwwLDAsMCw4LjQ4LDBsMzItMzJhNiw2LDAsMCwwLTguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMzYuMjQtOTQuMjRhNiw2LDAsMCwxLDAsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxNDQsMTUxLjUxbDQzLjc2LTQzLjc1QTYsNiwwLDAsMSwxOTYuMjQsMTA3Ljc2WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptLTYtNzRWODhhNiw2LDAsMCwxLDEyLDB2NDBhNiw2LDAsMCwxLTEyLDBabTE2LDM2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE3MCwxNjRaIi8+PC9zdmc+');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMjguMjQtODUuNzZMMTY4LjQ4LDEzNmwxOS43NiwxOS43NmE2LDYsMCwxLDEtOC40OCw4LjQ4TDE2MCwxNDQuNDhsLTE5Ljc2LDE5Ljc2YTYsNiwwLDAsMS04LjQ4LTguNDhMMTUxLjUyLDEzNmwtMTkuNzYtMTkuNzZhNiw2LDAsMCwxLDguNDgtOC40OEwxNjAsMTI3LjUybDE5Ljc2LTE5Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTA3Ljc2bC04MC04MEE2LDYsMCwwLDAsMTQ2LDMyVjc0LjJjLTU0LjQ4LDMuNTktMTIwLjM5LDU1LTEyNy45MywxMjAuNjZhMTAsMTAsMCwwLDAsMTcuMjMsOGgwQzQ2LjU2LDE5MC44NSw4NywxNTIuNiwxNDYsMTUwLjEzVjE5MmE2LDYsMCwwLDAsMTAuMjQsNC4yNGw4MC04MEE2LDYsMCwwLDAsMjM2LjI0LDEwNy43NlpNMTU4LDE3Ny41MlYxNDRhNiw2LDAsMCwwLTYtNmMtMjcuNzMsMC01NC43Niw3LjI1LTgwLjMyLDIxLjU1YTE5My4zOCwxOTMuMzgsMCwwLDAtNDAuODEsMzAuNjVjNC43LTI2LjU2LDIwLjE2LTUyLDQ0LTcyLjI3Qzk4LjQ3LDk3Ljk0LDEyNy4yOSw4NiwxNTIsODZhNiw2LDAsMCwwLDYtNlY0Ni40OUwyMjMuNTEsMTEyWiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}
.icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsMTI4YTk2LDk2LDAsMSwxLTIxLjk1LTYxLjA5LDgsOCwwLDEsMS0xMi4zMywxMC4xOEE4MCw4MCwwLDEsMCwyMDcuNiwxMzZIMTI4YTgsOCwwLDAsMSwwLTE2aDg4QTgsOCwwLDAsMSwyMjQsMTI4WiIvPjwvc3ZnPg==');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjMuMywxNjkuNTlhOC4wNyw4LjA3LDAsMCwwLTIuOC0zLjRDMjAzLjUzLDE1NC41MywyMDAsMTM0LjY0LDIwMCwxMjBjMC0xNy42NywxMy40Ny0zMy4wNiwyMS41LTQwLjY3YTgsOCwwLDAsMCwwLTExLjYyQzIwOC44Miw1NS43NCwxODcuODIsNDgsMTY4LDQ4YTcyLjIsNzIuMiwwLDAsMC00MCwxMi4xMyw3MS41Niw3MS41NiwwLDAsMC05MC43MSw5LjA5QTc0LjYzLDc0LjYzLDAsMCwwLDE2LDEyMy40YTEyNy4wNiwxMjcuMDYsMCwwLDAsNDAuMTQsODkuNzNBMzkuOCwzOS44LDAsMCwwLDgzLjU5LDIyNGg4Ny42OGEzOS44NCwzOS44NCwwLDAsMCwyOS4xMi0xMi41NywxMjUsMTI1LDAsMCwwLDE3LjgyLTI0LjZDMjI1LjIzLDE3NCwyMjQuMzMsMTcyLDIyMy4zLDE2OS41OVptLTM0LjYzLDMwLjk0YTIzLjc2LDIzLjc2LDAsMCwxLTE3LjQsNy40N0g4My41OWEyMy44MiwyMy44MiwwLDAsMS0xNi40NC02LjUxQTExMS4xNCwxMTEuMTQsMCwwLDEsMzIsMTIzLDU4LjUsNTguNSwwLDAsMSw0OC42NSw4MC40Nyw1NC44MSw1NC44MSwwLDAsMSw4OCw2NGguNzhBNTUuNDUsNTUuNDUsMCwwLDEsMTIzLDc2LjI4YTgsOCwwLDAsMCwxMCwwQTU1LjQ0LDU1LjQ0LDAsMCwxLDE2OCw2NGE3MC42NCw3MC42NCwwLDAsMSwzNiwxMC4zNWMtMTMsMTQuNTItMjAsMzAuNDctMjAsNDUuNjUsMCwyMy43Nyw3LjY0LDQyLjczLDIyLjE4LDU1LjNBMTA1LjgyLDEwNS44MiwwLDAsMSwxODguNjcsMjAwLjUzWk0xMjguMjMsMzBBNDAsNDAsMCwwLDEsMTY3LDBoMWE4LDgsMCwwLDEsMCwxNmgtMWEyNCwyNCwwLDAsMC0yMy4yNCwxOCw4LDgsMCwxLDEtMTUuNS00WiIvPjwvc3ZnPg==');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzMuNjYsOTguMzRhOCw4LDAsMCwxLDAsMTEuMzJsLTU2LDU2YTgsOCwwLDAsMS0xMS4zMiwwbC0yNC0yNGE4LDgsMCwwLDEsMTEuMzItMTEuMzJMMTEyLDE0OC42OWw1MC4zNC01MC4zNUE4LDgsMCwwLDEsMTczLjY2LDk4LjM0Wk0yMzIsMTI4QTEwNCwxMDQsMCwxLDEsMTI4LDI0LDEwNC4xMSwxMDQuMTEsMCwwLDEsMjMyLDEyOFptLTE2LDBhODgsODgsMCwxLDAtODgsODhBODguMSw4OC4xLDAsMCwwLDIxNiwxMjhaIi8+PC9zdmc+');}details.all-filters summary::after,.icon-faders-horizontal{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzYsODBhOCw4LDAsMCwxLDgtOGgzMmE4LDgsMCwwLDEsMCwxNkgxODRBOCw4LDAsMCwxLDE3Niw4MFpNNDAsODhIMTQ0djE2YTgsOCwwLDAsMCwxNiwwVjU2YTgsOCwwLDAsMC0xNiwwVjcySDQwYTgsOCwwLDAsMCwwLDE2Wm0xNzYsODBIMTIwYTgsOCwwLDAsMCwwLDE2aDk2YTgsOCwwLDAsMCwwLTE2Wk04OCwxNDRhOCw4LDAsMCwwLTgsOHYxNkg0MGE4LDgsMCwwLDAsMCwxNkg4MHYxNmE4LDgsMCwwLDAsMTYsMFYxNTJBOCw4LDAsMCwwLDg4LDE0NFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01My45MiwzNC42MkE4LDgsMCwxLDAsNDIuMDgsNDUuMzhMODEuMzIsODguNTVsLS4wNi4xMkE2NSw2NSwwLDAsMCw3Miw4OGE2NCw2NCwwLDAsMCwwLDEyOGg4OGE4Ny4zNCw4Ny4zNCwwLDAsMCwzMS44LTUuOTNsMTAuMjgsMTEuMzFhOCw4LDAsMSwwLDExLjg0LTEwLjc2Wk0xNjAsMjAwSDcyYTQ4LDQ4LDAsMCwxLDAtOTZjMS4xLDAsMi4yLDAsMy4zLjEyQTg4LjQsODguNCwwLDAsMCw3MiwxMjhhOCw4LDAsMCwwLDE2LDAsNzIuMjUsNzIuMjUsMCwwLDEsNS4wNi0yNi41NGw4Nyw5NS43QTcxLjY2LDcxLjY2LDAsMCwxLDE2MCwyMDBabTg4LTcyYTg3Ljg5LDg3Ljg5LDAsMCwxLTIyLjM1LDU4LjYxQTgsOCwwLDAsMSwyMTMuNzEsMTc2LDcyLDcyLDAsMCwwLDExNy4zNyw3MGE4LDgsMCwwLDEtOS40OC0xMi44OUE4OCw4OCwwLDAsMSwyNDgsMTI4WiIvPjwvc3ZnPg==');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDQsMjAwYTE2LDE2LDAsMSwxLTE2LTE2QTE2LDE2LDAsMCwxLDE0NCwyMDBabS0xNi00MGE4LDgsMCwwLDAsOC04VjQ4YTgsOCwwLDAsMC0xNiwwVjE1MkE4LDgsMCwwLDAsMTI4LDE2MFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDgsMTI4YTg3LjM0LDg3LjM0LDAsMCwxLTE3LjYsNTIuODEsOCw4LDAsMSwxLTEyLjgtOS42MkE3MS4zNCw3MS4zNCwwLDAsMCwyMzIsMTI4YTcyLDcyLDAsMCwwLTE0NCwwLDgsOCwwLDAsMS0xNiwwLDg4LDg4LDAsMCwxLDMuMjktMjMuODhDNzQuMiwxMDQsNzMuMSwxMDQsNzIsMTA0YTQ4LDQ4LDAsMCwwLDAsOTZIOTZhOCw4LDAsMCwxLDAsMTZINzJBNjQsNjQsMCwxLDEsODEuMjksODguNjgsODgsODgsMCwwLDEsMjQ4LDEyOFptLTY5LjY2LDQyLjM0TDE2MCwxODguNjlWMTI4YTgsOCwwLDAsMC0xNiwwdjYwLjY5bC0xOC4zNC0xOC4zNWE4LDgsMCwwLDAtMTEuMzIsMTEuMzJsMzIsMzJhOCw4LDAsMCwwLDExLjMyLDBsMzItMzJhOCw4LDAsMCwwLTExLjMyLTExLjMyWiIvPjwvc3ZnPg==');}details summary::after,.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTMuNjYsMTAxLjY2bC04MCw4MGE4LDgsMCwwLDEtMTEuMzIsMGwtODAtODBBOCw4LDAsMCwxLDUzLjY2LDkwLjM0TDEyOCwxNjQuNjlsNzQuMzQtNzQuMzVhOCw4LDAsMCwxLDExLjMyLDExLjMyWiIvPjwvc3ZnPg==');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzguMzQsMTY1LjY2LDE2MCwxNDcuMzFWMjA4YTgsOCwwLDAsMS0xNiwwVjE0Ny4zMWwtMTguMzQsMTguMzVhOCw4LDAsMCwxLTExLjMyLTExLjMybDMyLTMyYTgsOCwwLDAsMSwxMS4zMiwwbDMyLDMyYTgsOCwwLDAsMS0xMS4zMiwxMS4zMlpNMTYwLDQwQTg4LjA4LDg4LjA4LDAsMCwwLDgxLjI5LDg4LjY4LDY0LDY0LDAsMSwwLDcyLDIxNmg0MGE4LDgsMCwwLDAsMC0xNkg3MmE0OCw0OCwwLDAsMSwwLTk2YzEuMSwwLDIuMiwwLDMuMjkuMTJBODgsODgsMCwwLDAsNzIsMTI4YTgsOCwwLDAsMCwxNiwwLDcyLDcyLDAsMSwxLDEwMC44LDY2LDgsOCwwLDAsMCwzLjIsMTUuMzQsNy45LDcuOSwwLDAsMCwzLjItLjY4QTg4LDg4LDAsMCwwLDE2MCw0MFoiLz48L3N2Zz4=');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDBBODguMDksODguMDksMCwwLDAsODEuMjksODguNjcsNjQsNjQsMCwxLDAsNzIsMjE2aDg4YTg4LDg4LDAsMCwwLDAtMTc2Wm0wLDE2MEg3MmE0OCw0OCwwLDAsMSwwLTk2YzEuMSwwLDIuMiwwLDMuMjkuMTFBODgsODgsMCwwLDAsNzIsMTI4YTgsOCwwLDAsMCwxNiwwLDcyLDcyLDAsMSwxLDcyLDcyWm0zNy42Ni05My42NmE4LDgsMCwwLDEsMCwxMS4zMmwtNDgsNDhhOCw4LDAsMCwxLTExLjMyLDBsLTI0LTI0YTgsOCwwLDAsMSwxMS4zMi0xMS4zMkwxNDQsMTQ4LjY5bDQyLjM0LTQyLjM1QTgsOCwwLDAsMSwxOTcuNjYsMTA2LjM0WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDBBODguMDksODguMDksMCwwLDAsODEuMjksODguNjcsNjQsNjQsMCwxLDAsNzIsMjE2aDg4YTg4LDg4LDAsMCwwLDAtMTc2Wm0wLDE2MEg3MmE0OCw0OCwwLDAsMSwwLTk2YzEuMSwwLDIuMiwwLDMuMjkuMTFBODgsODgsMCwwLDAsNzIsMTI4YTgsOCwwLDAsMCwxNiwwLDcyLDcyLDAsMSwxLDcyLDcyWm0tOC03MlY4OGE4LDgsMCwwLDEsMTYsMHY0MGE4LDgsMCwwLDEtMTYsMFptMjAsMzZhMTIsMTIsMCwxLDEtMTItMTJBMTIsMTIsMCwwLDEsMTcyLDE2NFoiLz48L3N2Zz4=');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDBBODguMDksODguMDksMCwwLDAsODEuMjksODguNjcsNjQsNjQsMCwxLDAsNzIsMjE2aDg4YTg4LDg4LDAsMCwwLDAtMTc2Wm0wLDE2MEg3MmE0OCw0OCwwLDAsMSwwLTk2YzEuMSwwLDIuMiwwLDMuMjkuMTFBODgsODgsMCwwLDAsNzIsMTI4YTgsOCwwLDAsMCwxNiwwLDcyLDcyLDAsMSwxLDcyLDcyWm0yOS42Ni04Mi4zNEwxNzEuMzEsMTM2bDE4LjM1LDE4LjM0YTgsOCwwLDAsMS0xMS4zMiwxMS4zMkwxNjAsMTQ3LjMxbC0xOC4zNCwxOC4zNWE4LDgsMCwwLDEtMTEuMzItMTEuMzJMMTQ4LjY5LDEzNmwtMTguMzUtMTguMzRhOCw4LDAsMCwxLDExLjMyLTExLjMyTDE2MCwxMjQuNjlsMTguMzQtMTguMzVhOCw4LDAsMCwxLDExLjMyLDExLjMyWiIvPjwvc3ZnPg==');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNDhWOTZhOCw4LDAsMCwxLTgsOEgxNjhhOCw4LDAsMCwxLDAtMTZoMjguNjlMMTgyLjA2LDczLjM3YTc5LjU2LDc5LjU2LDAsMCwwLTU2LjEzLTIzLjQzaC0uNDVBNzkuNTIsNzkuNTIsMCwwLDAsNjkuNTksNzIuNzEsOCw4LDAsMCwxLDU4LjQxLDYxLjI3YTk2LDk2LDAsMCwxLDEzNSwuNzlMMjA4LDc2LjY5VjQ4YTgsOCwwLDAsMSwxNiwwWk0xODYuNDEsMTgzLjI5YTgwLDgwLDAsMCwxLTExMi40Ny0uNjZMNTkuMzEsMTY4SDg4YTgsOCwwLDAsMCwwLTE2SDQwYTgsOCwwLDAsMC04LDh2NDhhOCw4LDAsMCwwLDE2LDBWMTc5LjMxbDE0LjYzLDE0LjYzQTk1LjQzLDk1LjQzLDAsMCwwLDEzMCwyMjIuMDZoLjUzYTk1LjM2LDk1LjM2LDAsMCwwLDY3LjA3LTI3LjMzLDgsOCwwLDAsMC0xMS4xOC0xMS40NFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuNjYsMTA2LjM1bC04MC04MEE4LDgsMCwwLDAsMTQ0LDMyVjcyLjM1Yy0yNS45NCwyLjIyLTU0LjU5LDE0LjkyLTc4LjE2LDM0LjkxLTI4LjM4LDI0LjA4LTQ2LjA1LDU1LjExLTQ5Ljc2LDg3LjM3YTEyLDEyLDAsMCwwLDIwLjY4LDkuNThoMGMxMS0xMS43MSw1MC4xNC00OC43NCwxMDcuMjQtNTJWMTkyYTgsOCwwLDAsMCwxMy42Niw1LjY1bDgwLTgwQTgsOCwwLDAsMCwyMzcuNjYsMTA2LjM1Wk0xNjAsMTcyLjY5VjE0NGE4LDgsMCwwLDAtOC04Yy0yOC4wOCwwLTU1LjQzLDcuMzMtODEuMjksMjEuOGExOTYuMTcsMTk2LjE3LDAsMCwwLTM2LjU3LDI2LjUyYzUuOC0yMy44NCwyMC40Mi00Ni41MSw0Mi4wNS02NC44NkM5OS40MSw5OS43NywxMjcuNzUsODgsMTUyLDg4YTgsOCwwLDAsMCw4LThWNTEuMzJMMjIwLjY5LDExMloiLz48L3N2Zz4=');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNDhIMTc2VjQwYTI0LDI0LDAsMCwwLTI0LTI0SDEwNEEyNCwyNCwwLDAsMCw4MCw0MHY4SDQwYTgsOCwwLDAsMCwwLDE2aDhWMjA4YTE2LDE2LDAsMCwwLDE2LDE2SDE5MmExNiwxNiwwLDAsMCwxNi0xNlY2NGg4YTgsOCwwLDAsMCwwLTE2Wk05Niw0MGE4LDgsMCwwLDEsOC04aDQ4YTgsOCwwLDAsMSw4LDh2OEg5NlptOTYsMTY4SDY0VjY0SDE5MlpNMTEyLDEwNHY2NGE4LDgsMCwwLDEtMTYsMFYxMDRhOCw4LDAsMCwxLDE2LDBabTQ4LDB2NjRhOCw4LDAsMCwxLTE2LDBWMTA0YTgsOCwwLDAsMSwxNiwwWiIvPjwvc3ZnPg==');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjM4LDE2LjM4LDAsMCwwLDI0LjUsMTcuODJMMTI4LDE5OC40OWw1MC41MywzMS4wOEExNi40LDE2LjQsMCwwLDAsMjAzLDIxMS43NWwtMTMuNzYtNTguMDcsNDUtMzguODNBMTYuNDMsMTYuNDMsMCwwLDAsMjM5LjE4LDk3LjI2Wm0tMTUuMzQsNS40Ny00OC43LDQyYTgsOCwwLDAsMC0yLjU2LDcuOTFsMTQuODgsNjIuOGEuMzcuMzcsMCwwLDEtLjE3LjQ4Yy0uMTguMTQtLjIzLjExLS4zOCwwbC01NC43Mi0zMy42NWE4LDgsMCwwLDAtOC4zOCwwTDY5LjA5LDIxNS45NGMtLjE1LjA5LS4xOS4xMi0uMzgsMGEuMzcuMzcsMCwwLDEtLjE3LS40OGwxNC44OC02Mi44YTgsOCwwLDAsMC0yLjU2LTcuOTFsLTQ4LjctNDJjLS4xMi0uMS0uMjMtLjE5LS4xMy0uNXMuMTgtLjI3LjMzLS4yOWw2My45Mi01LjE2QTgsOCwwLDAsMCwxMDMsOTEuODZsMjQuNjItNTkuNjFjLjA4LS4xNy4xMS0uMjUuMzUtLjI1cy4yNy4wOC4zNS4yNUwxNTMsOTEuODZhOCw4LDAsMCwwLDYuNzUsNC45Mmw2My45Miw1LjE2Yy4xNSwwLC4yNCwwLC4zMy4yOVMyMjQsMTAyLjYzLDIyMy44NCwxMDIuNzNaIi8+PC9zdmc+');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}
assets/css/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
assets/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
assets/js/admin/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
assets/phosphor-icons/bold/index.php
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
base/Login.php
@@ -1,5 +1,8 @@
<?php
namespace JVBase\base;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Custom settings for the login page can be set here
 */
base/Membership.php
@@ -2,6 +2,10 @@
namespace JVBase\base;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * JVB_MEMBERSHIP defines the structure of the directory
 *         Options include:
base/SchemaHelper.php
@@ -1,18 +1,22 @@
<?php
namespace JVBase\base;
use JVBase\Instance;
use JVBase\meta\Meta;
use JVBase\registrar\config\seo\Resolver;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
class SchemaHelper
{
    use Instance;
    protected static array $allowedTypes;
    protected static array $allowedFormats = ['schema', 'archive', 'meta', 'reference'];
    protected static array $schemas = [];
    protected static array $metas = [];
    protected static array $archives = [];
    protected static array $references = [];
    public function __construct()
    protected function __construct()
    {
        self::$allowedTypes = array_merge(['website', 'organization'], Registrar::getRegistered());
    }
@@ -299,4 +303,10 @@
        }
        return $class;
    }
    public static function reset(string $type, string $format):bool
    {
        $default = self::getDefault($type, $format);
        return self::update($type, $format, $default);
    }
}
base/Site.php
@@ -177,7 +177,7 @@
    public static function hasIntegration(string $integration):bool
    {
        $integration = strtolower($integration);
        return array_key_exists($integration, self::$integrations);
        return array_key_exists($integration, self::$integrations) && self::$integrations[$integration] === true;
    }
    public static function hasAnyIntegration(array $integrations = []):bool
base/_setup.php
@@ -1,12 +1,16 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
use JVBase\base\Site;
require(JVB_DIR.'/base/Site.php');
add_action('plugins_loaded', 'jvbDefineSite', 1);
function jvbDefineSite() {
function jvbDefineSite():void {
    Site::getInstance();
}
//require(JVB_DIR.'/base/content.php');
base/colours.php
@@ -1,5 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
$defaults = [
    'action-0'      => '#ff0080',
    'action-50'     => '#ff2492',
base/content.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
/**
 * JVB_CONTENT: Base post type definitions
 *     Structure:  slug=> [$options]
base/email.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
$name = get_bloginfo('name');
$desc = get_bloginfo('description');
$defaults = [
base/options.php
@@ -4,6 +4,9 @@
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\registrar\Fields;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the Meta.php this way
base/taxonomies.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
/**
 * JVB_TAXONOMY : defines the custom taxonomies
 *  structure: slug => [$options]
base/users.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
/**
 * JVB_USER -> defines user roles. Mainly for Dashboard/Directory sites
 *      Structure: slug => $options
build/drawer-menu/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
checks.php
@@ -43,20 +43,24 @@
        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
            if ($tax) {
                $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;
                ]);
                $result = ($query->have_posts()) ? 'true' : 'false';
                wp_reset_postdata();
                return $result;
            }
            return 'false';
        }
    );
}
inc/Instance.php
File was renamed from inc/integrations/Instance.php
@@ -1,6 +1,5 @@
<?php
namespace JVBase\integrations;
namespace JVBase;
if (!defined('ABSPATH')) {
    exit;
}
inc/admin/ContentTaxonomy.php
@@ -358,7 +358,7 @@
        $results['total'] = count($terms);
        $results['messages'][] = "Found {$results['total']} terms to process";
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'sync_content_taxonomy_tables',
            0,
            [
inc/admin/Integrations.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\admin;
use JVBase\base\Site;
use JVBase\integrations\ServiceMap;
use JVBase\managers\IconsManager;
use JVBase\registrar\Registrar;
@@ -10,6 +12,8 @@
class Integrations
{
    use ServiceMap;
    public function __construct()
    {
        // Hook into WordPress admin
@@ -34,17 +38,15 @@
        );
        // Add submenu pages for configured integrations
        $integrations = JVB()->getAvailableServices(false);
        foreach($integrations as $service => $integration) {
//          if ($integration->isSetUp() && !empty($integration->enabledContentTypes())) {
            if (!empty($integration->enabledContentTypes())) {
        foreach ($this->getAvailableServices() as $service) {
            $integration = $this->getService($service);
            if ($integration->isSetUp() && method_exists($integration, 'getAllowedContent') && !empty($integration->getAllowedContent())) {
                add_submenu_page(
                    'jvb-integrations',
                    $integration->getTitle() . ' Defaults',
                    $integration->getTitle(),
                    'manage_options',
                    'jvb-integration-'.$service,
                    'jvb-integration-'.$integration->getServiceName(),
                    [$integration, 'renderDefaults']
                );
            }
@@ -121,11 +123,13 @@
                <div class="item-grid integrations">
                    <?php
                    $integrations = JVB()->getAvailableServices(false);
                    foreach ($this->getAvailableServices() as $service) {
                        $integration = $this->getService($service);
                        if ($integration->isSetUp() && method_exists($integration, 'renderConnection')) {
                            $integration->renderConnection();
                        }
                    }
                    foreach ($integrations as $service => $integration):
                        $integration->renderConnection();
                    endforeach;
                    ?>
                </div>
            </div>
inc/admin/SEOAdmin.php
@@ -2,6 +2,8 @@
namespace JVBase\admin;
use JVBase\base\SchemaHelper;
use JVBase\Instance;
use JVBase\managers\AdminPages;
use JVBase\meta\Form;
use JVBase\registrar\Registrar;
use JVBase\ui\Tabs;
@@ -11,6 +13,7 @@
}
class SEOAdmin {
    use Instance;
    protected string $admin_page;
    protected array $types = [
        'JVBase\managers\SEO\render\Thing'                                  => 'Thing',
@@ -105,7 +108,7 @@
    protected array $checks;
    public function __construct()
    protected function __construct()
    {
        $this->setChecks();
        add_action(BASE.'dashboard_page_seo', [$this, 'addDashboardSection'], 10);
@@ -134,7 +137,7 @@
    public function addAdminMenu():void
    {
        $main = JVB()->admin()->getMainConfig();
        $main = AdminPages::getInstance()->getMainConfig();
        $this->admin_page = add_submenu_page(
            $main['menu_slug'],
            'SEO',
@@ -358,7 +361,7 @@
            ];
        }
        $success = JVB()->schemaHelper()->updateSchema($action, $data);
        $success = SchemaHelper::getInstance()->updateSchema($action, $data);
        return $success ? [
            'jvb_notice'    => 'success',
            'jvb_message'   => 'Saved changes successfully'
inc/admin/_setup.php
@@ -1,5 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
use JVBase\registrar\Registrar;
require(JVB_DIR . '/inc/admin/Integrations.php');
inc/blocks/CustomBlocks.php
@@ -1,14 +1,12 @@
<?php
namespace JVBase\blocks;
use DateTime;
use DOMDocument;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\LoginManager;
use JVBase\managers\Login;
use JVBase\managers\SEO\BreadcrumbManager;
use JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\VideoObject;
use JVBase\utility\Image;
use JVBase\utility\Video;
use WP_Block;
use WP_Query;
@@ -18,6 +16,7 @@
class CustomBlocks
{
    use Instance;
    protected Cache $cache;
    protected static ?WP_Query $currentLoop = null;
@@ -31,11 +30,10 @@
    protected static array $pendingClass = [];
    protected static bool $renderGallery = false;
    public function __construct()
    private function __construct()
    {
        $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
        $this->cache->connect('post')->connect('taxonomy');
        $this->cache->flush();
//        $this->cache = Cache::for('blocks', null,WEEK_IN_SECONDS);
        add_filter('pre_render_block', [$this, 'prerender'], 10, 3);
        add_filter('render_block', [$this, 'render'], 10, 2);
@@ -113,6 +111,9 @@
    protected function checkMethods(?string $content, array $block, ?WP_Block $parent = null, bool $isPrerender = false):?string
    {
        $blockName = $this->sanitizeBlockName($block);
        if (str_starts_with($blockName, 'woocommerce')) {
            return $content;
        }
        $base = ($isPrerender) ? 'prerender_' : 'render_';
        $method = $base.$blockName;
@@ -135,6 +136,7 @@
            //Ignore for both
            $base = [
                'core/null',
                'core/pattern',
                'core/list-item',
                'core/post-content',
                'jvb/drawer-menu',
@@ -820,8 +822,8 @@
        }
        if (array_key_exists('displayLoginAsForm', $attrs) && $attrs['displayLoginAsForm']) {
            LoginManager::getInstance()->setAction($action);
            return LoginManager::getInstance()->renderLoginForm($action, $redirect, '<h2>Login</h2>');
            Login::getInstance()->setAction($action);
            return Login::getInstance()->renderLoginForm($action, $redirect, '<h2>Login</h2>');
        }
inc/blocks/DrawerMenuBlock.php
@@ -4,7 +4,9 @@
use JVBase\managers\Cache;
use JVBase\ui\Navigation;
use WP_Block;
if (!defined('ABSPATH')) {
    exit;
}
class DrawerMenuBlock {
    protected Cache $cache;
    public function __construct()
@@ -60,8 +62,7 @@
        }
        $items = array_map(function($item) { return $item['text'];}, $menu_items);
        $key = $cache->generateKey($items);
        $menu =  $cache->remember($key,
        $menu =  $cache->remember($items,
            function () use ($menu_items, $menu_id, $collapsed) {
                $menu = new Navigation($menu_id);
                $menu->asDrawer($collapsed)->populateFromArray($menu_items);
inc/blocks/FAQBlock.php
@@ -5,16 +5,18 @@
use JVBase\registrar\Registrar;
use WP_Block;
use WP_Query;
if (!defined('ABSPATH')) {
    exit;
}
class FAQBlock {
    protected Cache $cache;
//  protected Cache $cache;
    protected string $postType;
    protected string $section;
    protected string $slug;
    protected bool $isDirectory;
    public function __construct()
    {
        $this->cache = Cache::for('faq_block', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
//      $this->cache = Cache::for('faq_block', 'post', WEEK_IN_SECONDS);
        add_action('init', [ $this, 'registerBlock' ]);
        add_action('enqueue_block_editor_assets', [$this, 'localizeData']);
    }
@@ -261,7 +263,7 @@
            }
            $buttons = '';
            if ($this->isDirectory) {
                $directory = JVB()->directories()->directories($this->slug);
                $directory = DirectoryManager::getInstance()->directories($this->slug);
                $url = $directory['url']??'';
                if (!empty($url)) {
                    $buttons .= '<li><a href="'.$url.'">'.__('See Alphabetical List', 'jvb').'</a></li>';
inc/blocks/FeedBlock.php
@@ -32,12 +32,11 @@
    public function __construct()
    {
        // Initialize cache with connections
        $this->cache = Cache::for('feed_block', WEEK_IN_SECONDS);
        if (JVB_TESTING) {
            $this->cache->flush();
        }
        $this->cache->flush();
//      // Initialize cache with connections
//      $this->cache = Cache::for('feed_block','post', WEEK_IN_SECONDS);
//      if (JVB_TESTING) {
//          $this->cache->flush();
//      }
        add_action('init', [$this, 'registerBlock']);
    }
inc/blocks/FormBlock.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\blocks;
use JVBase\integrations\services\Cloudflare;
use JVBase\managers\Cache;
use JVBase\meta\Form;
use JVBase\base\Site;
@@ -33,7 +34,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('forms', WEEK_IN_SECONDS);
        $this->cache = Cache::for('forms', null, WEEK_IN_SECONDS);
        if (JVB_TESTING) {
            $this->cache->flush();
@@ -63,7 +64,7 @@
        // Enqueue Turnstile if needed
        if (Site::hasIntegration('cloudflare')) {
            $cloudflare = JVB()->connect('cloudflare');
            $cloudflare = Cloudflare::getInstance();
            if ($cloudflare->isSetUp()) {
                $cloudflare->enqueueTurnstileScripts();
            }
@@ -131,9 +132,8 @@
            return '<div class="jvb-form-error">No valid form type selected. Please edit this block and select a form type.</div>';
        }
        $cache_key = $this->cache->generateKey($block);
        return $this->cache->remember(
            $cache_key,
            $block,
            function() use ($form_type, $block) {
                return $this->renderForm($form_type, $block);
            }
@@ -409,7 +409,7 @@
            return;
        }
        $cloudflare = JVB()->connect('cloudflare');
        $cloudflare = Cloudflare::getInstance();
        if ($cloudflare->isSetUp()) {
            $cloudflare->renderTurnstile();
        }
inc/blocks/GMBReviewBlock.php
@@ -1,13 +1,20 @@
<?php
namespace JVBase\blocks;
use JVBase\managers\Cache;
use JVBase\base\Site;
use JVBase\integrations\services\GoogleMyBusiness;
//use JVBase\managers\Cache;
use WP_Block;
if (!defined('ABSPATH')) {
    exit;
}
class GMBReviewBlock {
    protected Cache $cache;
//  protected Cache $cache;
    public function __construct()
    {
        if (!Site::hasIntegration('gmb')){
            return;
        }
        add_action('init', [ $this, 'registerBlock' ]);
    }
@@ -34,6 +41,9 @@
     */
    public function render(array $attributes, string $content, WP_Block $block):string
    {
        if (!Site::hasIntegration('gmb')) {
            return '';
        }
        $count = $attributes['count'] ?? 5;
        $showRating = $attributes['showRating'] ?? true;
        $showDate = $attributes['showDate'] ?? true;
@@ -49,11 +59,16 @@
            $inheritUser = null;
        }
        try {
            $gmb = JVB()->connect('gmb', $inheritUser);
            if (!$gmb->isSetUp()) {
            $gmb = GoogleMyBusiness::getInstance();
            $gmb->switchUser($inheritUser);
            if (!$gmb->isSetUp()
                || !method_exists($gmb, 'getReviews')
                || !method_exists($gmb, 'getReviewsViewUrl')
                || !method_exists($gmb, 'getReviewUrl')) {
                error_log('GMB Not set up for: '.(int)$inheritUser);
                return '';
            }
            $gotReviews = $gmb->getReviews();
            // Get all data
            $allReviews = $gotReviews['reviews']??[];
inc/blocks/GlossaryBlock.php
@@ -22,7 +22,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('glossary_terms', WEEK_IN_SECONDS)->connect('post', true);
        $this->cache = Cache::for('glossary_terms', 'post', WEEK_IN_SECONDS);
        add_action('init', [ $this, 'registerBlock' ]);
    }
inc/blocks/MenuBlock.php
@@ -32,7 +32,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('menu', WEEK_IN_SECONDS)->connect('post');
        $this->cache = Cache::for('menu', 'post', WEEK_IN_SECONDS);
        if (Site::hasIntegration('square')) {
            $this->base = '_square_';
        } else if (Site::hasIntegration('helcim')) {
@@ -50,9 +50,12 @@
    protected function buildParams():void
    {
        if (is_post_type_archive(BASE.'menu_item')) {
//      if (is_post_type_archive(BASE.'menu_item') ||
        if (is_post_type_archive('product') ||
            (function_exists('is_shop') && is_shop())) {
            $this->params = ['all'];
        } elseif (is_tax(BASE.'section')) {
//      } elseif (is_tax(BASE.'section')) {
        } elseif (is_tax('product_cat')) {
            $this->params = [];
        } else {
            $this->params = [];
@@ -66,10 +69,9 @@
            return '';
        }
        jvbInlineStyles('forms');
        $key = $this->cache->generateKey($this->params);
        $this->cache->flush();
        return $this->cache->remember(
            $key,
            $this->params,
            function() {
                ob_start();
                $this->renderBlock();
@@ -99,7 +101,8 @@
                <?php
                $sections = $this->getSections();
                foreach ($sections as $section) {
                    if (!jvbTermHasPosts($section['id'], BASE.'section')) {
//                  if (!jvbTermHasPosts($section['id'], BASE.'section')) {
                    if (!jvbTermHasPosts($section['id'], 'product_cat')) {
                        continue;
                    }
                    ?>
@@ -129,13 +132,18 @@
    {
        $sections = $this->getSections();
        foreach ($sections as $section) {
            if($section['name'] === 'Uncategorized') {
                continue;
            }
            $items = new WP_Query([
                'post_type' => BASE.'menu_item',
                'post_type' => 'product',
//              'post_type' => BASE.'menu_item',
                'order' => 'ASC',
                'posts_per_page' => -1,
                'tax_query' => [
                    [
                        'taxonomy' => BASE.'section',
                        'taxonomy' => 'product_cat',
//                      'taxonomy' => BASE.'section',
                        'terms' => (int) $section['id'],
                    ]
                ],
@@ -162,7 +170,108 @@
        }
    }
    protected function renderMenuItem(int $ID, string $slug, string $postType = 'menu_item') {
    /**
     * Renders from WooCommerce product
     * @param int $ID
     * @param string $slug
     * @param string $postType
     * @return void
     */
    protected function renderMenuItem(int $ID, string $slug, string $postType = 'product'):void {
        $meta = Meta::forPost($ID);
        $fields = $meta->getAll();
//      jvbDump($fields, 'Fields');
        $excerpt = get_the_excerpt($ID);
        $isVariable = !empty($fields['variations']??[]);
        $variations = $isVariable ? array_map('floatval', array_column($fields['variations'], 'price')) : [];
        $priceRange = $isVariable ?
            (min($variations).' - '.max($variations)) :
            $fields['_price']??'';
        $out = sprintf(
    '<div class="menu-item%s" data-section="%s" data-label="%s" data-id="%s">
                <div class="header row x-btw">
                    <h3>%s</h3>
                    <p class="price">$%s</p>
                </div>
                <div class="description">%s</div>
                <div class="info col right">',
            $isVariable ? ' variable' : '',
            $slug,
            $fields['post_title'],
            $ID,
            $fields['post_title'],
            $priceRange,
            $excerpt
        );
        if (!empty($fields['variations']??[])) {
            foreach ($fields['variations'] as $index => $row) {
                $out .= sprintf(
                '<div class="row right nowrap">
                        <div>
                            <h4>%s â€” %s</h4>
                            %s
                        </div>', //Note: we had 3 %s%s%s before the last </div> last time. This was to add optional ingredients or preparation time duration, not default in WooCommerce
                        $row['title'],
                        jvbCurrency($row['price']),
                        empty($row['description']) ? '' : wpautop($row['description']),
                );
                $out .= Form::render(
                    $row['id'],
                    '',
                    [
                        'type'  => 'quantity',
                        'label' => $row['title'],
                        'min'   => 0,
                        'max'   => 30,
                        'data'  => [
                            'price' => $row['price'],
                            'id'    => $row['id'],
                            'sku'   => $row['sku'],
                            'name'  => $row['title'],
                            'add-to-cart'   => ''
                        ],
                        'add'   => 'Add 1 '.$row['title'].' to cart',
                        'remove'    => 'Remove 1 '.$row['title']. ' from cart',
                    ]
                );
                $out .= '</div>';
            }
        } else {
            $out .= Form::render(
                $ID,
                '',
                [
                    'type'  => 'quantity',
                    'label' => 'Add to Order',
                    'min'   => 0,
                    'max'   => 30,
                    'step'  => 1,
                    'data'  => [
                        'price' => $fields['_price'],
                        'id'    => $ID,
                        'name'  => $fields['post_title'],
                        'step'  => 1,
                        'add-to-cart'   => '',
                    ],
                    'add'   => 'Add 1 '.$fields['post_title'].' to cart',
                    'remove'    => 'Remove 1 '.$fields['title']. ' from cart',
                ]
            );
        }
        $out .= '</div></div>';
        echo $out;
    }
    /**
    protected function renderMenuItemOLD(int $ID, string $slug, string $postType = 'menu_item') {
        $meta = Meta::forPost($ID);
        $fields = $meta->getAll();
        $values = $meta->getAll([
@@ -278,7 +387,8 @@
        </div>
        <?php
    }
    } **/
    protected function renderOnThisPage():void
inc/blocks/SimpleCalendarBlock.php
@@ -30,7 +30,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('menu', DAY_IN_SECONDS)->connect('post');
        $this->cache = Cache::for('calendar', 'post',  DAY_IN_SECONDS);
        add_action('init', [ $this, 'registerBlock' ]);
    }
@@ -82,10 +82,9 @@
            return '';
        }
        $key = $this->cache->generateKey($this->params);
        $this->cache->flush();
        return $this->cache->remember(
            $key,
            $this->params,
            function() {
                ob_start();
                $this->renderBlock();
inc/blocks/SummaryBlock.php
@@ -25,7 +25,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('summary_block', WEEK_IN_SECONDS);
        $this->cache = Cache::for('summary_block', 'post', WEEK_IN_SECONDS);
        add_action('init', [ $this, 'registerBlock' ]);
        add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
        if (JVB_TESTING) {
@@ -65,7 +65,15 @@
    {
        $this->config = $this->getConfig();
        $key = $this->generateKey();
        $cache = $this->cache->get($key);
        if (is_tax()) {
            $this->cache = Cache::for('summary_tax', 'term', WEEK_IN_SECONDS);
        } elseif (is_author()) {
            $this->cache = Cache::for('summary_auth', 'user', WEEK_IN_SECONDS);
        } else {
            $this->cache = Cache::for('summary_post', 'post', WEEK_IN_SECONDS);
        }
        $cache = $this->cache->get(get_queried_object_id());
        if ($cache) {
            return $cache;
inc/blocks/TimelineBlock.php
@@ -4,7 +4,6 @@
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_Block;
if (!defined('ABSPATH')) {
@@ -30,7 +29,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('timelines', WEEK_IN_SECONDS)->connect('post');
        $this->cache = Cache::for('timelines', 'post', WEEK_IN_SECONDS);
        if (JVB_TESTING){
            $this->cache->flush();
        }
inc/forms/PostSelector.php
@@ -2,6 +2,7 @@
namespace JVBase\forms;
use JVBase\managers\Cache;
use JVBase\managers\Roles;
use WP_REST_Request;
use WP_REST_Response;
use WP_Query;
@@ -23,7 +24,7 @@
    public function __construct(string $post_type, array $config = [])
    {
        $this->post_type = $post_type;
        $this->cache = Cache::for(jvbNoBase($post_type), WEEK_IN_SECONDS)->connect('post', true);
        $this->cache = Cache::for(jvbNoBase($post_type), 'post',WEEK_IN_SECONDS);
        $this->config = wp_parse_args($config, [
            'multiple' => true,
@@ -237,7 +238,7 @@
            'methods' => 'GET',
            'callback' => [new self('post'), 'searchPosts'],
            'permission_callback' => function($request) {
                return JVB()->roles()->checkRole(get_current_user(), $request->get_param('post_type'));
                return Roles::getInstance()()->checkRole(get_current_user(), $request->get_param('post_type'));
            },
            'args' => [
                'post_type' => [
inc/helpers/breadcrumbs.php
@@ -112,11 +112,11 @@
    if (is_singular(BASE.'directory')) {
        $type = get_post_meta(get_the_ID(), BASE.'for_type_slug', true);
        return JVB()->directories()?->getDirectoryList()[$type] ?? [];
        return DirectoryManager::getInstance()?->getDirectoryList()[$type] ?? [];
    }
    $obj = get_queried_object();
    $directories = JVB()->directories()?->directories();
    $directories = DirectoryManager::getInstance()?->directories();
    if (is_tax()) {
        $tax = jvbNoBase($obj->taxonomy);
        return array_key_exists($tax, $directories) ? $directories[$tax] : [];
inc/helpers/crud.php
@@ -5,7 +5,7 @@
}
use JVBase\managers\Cache;
use JVBase\managers\RoleManager;
use JVBase\managers\Roles;
use JVBase\meta\Form;
use JVBase\registrar\Registrar;
@@ -95,7 +95,7 @@
            'label' => 'Scrapped',
        ]
    ];
    $permission = JVB()->roles()->getContentPlural($content);
    $permission = Roles::getInstance()()->getContentPlural($content);
    $canPublish = current_user_can("publish_{$permission}") && current_user_can('skip_moderation');
    $out = '<div class="filters">';
    if (!empty($statusFilters)) {
@@ -194,7 +194,7 @@
            'disabled'    => true,
        ]
    ];
    $permission = JVB()->roles()->getContentPlural($content);
    $permission = Roles::getInstance()()->getContentPlural($content);
    if (current_user_can('skip_moderation') && current_user_can("publish_{$permission}")) {
        $bulkActions['publish']['disabled'] = false;
    }
@@ -217,7 +217,7 @@
 */
function jvbRenderDateFilter(string $content):string
{
    $cache = Cache::for('date_filter')->connect('post', true);
    $cache = Cache::for('date_filter');
    $check = $cache->get($content);
    if ($check) {
        return $check;
inc/helpers/dashboard.php
@@ -101,7 +101,7 @@
        $temp = [];
        foreach ($types as $t) {
            $permission = JVB()->roles()->getPermission('edit',$t);
            $permission = Roles::getInstance()()->getPermission('edit',$t);
            if (user_can($link, $permission)){
                $temp[] = $t;
            }
inc/helpers/directory.php
@@ -1,5 +1,8 @@
<?php
use JVBase\base\Site;
use JVBase\managers\DirectoryManager;
if (!defined('ABSPATH')) {
    exit;
}
@@ -7,5 +10,5 @@
function jvbIsDirectory():bool
{
    return JVB()->directories() && JVB()->directories()->isDirectory();
    return Site::has('is_directory') && DirectoryManager::getInstance()->isDirectory();
}
inc/helpers/formatting.php
@@ -77,7 +77,7 @@
 */
function jvbFormatRating(int $ID, string $type = 'post'):string
{
    $cache = Cache::for('rating', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
    $cache = Cache::for('rating', $type, WEEK_IN_SECONDS);
    $cached = $cache->get($ID);
    if ($cached) {
inc/helpers/members.php
@@ -1,6 +1,7 @@
<?php
use JVBase\managers\Cache;
use JVBase\managers\Roles;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
@@ -17,7 +18,7 @@
 */
function jvbShareName(int $userID):string
{
    $cache = Cache::for('usernames')->connect('user');
    $cache = Cache::for('usernames', 'user');
    return $cache->remember(
        $userID,
        function() use ($userID) {
@@ -34,7 +35,7 @@
 */
function jvbGetUserByFirstName(string $first_name):WP_User|false
{
    $cache = Cache::for('userFirstname')->connect('user', true);
    $cache = Cache::for('userFirstname', 'user');
    return $cache->remember(
        $first_name,
        function() use ($first_name) {
@@ -74,7 +75,7 @@
 */
function jvbGetUserByDisplayName(string $display_name):WP_User|false
{
    $cache = Cache::for('displayNames')->connect('user', true);
    $cache = Cache::for('displayNames', 'user');
    $cached = $cache->get($display_name)??false;
    if ($cached && is_int($cached)) {
@@ -113,7 +114,7 @@
function jvbGetUsername(int $user_id):string
{
    $key = 'user_display_names';
    $cache = Cache::for('userNames', WEEK_IN_SECONDS)->connect('user');
    $cache = Cache::for('userNames', 'user',WEEK_IN_SECONDS);
    $cached = $cache->get($user_id);
    if ($cached) {
@@ -154,7 +155,7 @@
        return false;
    }
    $cache = Cache::for('artist', 3600)->connect('post');
    $cache = Cache::for('artist', 'post', 3600);
    $cached = $cache->get($userID);
    if ($cached) {
        return match ($return) {
@@ -222,7 +223,7 @@
function  jvbUserProfileLink(int $userID):string|false
{
    $cache = Cache::for('userLink')->connect('user');
    $cache = Cache::for('userLink', 'user');
    return $cache->remember(
        $userID,
        function() use ($userID) {
@@ -238,7 +239,7 @@
            $link = get_user_meta($userID, BASE.'profile_link', true);
            //Try to create it
            if (empty($link)) {
                $link = JVB()->roles()->addUserLink($user, $role);
                $link = Roles::getInstance()()->addUserLink($user, $role);
                if (!$link) {
                    return false;
                }
inc/helpers/renderFields.php
@@ -62,9 +62,9 @@
 * @param string $type
 * @return string
 */
function jvbRenderLinks(int $ID, string $type =''):string
function jvbRenderLinks(int $ID, string $type ='post'):string
{
    $cache = Cache::for('user_links', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
    $cache = Cache::for('user_links_'.$type, $type,WEEK_IN_SECONDS);
    $cached = $cache->get($ID);
    if ($cached) {
        return $cached;
@@ -143,13 +143,16 @@
/**
 * @param int $ID
 * @param string $type
 * @param ?string $type
 *
 * @return string
 */
function jvbRenderContactInfo(int $ID, string $type = ''):string
function jvbRenderContactInfo(int $ID, ?string $type = null):string
{
    $cache = Cache::for('contact', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy');
    if (is_null($type)) {
        return '';
    }
    $cache = Cache::for('contact_'.$type, $type,WEEK_IN_SECONDS);
    $cached = $cache->get($ID);
    if($cached){
@@ -376,7 +379,7 @@
            return '';
        }
    }
    $cache = Cache::for($term->taxonomy.'_link')->connect('taxonomy');
    $cache = Cache::for($term->taxonomy.'_link', 'term');
    $key = $term->term_id;
    return $cache->remember(
        $key,
@@ -599,7 +602,7 @@
        return '';
    }
    $cache = Cache::for('locations')->connect('taxonomy');
    $cache = Cache::for('tax_locations', 'term');
    $key = $cache->generateKey($location);
    $cached = false;
inc/helpers/time.php
@@ -143,7 +143,10 @@
 */
function jvbRenderHours(int $ID, string $type = ''):string
{
    $cache = Cache::for('hours_display', WEEK_IN_SECONDS)->connect('taxonomy')->connect('post')->connect('user');
    if (!$type) {
        return '';
    }
    $cache = Cache::for('hours_display_'.$type, $type, WEEK_IN_SECONDS);
    $cached = $cache->get($ID);
inc/importers/JaneAppSalesImporter.php
@@ -2,7 +2,7 @@
namespace JVBase\importers;
use WP_Error;
use JVBase\managers\ReferralManager;
use JVBase\managers\Referrals;
if (!defined('ABSPATH')) {
    exit;
@@ -20,7 +20,7 @@
    protected string $referrals_table;
    protected string $treatments_table;
    protected string $rewards_table;
    protected ReferralManager $referral_manager;
    protected Referrals $referral_manager;
    protected array $import_stats = [];
    protected array $skipped_details = [];
@@ -58,7 +58,7 @@
        $this->referrals_table = $wpdb->prefix . BASE . 'referrals';
        $this->treatments_table = $wpdb->prefix . BASE . 'referral_treatments';
        $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
        $this->referral_manager = new ReferralManager();
        $this->referral_manager = Referrals::getInstance();
    }
    /**
inc/importers/_setup.php
@@ -1,3 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/importers/JaneAppClientImporter.php');
require(JVB_DIR . '/inc/importers/JaneAppSalesImporter.php');
inc/integrations/Admin.php
@@ -81,7 +81,7 @@
    public function renderConnection(bool $return = false):string
    {
        if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
        if ($this->userID && !$this->userCanConnect($this->userID)) {
            return '';
        }
inc/integrations/Integrations.php
@@ -338,7 +338,7 @@
        array $data,
        array $options = []
    ):bool {
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $queued =  $queue->queueOperation(
            $type,
@@ -405,7 +405,7 @@
                $this->addSavePost();
            }
            add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
//          add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
            if ($this->canSync['delete']) {
                add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1);
@@ -607,7 +607,7 @@
        if ($jpegVersion !== '' && is_int($jpegVersion)) {
            return $jpegVersion;
        }
        $uploader = new UploadManager();
        $uploader = UploadManager::getInstance();
        $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false);
        if ($converted && !is_wp_error($converted)) {
            update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']);
@@ -620,6 +620,7 @@
    /**
     * Used by JVBase\registrar\helpers\AddIntegrationFields.php
     * @param string|null $content_type
     * @return array
     */
    public function getAdditionalFields(?string $content_type = null):array
inc/integrations/OAuth.php
@@ -10,7 +10,7 @@
trait OAuth
{
    use _Base, Admin;
    use _Base, Admin, ServiceMap;
    protected OAuthURLs $oauth;
    /**
     * Child classes must set this (e.g. 'https://api.example.com/v1/')
@@ -38,6 +38,10 @@
    {
        return $this->getRequestHeaders();
    }
    public function getOAuthBaseURL():?string
    {
        return $this->baseOAuthUrl;
    }
    /**
     * Default to the base handleResponse method, but extensions can modify accordingly
@@ -291,12 +295,14 @@
        }
        // Get integration instance
        $integration = JVB()->connect($this->service_name, $user_id);
        $integration = $this->getService($this->service_name);
        if (!$integration) {
        if (!$integration || !method_exists($integration, 'switchUser')) {
            wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error');
        }
        $integration->switchUser($user_id);
        // Exchange code for tokens
        $result = $integration->handleOAuthCode($code, $state);
inc/integrations/ServiceMap.php
New file
@@ -0,0 +1,35 @@
<?php
namespace JVBase\integrations;
use JVBase\base\Site;
if (!defined('ABSPATH')) {
    exit;
}
trait ServiceMap {
    protected array $serviceMap = [
        'maps' => 'JVBase\integrations\services\GoogleMaps',
        'square' => 'JVBase\integrations\services\Square',
        'facebook' => 'JVBase\integrations\services\Facebook',
        'helcim' => 'JVBase\integrations\services\Helcim',
        'instagram' => 'JVBase\integrations\services\Instagram',
        'bluesky' => 'JVBase\integrations\services\BlueSky',
        'gmb' => 'JVBase\integrations\services\GoogleMyBusiness',
        'cloudflare' => 'JVBase\integrations\services\Cloudflare',
        'umami' => 'JVBase\integrations\services\Umami',
        'postmark' => 'JVBase\integrations\services\PostMark',
    ];
    public function getService(string $service):Integrations|false
    {
        return array_key_exists($service, $this->serviceMap) ?
            $this->serviceMap[$service]::getInstance()
            : false;
    }
    public function getAvailableServices():array
    {
        return array_filter(array_keys($this->serviceMap), function ($service) {
            return Site::hasIntegration($service);
        }, ARRAY_FILTER_USE_KEY);
    }
}
inc/integrations/SyncFromPost.php
@@ -1 +1,4 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
inc/integrations/SyncFromTerm.php
@@ -1 +1,4 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
inc/integrations/SyncFromUser.php
@@ -1 +1,4 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
inc/integrations/SyncHelpers.php
@@ -3,6 +3,7 @@
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\mergers\DefaultMerger;
use JVBase\managers\queue\Queue;
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Meta;
use WP_Query;
@@ -29,6 +30,7 @@
        self::$syncTo = $this->service_name.'_sync_to';
        self::$deleteFrom = $this->service_name.'_delete_from';
        self::$syncFrom = $this->service_name.'_sync_from';
        self::$syncCustomer = $this->service_name.'_sync_customer';
        //TODO: What is the difference between sync_from and import?
        self::$import = $this->service_name.'import_from';
    }
@@ -42,6 +44,17 @@
            default => ''
        };
    }
    public function setServiceItemID(int $itemID, string $id, string $type = 'post'):bool
    {
        $result = match($type) {
            'post'  => Meta::forPost($itemID)->set("_{$this->service_name}_item_id", $id),
            'term','taxonomy'   => Meta::forTerm($itemID)->set("_{$this->service_name}_item_id", $id),
            'user'  => Meta::forUser($itemID)->set("_{$this->service_name}_item_id", $id),
            default => false
        };
        return $result !== false;
    }
    protected function updateItemStatus(int|array $items, string $status = 'pending', string $type = 'post'):void
    {
        if (!is_array($items)) {
@@ -151,7 +164,7 @@
        if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) {
            return;
        }
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $executor = new IntegrationExecutor();
        $queue->registry()->register(self::$syncTo, new TypeConfig(
inc/integrations/SyncToPost.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\integrations;
use JVBase\managers\queue\Queue;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Post;
@@ -127,7 +128,7 @@
            return;
        }
        JVB()->queue()->add(
        Queue::getInstance()->add(
            self::$deleteFrom,
            $userID,
            [
inc/integrations/SyncToTerm.php
@@ -102,7 +102,7 @@
        if (empty($fields["_{$this->service_name}_item_id"])) {
            return;
        }
        JVB()->queue()->add(
        Queue::getInstance()->add(
            self::$deleteFrom,
            0,
            [
inc/integrations/SyncToUser.php
@@ -170,7 +170,7 @@
        if (empty($fields["_{$this->service_name}_item_id"])) {
            return;
        }
        JVB()->queue()->add(
        Queue::getInstance()->add(
            self::$deleteFrom,
            0,
            [
inc/integrations/UserConnection.php
@@ -19,7 +19,7 @@
    }
    public function switchUser(?int $userID = null):bool
    {
        if (!$this->userCanConnect($userID)) {
        if (!is_null($userID) && !$this->userCanConnect($userID)) {
            return false;
        }
        $this->clearUser();
inc/integrations/_Base.php
@@ -144,7 +144,7 @@
        if (method_exists($this, 'initializeRequestLimiter')) {
            $this->initializeRequestLimiter();
        }
        if (method_exists($this, 'intializeOAuthRateLimiter')) {
        if (method_exists($this, 'initializeOAuthRateLimiter')) {
            $this->initializeOAuthRateLimiter();
        }
    }
@@ -160,4 +160,14 @@
        }
        return $response;
    }
    public function getAllowedContent():array
    {
        return array_keys($this->contentTypes);
    }
    public function getServiceName():string
    {
        return $this->service_name;
    }
}
inc/integrations/_setup.php
@@ -1,6 +1,10 @@
<?php
use JVBase\base\Site;
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/integrations/_Base.php');
require(JVB_DIR . '/inc/integrations/Admin.php');
    require(JVB_DIR . '/inc/integrations/AdminActionResult.php');
    require(JVB_DIR . '/inc/integrations/AdminActions.php');
@@ -10,7 +14,7 @@
require(JVB_DIR . '/inc/integrations/Endpoint.php');
require(JVB_DIR . '/inc/integrations/EndpointField.php');
require(JVB_DIR . '/inc/integrations/Field.php');
require(JVB_DIR . '/inc/integrations/Instance.php');
require(JVB_DIR . '/inc/integrations/OAuth.php');
    require(JVB_DIR . '/inc/integrations/OAuthCredentials.php');
    require(JVB_DIR . '/inc/integrations/OAuthURLs.php');
@@ -30,45 +34,50 @@
require(JVB_DIR . '/inc/integrations/Webhooks.php');
require(JVB_DIR . '/inc/integrations/Integrations.php');
if (Site::hasIntegration('maps')) {
    require(JVB_DIR . '/inc/integrations/services/GoogleMaps.php');
}
add_action('jvbloaded', 'jvbLoadIntegrations');
function jvbLoadIntegrations():void
{
    if (Site::hasIntegration('maps')) {
        require(JVB_DIR . '/inc/integrations/services/GoogleMaps.php');
    }
if (Site::hasIntegration('umami')) {
    require(JVB_DIR . '/inc/integrations/services/Umami.php');
}
if (Site::hasIntegration('cloudflare')) {
    require(JVB_DIR . '/inc/integrations/services/Cloudflare.php');
}
    if (Site::hasIntegration('umami')) {
        require(JVB_DIR . '/inc/integrations/services/Umami.php');
    }
    if (Site::hasIntegration('cloudflare')) {
        require(JVB_DIR . '/inc/integrations/services/Cloudflare.php');
    }
if (Site::hasIntegration('square')) {
    require(JVB_DIR . '/inc/integrations/services/Square.php');
}
    if (Site::hasIntegration('square')) {
        require(JVB_DIR . '/inc/integrations/services/Square.php');
    }
if (Site::hasIntegration('facebook')) {
    require(JVB_DIR . '/inc/integrations/services/Facebook.php');
}
if (Site::hasIntegration('instagram')) {
    if (!Site::hasIntegration('facebook')) {
    if (Site::hasIntegration('facebook')) {
        require(JVB_DIR . '/inc/integrations/services/Facebook.php');
    }
    require(JVB_DIR . '/inc/integrations/services/Instagram.php');
}
if (Site::hasIntegration('bluesky')) {
    require(JVB_DIR . '/inc/integrations/services/BlueSky.php');
}
    if (Site::hasIntegration('instagram')) {
        if (!Site::hasIntegration('facebook')) {
            require(JVB_DIR . '/inc/integrations/services/Facebook.php');
        }
        require(JVB_DIR . '/inc/integrations/services/Instagram.php');
    }
if (Site::hasIntegration('gmb')) {
    require(JVB_DIR . '/inc/integrations/services/GoogleMyBusiness.php');
}
    if (Site::hasIntegration('bluesky')) {
        require(JVB_DIR . '/inc/integrations/services/BlueSky.php');
    }
if (Site::hasIntegration('postmark')) {
    require(JVB_DIR . '/inc/integrations/services/PostMark.php');
}
    if (Site::hasIntegration('gmb')) {
        require(JVB_DIR . '/inc/integrations/services/GoogleMyBusiness.php');
    }
    if (Site::hasIntegration('postmark')) {
        require(JVB_DIR . '/inc/integrations/services/PostMark.php');
    }
if (Site::hasIntegration('helcim')) {
    require(JVB_DIR . '/inc/integrations/services/Helcim.php');
    if (Site::hasIntegration('helcim')) {
        require(JVB_DIR . '/inc/integrations/services/Helcim.php');
    }
}
inc/integrations/services/Cloudflare.php
@@ -2,7 +2,7 @@
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
@@ -137,8 +137,7 @@
    /**
     * Verify the Turnstile response
     * Can be called directly via JVB()->connect('cloudflare')->verifyTurnstile($token)
     *   or Cloudflare::getInstance()->verifyTurnstile($token)
     * Can be called directly via Cloudflare::getInstance()->verifyTurnstile($token)
     * @param string|null $token
     * @param string $remote_ip
     * @return bool
inc/integrations/services/CloudflareOld.php
@@ -188,7 +188,7 @@
    /**
     * Verify Turnstile response
     * Can be called directly: JVB()->connect('cloudflare')->verifyTurnstile($token)
     * Can be called directly: Cloudflare::getInstance()->verifyTurnstile($token)
     */
    public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool
    {
inc/integrations/services/Facebook.php
@@ -7,7 +7,7 @@
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
@@ -17,6 +17,8 @@
use JVBase\integrations\SyncFrom;
use JVBase\integrations\SyncTo;
use JVBase\integrations\UserConnection;
use JVBase\meta\Meta;
use JVBase\meta\Sanitizer;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -40,22 +42,25 @@
        $this->title = 'Facebook';
        $this->icon = 'facebook-logo';
        $this->apiVersion = 'v25.0';
        $this->apiVersion = 'v26.0';
        $this->canSync = [
            'create'    => true,
            'update'    => true,
            'delete'    => true
            'update'    => false,
            'delete'    => false
        ];
        $this->defineFields();
        $this->defineInstructions();
        $this->defineOAuth();
        $this->defineActions();
        $this->defineContentTypes();
        $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion;
        $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion;
        $this->supportsWebp = false;
        parent::__construct();
    }
@@ -134,6 +139,31 @@
        {
            return 'Connect your Facebook Page to share posts, events, and engage with your audience.';
        }
        protected function defineContentTypes():void
        {
            $base = $this->getBaseFields();
            $this->contentTypes['post'] = array_merge($base, $this->contentTypePostFields());
            $this->contentTypes['image'] = array_merge($base, $this->contentTypeImageFields());
            //Looks like product requires some major heavy lifting. Best to skip for now
//          $this->contentTypes['product'] = array_merge($base, $this->contentTypeProductFields());
        }
            protected function getBaseFields():array
            {
                return  [
                ];
            }
            protected function contentTypePostFields():array
            {
                return [];
            }
            protected function contentTypeImageFields():array
            {
                return [];
            }
    protected function initialize(): void
    {
        // TODO: Implement initialize() method.
@@ -148,23 +178,64 @@
    {
        return [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json'
            'Accept' => 'application/json',
        ];
    }
    protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
    protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array
    {
        if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
            return $this->response(false, 'Rate limit exceeded');
        }
        $errors = $this->checkErrors($response);
        if (!empty($errors)) {
            return $errors;
        }
        $endpoint = str_replace($this->baseUrl, '', $endpoint);
        return match ($endpoint) {
            '/me/accounts?access_token='.$this->getAccessToken()    => $this->handleGetPagesResponse($response, $method),
            default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method),
            default => $response
        };
    }
        protected function checkErrors(WP_Error|array $response):array|false
        {
            if (!isset($response['error'])) {
                return false;
            }
            $error = (string)$response['error']['code'];
            switch ($error) {
                //Invalid parameter
                case '100':
                //Too many calls by this account. Wait for a bit
                case '80001':
                //Permissions error
                case '200':
                //Action has been deemed abusive, or otherwise disallowed
                case '368':
                //Missing or invalid image file
                case '324':
                //Invalid OAuth 2.0 Access Token
                case '190':
                    //Incorrect Signature
                case '104':
                //Desktop applications cannot call this function for other users
                case '240':
                //Requires additional permissions
                case '283':
                //Session is invalid; user has been checkpointed
                case '459':
                default:
                    $this->logError(
                        'handleResponse',
                        'Something went wrong: '.$response['Message']??'',
                        $response
                    );
                    return $this->response(false, 'Something went wrong: '.$response['Message']??'');
            }
        }
    /************************************************************
     * UTILITY METHODS
    ************************************************************/
@@ -188,6 +259,7 @@
            $resp = $this->getRequest(
                $this->baseUrl.'/me?access_token='.$cred['access_token'],
            );
            $tmp = [];
            if ($resp && !is_wp_error($resp)) {
                $tmp['user_id'] = $resp['id'];
                $tmp['user_name'] = $resp['name'];
@@ -275,4 +347,272 @@
    {
        return [];
    }
    public function getOrCreateImage(int $imgID, string $caption = '', bool $publish = true, bool $returnResponse = false):string|array|false
    {
        $imgID = $this->getSupportedImage($imgID);
        $id = $this->getServiceItemID($imgID);
        if (!empty($id)) {
            return $id;
        }
        $url = wp_get_attachment_url($imgID);
        if (!file_exists($url)) {
            return false;
        }
        $args = [
            'url'   => $url
        ];
        if (!empty($caption)) {
            $caption = html_entity_decode(wp_strip_all_tags($caption));
            if (!empty($caption)) {
                $args['caption'] = $caption;
            }
        }
        $alt_text = get_post_meta($imgID, '_wp_attachment_image_alt', true);
        if (!empty($alt_text)) {
            $args['alt_text_custom'] = esc_html($alt_text);
        }
        if (!$publish) {
            $args['published'] = false;
        }
        $args['access_token'] = $this->getAccessToken();
        $response = $this->postRequest(
            sprintf(
                '%s/%s/photos',
                $this->baseUrl,
                $this->getPageID(),
            ),
            $args
        );
        if (isset($response['id'])) {
            $this->setServiceItemID($imgID, $response['id']);
        }
        return $returnResponse ? $response : $response['id'];
    }
    /**********************************************************
     * POSTING UTILITIES
    **********************************************************/
    public function post(string $message):array
    {
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up.');
        }
        $message = html_entity_decode(wp_strip_all_tags($message));
        if (empty($message)) {
            return $this->response(false, 'Message cannot be empty.');
        }
        $args = [
            'message'   => $message,
        ];
        $args['access_token'] = $this->getAccessToken();
        return $this->postRequest(
            sprintf(
                '%s/%s/feed',
                $this->baseUrl,
                $this->getPageID(),
            ),
            $args
        );
    }
    /**
     * From https://developers.facebook.com/docs/graph-api/reference/photo/#u_0_o_gP
     *
     * @param int $imgID
     * @param string $caption
     * @return array
     */
    public function postSingleImage(int $imgID, string $caption):array
    {
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up yet');
        }
        $response = $this->getOrCreateImage($imgID, $caption);
        return $this->response(true, 'Posted Image', $response);
    }
    public function postGalleryImages(array $imgIDs, string $caption, int $postID = 0):array
    {
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up yet');
        }
        $post = [];
        $i = 0;
        foreach ($imgIDs as $imgID) {
            $id = $this->getOrCreateImage($imgID, '', false);
            if ($id) {
                $post['attached_media['.$i.']'] = json_encode(['media_fbid' => $id]);
                $i++;
            }
        }
        if (empty($post)) {
            return $this->response(false, 'No images posted');
        }
        $post['access_token'] = $this->getAccessToken();
        $caption = html_entity_decode(wp_strip_all_tags($caption));
        if (!empty($caption)) {
            $post['message'] = $caption;
        }
        $response = $this->postRequest(
            sprintf(
                '%s/%s/feed',
                $this->baseUrl,
                $this->getPageID(),
            ),
            $post
        );
        if ($postID > 0 && isset($response['id'])) {
            $this->setServiceItemID($postID, $response['id']);
        }
        return $this->response(true, 'Successfully posted to Facebook', $response);
    }
    public function getReviews():array
    {
        if (!$this->isSetUp() || !$this->getPageID()) {
            return [];
        }
        $ratings = $this->getRequest(
            sprintf(
            '%s/%s/ratings',
                $this->baseUrl,
                $this->getPageID(),
            )
        );
        if (!isset($ratings['data'])) {
            return [];
        }
        return $ratings['data'];
    }
    /**
     * https://developers.facebook.com/docs/graph-api/reference/page/#parameters-3
     * @param array $data allowed keys are: about, description, phone, website, emails, location {json object with street, city, state, zip, country}
     * @return array
     * TODO: Cover photo? (cover)
     * TODO: Food style (food_styles)
     */
    public function updateBusiness(array $data):array
    {
        if (!$this->isSetUp() || !$this->getPageID()) {
            return $this->response(false, 'Not set up yet');
        }
        $data = array_filter($data, function($item) use ($data) {
            if (!in_array($item, ['about', 'description', 'phone', 'website', 'emails', 'location', 'hours'])) {
                $this->logError('updateBusiness', 'Attempted to set property that is not allowed: '.$item.' with value: '.print_r($data[$item], true));
                return false;
            }
            return true;
        }, ARRAY_FILTER_USE_KEY);
        $out = [];
        foreach ($data as $prop => $val) {
            $out[$prop] = match($prop) {
                'about', 'description'  => sanitize_text_field($val),
                'phone' => Sanitizer::sanitizePhone($val),
                'emails' => $this->sanitizeEmails($val),
                'website'   => sanitize_url($val),
                'location'  => $this->sanitizeLocation($val),
                'hours' => $this->format_hours($val),
                default => null
            };
        }
        $out = array_filter($out);
        if (empty($out)) {
            return $this->response(false, 'No data to update');
        }
        $out['access_token'] = $this->getAccessToken();
        $response = $this->postRequest(
            sprintf(
                '%s/%s',
                $this->baseUrl,
                $this->getPageID()
            ),
            $out
        );
        $success = $response['success']??false;
        return $this->response($success, $success ? 'Successfully updated information' : 'Failed to update information');
    }
        protected function format_hours(array $days):array|null
        {
            //FB expects it to be:
                // - first 3 characters of day of week (mon)
                // - either 1 or 2, to allow for two set hours (ex: noon break)
                // - open or close
            //example: mon_1_open: 11:00, mon_1_close: 18:00
            //To accomplish this, we will assume the hours are formatted as with the SEO schema
            $days = array_filter($days, function ($day) {
                return in_array($day, ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
            }, ARRAY_FILTER_USE_KEY);
            $out = [];
            foreach ($days as $day => $conf) {
                if (array_key_exists('isOpen', $conf) && $conf['isOpen'] === 1
                    && array_key_exists('opens', $conf) && !empty($conf['opens'])
                    && array_key_exists('closes', $conf) && !empty($conf['closes'])
                ) {
                    $short = substr($day, 0, 3);
                    $out[$short.'_1_open'] = $conf['opens']; //Should be stored in 24hr time already
                    $out[$short.'_1_close'] = $conf['closes'];
                }
            }
            return empty($out) ? null : $out;
        }
        protected function sanitizeLocation(array $location):array|null
        {
            if (array_key_exists('province', $location)) {
                $location['state'] = $location['province'];
                unset($location['province']);
            }
            if (array_key_exists('postal_code', $location)) {
                $location['zip'] = $location['postal_code'];
                unset($location['postal_code']);
            }
            $location = array_filter($location, function ($item) use ($location) {
                if (!in_array($item, ['street', 'city', 'state', 'zip', 'country'])) {
                    $this->logError('sanitizeLocation', 'Attempted to set property that is not allowed: '.$item.', with value: '.print_r($location[$item], true));
                    return false;
                }
                if (!is_string($location[$item])) {
                    $this->logError('sanitizeLocation', 'Unexpected value for '.$item.': '.print_r($location[$item], true));
                    return false;
                }
                return true;
            }, ARRAY_FILTER_USE_KEY);
            return empty($location) ? null : $location;
        }
        protected function sanitizeEmails(array $emails):array|null
        {
            $emails = array_filter($emails, function($item) {
                return (bool)filter_var($item, FILTER_VALIDATE_EMAIL);
            });
            return empty($emails) ? null : $emails;
        }
}
inc/integrations/services/GoogleMaps.php
@@ -6,7 +6,7 @@
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\Requests;
use WP_Error;
inc/integrations/services/GoogleMyBusiness.php
@@ -2,12 +2,13 @@
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
use JVBase\integrations\SyncTo;
use JVBase\integrations\UserConnection;
@@ -17,7 +18,7 @@
class GoogleMyBusiness extends Integrations
{
    use Admin, Instance, OAuth, Requests, UserConnection;
    use Admin, Instance, OAuth, Requests, UserConnection, SyncTo;
    protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
@@ -250,4 +251,14 @@
            }
            return $options;
        }
    protected function setContentTypes(): void
    {
        // TODO: Implement setContentTypes() method.
    }
    protected function formatForService(int $itemID, string $type = 'post'): array
    {
        // TODO: Implement formatForService() method.
    }
}
inc/integrations/services/GoogleMyBusinessOld.php
@@ -2482,7 +2482,8 @@
            $gmb_item = $this->buildMenuItem($item, $defaults);
            // Get section for this item
            $categories = wp_get_post_terms($item->ID, BASE.'section');
            $categories = wp_get_post_terms($item->ID, 'product_cat');
//          $categories = wp_get_post_terms($item->ID, BASE.'section');
            $section_slug = !empty($categories) ?
                $categories[0]->slug :
                'menu-items';
inc/integrations/services/Helcim.php
@@ -2,7 +2,7 @@
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
inc/integrations/services/HelcimOld.php
@@ -757,7 +757,7 @@
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
    {
        $queue    = JVB()->queue();
        $queue    = Queue::getInstance();
        $queue->registry()->register(self::$import, new TypeConfig(
            executor:   $executor,
inc/integrations/services/Instagram.php
@@ -9,7 +9,7 @@
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
inc/integrations/services/PostMark.php
@@ -2,9 +2,10 @@
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\Requests;
use JVBase\meta\Meta;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -217,64 +218,79 @@
            return $this->response(false, 'Rate limit exceeded');
        }
        $endpoint = str_replace($this->apiBase, '', $endpoint);
        $error = $response['ErrorCode'] ?? false;
        if ($error) {
            switch ($error) {
                case '0':
                    //Do nothing, no error found
                    break;
                //Bad or missing API Token
                case '10':
                //Multiple errors
                case '11':
                //Resource Not found
                case '12':
                //Invalid Pagination Key
                case '13':
                //Maintenance; PostMark API down for a sec
                case '100':
                //Invalid Email Request
                case '300':
                //Sender Signature not found
                case '400':
                //Sender signature not confirmed
                case '401':
                //Invalid JSON
                case '402':
                //Invalid request field(s) - {FIELD_NAME}
                case '403':
                //Not allowed to send (no more credits in account)
                case '405':
                //Inactive recipient. Recipient hard bounces, or filed spam complaint
                case '406':
                //JSON required
                case '409':
                //Too many requests (500 max)
                case '410':
                //Forbidden Attachment type
                case '411':
                //Account is pending
                case '412':
                //Account may not exist
                case '413':
                //Invalid query string parameter
                case '500':
                default:
                    $this->logError(
                        'handleResponse',
                        'Something went wrong: '.$response['Message']??'',
                        $response
                    );
                    return $this->response(false, 'Something went wrong: '.$response['Message']??'');
            }
        $checkErrors = $this->checkErrors($response);
        if (!empty($checkErrors)) {
            return $checkErrors;
        }
        return match ($endpoint) {
            '/email' => $this->handleEmailResponse($response, $method),
            '/email/bulk'   => $this->handleBulkEmailResponse($response, $method),
            '/email/batch'  => $this->handleBatchEmailResponse($response, $method),
            default => $this->response(false, 'Unhandled Response for '. $endpoint . ' endpoint, with method: '. $method),
        };
    }
        protected function checkErrors(WP_Error|array $response):array|false
        {
            $error = $response['ErrorCode'] ?? false;
            if ($error) {
                switch ($error) {
                    case '0':
                        //Do nothing, no error found
                        return false;
                    //Bad or missing API Token
                    case '10':
                        //Multiple errors
                    case '11':
                        //Resource Not found
                    case '12':
                        //Invalid Pagination Key
                    case '13':
                        //Maintenance; PostMark API down for a sec
                    case '100':
                        //Maybe schedule a retry?
                        //Invalid Email Request
                    case '300':
                        //Sender Signature not found
                    case '400':
                        //Sender signature not confirmed
                    case '401':
                        //Invalid JSON
                    case '402':
                        //Invalid request field(s) - {FIELD_NAME}
                    case '403':
                        //Not allowed to send (no more credits in account)
                    case '405':
                        //Inactive recipient. Recipient hard bounces, or filed spam complaint
                    case '406':
                        //JSON required
                    case '409':
                        //Too many requests (500 max)
                    case '410':
                        //Maybe schedule a retry?
                        //Forbidden Attachment type
                    case '411':
                        //Account is pending
                    case '412':
                        //Account may not exist
                    case '413':
                        //Some errors in bulk request
                    case '422':
                        //Invalid query string parameter
                    case '500':
                    default:
                        $this->logError(
                            'handleResponse',
                            'Something went wrong: '.$response['Message']??'',
                            $response
                        );
                        return $this->response(false, 'Something went wrong: '.$response['Message']??'');
                }
            }
            return false;
        }
    /**********************************************************
     * UTILITY
    **********************************************************/
@@ -379,16 +395,16 @@
        $payload = $this->buildPayload($to, $subject, $message, $is_html, $headers, $attachments);
        $response = $this->postRequest(
        return $this->postRequest(
            sprintf('%s/email', $this->apiBase),
            $payload
        );
    }
        protected function handleEmailResponse(WP_Error|array $response, string $method):array
        {
            return $this->response(true, 'Successfully sent request to PostMark', $response);
        }
        protected function buildPayload(string|array $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
        public function buildPayload(string|array $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
        {
            $to = is_array($to) ? implode(',',$to) : $to;
@@ -426,6 +442,8 @@
            if (!empty($headers['reply-to'])) {
                $payload['ReplyTo'] = $headers['reply-to'];
            } else {
                $payload['ReplyTo'] = $this->getFromEmail();
            }
            if (!empty($attachments)) {
@@ -440,4 +458,179 @@
            return $payload;
        }
    /**
     * Sends the same message to multiple emails. Can use templates
     * @param array<int> $userIDs
     * @param string $subject
     * @param string $message
     * @param bool $is_html
     * @param array $headers
     * @param array $attachments
     * @return array
     */
    public function sendMessageToMany(array $userIDs, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
    {
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up');
        }
        $tags = [];
        $pattern = '/\{\{(.*?)\}\}/';
        if (str_contains($subject, '{{')){
            preg_match_all($pattern, $subject, $tags);
        }
        if (str_contains($message, '{{')) {
            preg_match_all($pattern, $message, $tags);
        }
        $tags = array_unique($tags);
        foreach ($tags as $tag) {
            $t = implode(
                array_map(function($word) {
                    return ucfirst($word);
                }, explode('_', $tag)
                ));
            str_replace('{{'.$tag.'}}', '{{'.$t.'}}', $subject);
            str_replace('{{'.$tag.'}}', '{{'.$t.'}}', $message);
        }
        $to = array_map(function ($userID) use ($tags, $subject, $message) {
            $meta = Meta::forUser($userID);
            $email = $meta->get('user_email');
            $out = [];
            if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
                $out['To'] = $email;
            }
            if (empty($out)) {
                $this->logError('sendMessageToMany', 'User does not have an email address',['user' => $userID]);
                return [];
            }
            $tmp = [];
            foreach ($tags as $tag) {
                $v = $meta->get($tag);
                if (!empty($v)) {
                    $tag = implode(
                        array_map(function($word) {
                            return ucfirst($word);
                        }, explode('_', $tag)
                        ));
                    $tmp[$tag] = $v;
                }
            }
            if (!empty($tmp)) {
                $out['TemplateModel'] = $tmp;
            }
            return $out;
        }, $userIDs);
        $to = array_filter($to, function($result) {
            return array_key_exists('To', $result) && !empty($result['To']);
        });
        if (empty($to)) {
            $this->logError('sendMessageToMany', 'No users set',['users' => $userIDs]);
        }
        //Onward!
        $payload = [
            'From'  => sprintf(
                '%s <%s>',
                $headers['from-name'] ?? $this->getFromName(),
                $headers['from'] ?? $this->getFromEmail()
            ),
            'Subject'       => sanitize_text_field($subject),
            'TrackOpens'    => $this->getTrackOpens() === 'true',
            'TrackLinks'    => $this->getTrackLinks(),
            'MessageStream' => 'broadcast'
        ];
        if ($is_html) {
            $payload['HtmlBody'] = $message;
            $payload['TextBody'] = $this->textFromHtml($message);
        } else {
            $payload['TextBody'] = $message;
        }
        if (!empty($headers['reply-to'])) {
            $payload['ReplyTo'] = $headers['reply-to'];
        } else {
            $payload['ReplyTo'] = $this->getFromEmail();
        }
        if (!empty($attachments)) {
            $payload['Attachments'] = $this->processAttachments($attachments);
        }
        if (!empty($headers['x-headers'])) {
            $payload['Headers'] = $headers['x-headers'];
        }
        $payload['Tag'] = $this->determineEmailTag($subject, $headers);
        $payload['Messages'] = $to;
        return $this->postRequest(
            sprintf('%s/email/bulk', $this->apiBase),
            $payload
        );
    }
        protected function handleBulkEmailResponse(WP_Error|array $response, string $method):array
        {
            return $this->response($response['Status']??'' === 'Accepted', 'Successfully sent request to Postmark', $response);
        }
    /**
     * @param array $emails An array of results from buildPayload, ready to batch send to Postmark. Limit of 500 at a time
     * @return array
     */
    public function sendMany(array $emails):array
    {
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up');
        }
        $emails = array_filter($emails, function ($email) {
            if (!array_key_exists('To', $email) || !filter_var($email['To'], FILTER_VALIDATE_EMAIL)) {
                $this->logError('sendMany', 'Email does not have a To email address', $email);
                return false;
            }
            //This shouldn't happen, but if it does, we'll just remove it for now.
            if (!array_key_exists('From', $email)) {
                $this->logError('sendMany', 'Email does not have a From email address', $email);
                return false;
            }
            return true;
        });
        if (empty($emails)) {
            $this->logError('sendMany', 'No emails have been sent', $emails);
            return $this->response(false, 'No emails have been sent', $emails);
        }
        return $this->postRequest(
            sprintf(
                '%s/email/batch',
                $this->apiBase
            ),
            $emails
        );
    }
        protected function handleBatchEmailResponse(WP_Error|array $response, string $method):array
        {
            $main = $response[0];
            $checkErrors = $this->checkErrors($main);
            if (!empty($checkErrors)) {
                return $checkErrors;
            }
            if (count($response) > 1) {
                $errors = $response;
                unset($errors[0]);
                foreach ($errors as $error) {
                    $this->checkErrors($error);
                }
            }
            return $this->response($main['Message']??'' === 'OK', 'Successfully sent request to Postmark', $response);
        }
}
inc/integrations/services/Square.php
@@ -4,7 +4,7 @@
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\APIEndpoint;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
@@ -18,6 +18,7 @@
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Sanitizer;
use JVBase\registrar\Registrar;
use JVBase\ui\CRUDSkeleton;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -163,6 +164,37 @@
        add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']);
    }
        public function addDashboardPages():void
        {
            $page = Dashboard::getInstance()->addPage('Your Orders', 'orders', 'receipt');
            $page->setScripts(['jvb-crud']);
        }
    public function renderDashPage():void
    {
        $this->determineUser();
        $crud = new CRUDSkeleton();
        $crud->icon('receipt');
        $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like');
        $crud->content($this->orderPostType, 'Order', 'Orders');
        $crud->addSearch();
        $crud->addCapabilities(['view']);
        $crud->setEmptyState(sprintf(
            '<div class="empty-state">
                <h3>%sNothing here%s</h3>
                <p>It doesn\'t look like you have any orders yet.</p>
                <p>Head on over to <a href="%s">our menu</a> and make your first order!</p>
            </div>',
            jvbDashIcon($crud->getIcon()),
            jvbDashIcon($crud->getIcon()),
            get_post_type_archive_link(BASE.'menu_item')
        ));
        $crud->render();
    }
    protected function initialize(): void
    {
        $this->defineEndpoints();
@@ -718,7 +750,7 @@
    ********************************************************************/
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
    {
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $queue->registry()->register(
            self::$syncCustomer,
            new TypeConfig(
@@ -863,7 +895,7 @@
    {
        return $this->loadCredentials()['location_id']??false;
    }
    protected function getLocations():array|false
    public function getLocations():array|false
    {
        if (!$this->getAccessToken()) {
            return false;
@@ -954,7 +986,17 @@
        return $this->contentTypes[$content_type];
    }
    public function handleImportFromSquare():array
    {
        return $this->response(true, 'Did a thing');
    }
    public function handleSyncToSquare():array
    {
        return $this->response(true, 'Did a thing');
    }
}
inc/integrations/services/SquareOld.php
@@ -912,7 +912,7 @@
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
    {
        $queue    = JVB()->queue();
        $queue    = Queue::getInstance();
        $queue->registry()->register(self::$syncCustomer, new TypeConfig(
            executor:   $executor,
@@ -1594,11 +1594,11 @@
            "If you didn't create this account, please ignore this email.\n\n" .
            "Thanks,\n",
            $site_name,
            JVB()->email()->button('Reset Password', $reset_url),
            JVB()->email()->link($reset_url),
            Emails::getInstance()->button('Reset Password', $reset_url),
            Emails::getInstance()->link($reset_url),
        );
        JVB()->email()->sendEmail(
        Emails::getInstance()->sendEmail(
            $user->user_email,
            sprintf('[%s] Welcome! Set Your Password', $site_name),
            $message
@@ -3438,7 +3438,7 @@
    public function addDashboardPages():void
    {
        $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt');
        $page = Dashboard::getInstance()->addPage('Your Orders', 'orders', 'receipt');
        $page->setScripts(['jvb-crud']);
    }
    public function renderDashPage():void
inc/integrations/services/Umami.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\Instance;
use JVBase\integrations\Integrations;
use JVBase\managers\Roles;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
@@ -11,8 +13,212 @@
class Umami extends Integrations
{
    use Admin, Instance;
    protected function __construct()
    {
        $this->service_name = 'umami';
        $this->title = 'umami';
        $this->icon = 'chart-line';
        $this->defineFields();
        $this->defineInstructions();
        parent::__construct();
        $this->defineTables();
    }
    protected function defineFields(): void
    {
        $id = $this->addField('website_id', 'Website ID', 'text', 'manage_options');
        $id->setHint('Found in your Umami dashboard under Settings->Websites');
        $id->setRequired();
        $api_url = $this->addField('api_url', 'API URL', 'text', 'manage_options');
        $api_url->setHint('Your Umami server URL. Leave blank if using umami.is cloud service.');
        $api_token = $this->addField('api_token', 'API Token (Optional)', 'text', 'manage_options');
        $api_token->setSubType('password');
        $api_token->setHint('Required only if you want to retrieve analytics data for dashboards.');
    }
    protected function defineInstructions():void
    {
        $this->instructions = [
            'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID'
        ];
    }
    protected function defineTables():void
    {
        //TODO: Once we have an actual umami setup, we can store the fetched data here for reference
        //Ideally, we have the type (term, user, post), WP ID, created_date, and count
    }
    protected function initialize(): void
    {
        // TODO: Implement initialize() method.
        // Add tracking script to frontend
        add_action('wp_head', [$this, 'renderTrackingScript'], 5);
        // Schedule data collection if API is configured
        if ($this->getApiToken()) {
            add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
            if (!wp_next_scheduled('jvb_daily_umami_collection')) {
                wp_schedule_event(time(), 'daily', 'jvb_daily_umami_collection');
            }
        }
    }
    public function renderTrackingScript():void
    {
        if (JVB_TESTING || !$this->isSetUp() || is_admin()) {
            return;
        }
        $script_url = $this->getTrackingScriptUrl();
        if (!$this->getWebsiteID() || empty($script_url)) {
            return;
        }
        echo sprintf(
            '<link rel="preconnect" href="https://%s"/>' . "\n",
            esc_attr(parse_url($script_url, PHP_URL_HOST))
        );
        $attributes = [
            'defer',
            sprintf(
                'src="%s"',
                esc_url($script_url)
            ),
            sprintf(
                'data-website-id="%s"',
                esc_attr($this->getWebsiteID())
            )
        ];
        $tags = $this->getCurrentTag();
        if (!empty($tags)) {
            $attributes[] = $tags;
        }
        echo sprintf(
            '<script %s></script>',
            implode(' ', $attributes)
        );
        $this->outputCustomEvent();
    }
        protected function getCurrentTag():string
        {
            $contentTax = array_map('jvbCheckBase', Registrar::withFeature('is_content', 'term'));
            if (!empty($contentTax) && is_tax($contentTax)) {
                $tax = get_queried_object();
                $reg = Registrar::getInstance($tax->taxonomy);
                return 'data-tag="'.$reg->getUmamiEvent().'"';
            }
            $profiles = array_map('jvbCheckBase', Registrar::withFeature('profile_link'));
            if (!empty($profiles) && is_singular($profiles)) {
                $usr = get_queried_object();
                $reg = Registrar::getInstance($usr->post_type);
                return 'data-tag="'.$reg->getUmamiEvent().'"';
            }
            return '';
        }
        protected function outputCustomEvent():void
        {
            $args = [];
            $event = false;
            $contentTax = array_map('jvbCheckBase', Registrar::withFeature('is_content', 'term'));
            if (!empty($contentTax) && is_tax($contentTax)) {
                $tax = get_queried_object();
                $reg = Registrar::getInstance($tax->taxonomy);
                $event = $reg->getUmamiEvent();
                $args = [
                    'id'    => $tax->term_id,
                ];
                if ($reg->isOwnable()) {
                    $owners = jvbGetTermOwners($tax->term_id);
                    if (!empty($owners)) {
                        $args['owners'] = $owners;
                    }
                }
            }
            $profiles = array_map('jvbCheckBase', Registrar::withFeature('profile_link'));
            if (!empty($profiles) && is_singular($profiles)) {
                $usr = get_queried_object();
                $reg = Registrar::getInstance($usr->post_type);
                $event = $reg->getUmamiEvent();
                global $post;
                $args['id'] = get_the_ID();
                $args['owner_id'] = $post->post_author;
            }
            if ($event && !empty($args)) {
                echo sprintf(
                    '<script defer>umami.track(event_name: %s, data: %s);</script>',
                    $event,
                    json_encode($args)
                );
            }
        }
    /*********************************************************
     * UTILITY
     *********************************************************/
    public function getWebsiteID(): string|false
    {
        return $this->loadCredentials()['website_id']??false;
    }
    protected function getApiUrl():string|false
    {
        $api = $this->loadCredentials()['api_url'];
        return empty($api) ? false : $api;
    }
    protected function getApiToken():string|false
    {
        $api = $this->loadCredentials()['api_token'];
        return empty($api) ? false : $api;
    }
    protected function getTrackingScriptUrl():string
    {
        if ($this->getApiUrl()) {
            return rtrim($this->getApiUrl(), '/').'/script.js';
        }
        // Default to Umami cloud
        return 'https://cloud.umami.is/script.js';
    }
    public function buildEvent(string $event, array $args = []):string
    {
        $base = 'data-umami-event';
        $out = [
            $base   => esc_attr($event),
        ];
        foreach ($args as $k => $v) {
            $out[$base.$k] = esc_attr(jvbNoBase($v));
        }
        return $this->attributesToString($out);
    }
        protected function attributesToString(array $attributes):string
        {
            $strings = [];
            foreach ($attributes as $key => $value) {
                $strings[] = sprintf(
                    '%s="%s"',
                    $key,
                    $value
                );
            }
            return implode(' ', $strings);
        }
}
inc/integrations/services/UmamiOld.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
@@ -216,6 +217,7 @@
        $event = '';
        $type = '';
        if (is_singular([BASE . 'artist', BASE . 'partner'])) {
            // Profile view
            global $post;
inc/integrations/services/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/AdminPages.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_REST_Response;
@@ -16,6 +17,7 @@
 */
class AdminPages
{
    use Instance;
    protected array $subpages;
    protected array $actions;
    protected string $page_hook;
@@ -291,7 +293,7 @@
    protected function renderStatusItems():void
    {
        // Get queue stats
        $queue_status = JVB()->queue()->getStatus();
        $queue_status = Queue::getInstance()->getStatus();
        error_log('Queue Status: '.print_r($queue_status, true));
        // Other system checks
@@ -660,7 +662,7 @@
            }
            $group = sanitize_text_field($request->get_param('group'));
            Cache::invalidateGroup($group);
            Cache::for($group)->flush();
            return new WP_REST_Response([
                'success' => true,
inc/managers/Approvals.php
File was renamed from inc/managers/ApprovalManager.php
@@ -4,19 +4,25 @@
use DateTime;
use JVBase\base\Site;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\routes\ApprovalRoutes;
if (!defined('ABSPATH')) {
    exit;
}
class ApprovalManager {
class Approvals {
    use Instance;
    protected array $tables =[];
    protected int $expiresAt = 21; //days for request
    protected int $requiredVotes = 3; //Number of votes before a term is approved
    protected bool $hasApproval = false;
    public function __construct()
    protected Rest $route;
    private function __construct()
    {
        $this->route = ApprovalRoutes::getInstance();
        $this->defineTables();
        if (empty($this->tables)) {
            return;
@@ -370,7 +376,7 @@
            ]);
        JVB()->notification()->notify($request['user_id'], $type.'approved', null, 'Your suggestion "'.$request['name'].'" was denied.');
        NotificationManager::getInstance()->notify($request['user_id'], $type.'approved', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function finalizeDenial(int $request_id, string $type):void
    {
@@ -387,7 +393,7 @@
                'request_id' => $request_id
            ]);
        JVB()->notification()->notify($request['user_id'], $type.'denied', null, 'Your suggestion "'.$request['name'].'" was denied.');
        NotificationManager::getInstance()->notify($request['user_id'], $type.'denied', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function getApprovalRequests(int $userID, ?string $type = null):array
inc/managers/CRUDManager.php
@@ -27,14 +27,10 @@
        if (!$this->registrar) {
            return;
        }
        $this->cache = Cache::for('crud_date_ranges','post')->user();
        $this->user_id = get_current_user_id();
        $this->content = $content;
        $this->cache = Cache::for('crud')->connect('post')->connect('taxonomy');
        if (JVB_TESTING) {
            $this->cache->flush();
        }
        // Create and configure skeleton
        $this->skeleton = new CRUDSkeleton();
@@ -187,7 +183,7 @@
    protected function addDateRanges():array
    {
        return $this->cache->user()->remember(
        return $this->cache->remember(
            'dateRanges',
            function() {
                $postType = jvbCheckBase($this->content);
inc/managers/Cache.php
@@ -9,23 +9,78 @@
{
    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 bool $varyByAuth = false;
    private bool $varyByUser = false;
    private function __construct(string $group, int $ttl)
    private array $index=[];
    private ?string $target = null;
    private function __construct(string $group, ?string $target = null, int $ttl = WEEK_IN_SECONDS)
    {
        if (!is_null($target) && empty($this->index)) {
            $this->target = null;
            $this->index = get_option(BASE."cache_{$target}_index",[]);
        }
        $this->group = $group;
        $this->ttl   = $ttl;
        $this->hasRedis = (bool) wp_using_ext_object_cache();
        $this->logGroup();
        $this->hasRedis = (bool)wp_using_ext_object_cache();
    }
    protected function logGroup():void
    {
        $changed = false;
        $groups = get_option(BASE."cache_groups", []);
        if (!array_key_exists($this->group, $groups)) {
            $groups[$this->group] = [
                'auth'  => $this->varyByAuth,
                'user'  => $this->varyByUser,
                'target'=> $this->target,
                'ttl'   => $this->ttl,
            ];
            $changed = true;
        }
        if ($groups[$this->group]['auth'] && !$this->varyByAuth) {
            error_log('[Cache]Group '.$this->group.' previously defined as varying by auth, but is not set. Setting it now.');
            $this->auth();
        }
        if ($groups[$this->group]['user'] && !$this->varyByUser) {
            error_log('[Cache]Group '.$this->group.' previously defined as varying by user, but is not set. Setting it now.');
            $this->user();
        }
        if (is_null($this->target) && !is_null($groups[$this->group]['target'])) {
            error_log('[Cache]Group '.$this->group. ' previously defined with target '.$groups[$this->group]['target'].'... Setting it now.');
            $this->target = $groups[$this->group]['target'];
        } elseif ($this->target !== $groups[$this->group]['target']) {
            error_log('[Cache]WARNING Group previously defined with target '.$groups[$this->group]['target'].' - but is currently defined with '.$this->target. '. You may want to look into this.');
        }
        if ($this->ttl !== $groups[$this->group]['ttl']) {
            error_log('[Cache]Group '. $this->group. ' previously defined with ttl '.$groups[$this->group]['ttl']. ' - but is currently defined with '. $this->ttl);
            $groups[$this->group]['ttl'] = $this->ttl;
            $changed = true;
        }
        if ($this->varyByAuth && !$groups[$this->group]['auth']) {
            $groups[$this->group]['auth'] = true;
            $changed = true;
        }
        if ($this->varyByUser && !$groups[$this->group]['user']) {
            $groups[$this->group]['user'] = true;
            $changed = true;
        }
        if ($this->target !== $groups[$this->group]['target']) {
            $groups[$this->group]['target'] = $this->target;
            $changed = true;
        }
        if ($changed) {
            update_option(BASE."cache_groups", $groups, false);
        }
    }
    public static function registerHooks(): void
@@ -45,9 +100,9 @@
        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);
//      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);
@@ -55,9 +110,9 @@
        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);
//      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);
    }
    public function auth(): static
@@ -83,41 +138,48 @@
        return (string) $key;
    }
    /* ---------------------------------------------------------------------
     * Factory
     * ------------------------------------------------------------------- */
    public static function for(string $group, int $ttl = HOUR_IN_SECONDS): self
    public static function for(string $group, ?string $target = null, int $ttl = HOUR_IN_SECONDS): self
    {
        $group = sanitize_key($group);
        if (!isset(self::$instances[$group])) {
            self::$instances[$group] = new self($group, $ttl);
            self::$instances[$group] = new self($group, $target, $ttl);
        }
        return self::$instances[$group];
    }
    /**
     * To help facilitate cache flushing on object updates, we track the keys that are used for a particular id
     * Generally, $this->target will either be 'post', 'user', 'term', or 'custom'
     * @param int $ID
     * @param string $key
     * @return void
     */
    protected function trackID(int $ID, string $key):void
    {
        if (is_null($this->target)) {
            return;
        }
        if (!array_key_exists($ID, $this->index[$this->target])) {
            $this->index[$ID] = [];
        }
        if (!array_key_exists($this->group, $this->index[$ID])) {
            $this->index[$ID][$this->group] = [];
        }
        if (!in_array($key, $this->index[$ID][$this->group])) {
            $this->index[$this->target][$ID][$this->group][] = $key;
            update_option(BASE."cache_{$this->target}_index", $this->index, false);
        }
    }
    /* ---------------------------------------------------------------------
     * Core operations
     * ------------------------------------------------------------------- */
    public function remember(int|string|array $key, callable $callback, ?int $ttl = null): mixed
    {
        //TODO: Redis is fucking this all up for some reason. While we figure it out, let's just return the callback
        return $callback();
        if (is_array($key)) {
            $key = $this->generateKey($key);
        }
        if (!empty($this->tags)) {
            return $this->rememberTagged(
                $key,
                $this->tags,
                $callback,
                $ttl
            );
        }
        $key = $this->generateKey($key);
        $value = $this->get($key);
@@ -133,44 +195,55 @@
        return $value;
    }
    public function get(int|string|array $id): mixed
    protected function transientKey(string $key):string
    {
        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;
        return BASE."{$this->group}_{$key}";
    }
    public function set(int|string|array $id, mixed $value, ?int $ttl = null): void
    public function get(int|string|array $key): mixed
    {
        if (is_array($id)) {
            $id = $this->generateKey($id);
        }
        $ttl = $ttl ?? $this->ttl;
        $key = $this->generateKey($key, false);
        $value = $this->hasRedis ?
            wp_cache_get($key, $this->group) :
            get_transient($this->transientKey($key));
        return $value['value']??false;
    }
    public function getTimestamp(int|string|array $key): mixed
    {
        $key = $this->generateKey($key, false);
        $value = $this->hasRedis ?
            wp_cache_get($key, $this->group) :
            get_transient($this->transientKey($key));
        return $value['modified']??false;
    }
    public function set(int|string|array $key, mixed $value, ?int $ttl = null): void
    {
        $key = $this->generateKey($key);
        $ttl = $ttl??$this->ttl;
        $store = [
            'value' => $value,
            'modified'  => time()
        ];
        if ($this->hasRedis) {
            wp_cache_set($id, $value, $this->group, $ttl);
            wp_cache_set($key, $store, $this->group, $ttl);
        } else {
            set_transient("jvb_{$this->group}_{$id}", $value, $ttl);
            set_transient($this->transientKey($key), $store, $ttl);
        }
    }
    public function forget(int|string|array $id): void
    public function forget(int|string|array $key): void
    {
        if (is_array($id)) {
            $id = $this->generateKey($id);
        }
        $key = $this->generateKey($key);
        if ($this->hasRedis) {
            wp_cache_delete($id, $this->group);
            wp_cache_delete($key, $this->group);
        } else {
            delete_transient("jvb_{$this->group}_{$id}");
            delete_transient($this->transientKey($key));
        }
    }
    public function flush(): void
@@ -186,266 +259,53 @@
        }
    }
    /* ---------------------------------------------------------------------
     * Invalidation
     * ------------------------------------------------------------------- */
    public static function invalidateItem(string $group, int|string|array $id): void
    public static function clearPost(int $ID):void
    {
        if (is_array($id)) {
            $id = self::for($group)->generateKey($id);
        $index = get_option(BASE."cache_post_index", []);
        if (!array_key_exists($ID, $index)) {
            return;
        }
        $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);
        foreach ($index[$ID] as $group => $keys) {
            $cache = self::for($group, 'post');
            foreach ($keys as $key) {
                $cache->forget($key);
            }
        }
        self::invalidateByTag($group, $id);
    }
    public static function clearUser(int $ID):void
    {
        $index = get_option(BASE."cache_user_index", []);
        if (!array_key_exists($ID, $index)) {
            return;
        }
        foreach ($index[$ID] as $group => $keys) {
            $cache = self::for($group, 'user');
            foreach ($keys as $key) {
                $cache->forget($key);
            }
        }
    }
    public static function clearTerm(int $ID):void
    {
        $index = get_option(BASE."cache_term_index", []);
        if (!array_key_exists($ID, $index)) {
            return;
        }
        foreach ($index[$ID] as $group => $keys) {
            $cache = self::for($group, 'term');
            foreach ($keys as $key) {
                $cache->forget($key);
            }
        }
    }
    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;
        self::for($group)->flush();
    }
    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)) {
            $key = $this->generateKey($key);
        }
        $instanceTags = array_map(fn($tag) => [$this->group, $tag], $this->getTags());
        $tags = array_unique(array_merge(
            $instanceTags,
            array_map(fn($tag) => [sanitize_key($tag[0]), $tag[1]], $tags)
        ), SORT_REGULAR);
        $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
     ****************************************************/
@@ -453,8 +313,8 @@
    {
        global $wpdb;
        $pattern = '_transient_jvb_' . $this->group . '_%';
        $timeout_pattern = '_transient_timeout_jvb_' . $this->group . '_%';
        $pattern = '_transient_' .BASE. $this->group . '%';
        $timeout_pattern = '_transient_timeout_' . BASE . $this->group . '%';
        // Remove LIMIT to avoid table locks, add retry for deadlocks
        $attempts = 0;
@@ -494,13 +354,13 @@
        }
//      error_log('[Clearing cache for post change: '.$postId.']');
        self::invalidateItem('post', $postId);
        self::clearPost($postId);
    }
    public static function onPostDelete(int $postId): void
    {
//      error_log('[Clearing cache for post delete: '.$postId.']');
        self::invalidateItem('post', $postId);
        self::clearPost($postId);
    }
//  public static function onPostMetaChange(int $metaId, int $objectId): void
@@ -521,25 +381,25 @@
    public static function onTermChange(int $termId, int $ttId, string $taxonomy): void
    {
        error_log('[Clearing cache for term change: '.$termId.']');
        self::invalidateItem('taxonomy', $termId);
        self::clearTerm($termId);
    }
    public static function onTermDelete(int $termId): void
    {
//      error_log('[Clearing cache for term delete: '.$termId.']');
        self::invalidateItem('taxonomy', $termId);
        self::clearTerm($termId);
    }
    public static function onTermMetaChange(int $metaId, int $objectId): void
    {
//      error_log('[Clearing cache for term meta change: '.$objectId.']');
        self::invalidateItem('taxonomy', $objectId);
        self::clearTerm($objectId);
    }
    public static function onTermMetaDelete(array $metaIds, int $objectId): void
    {
//      error_log('[Clearing cache for term meta delete: '.$objectId.']');
        self::invalidateItem('taxonomy', $objectId);
        self::clearTerm($objectId);
    }
    /****************************************************
@@ -548,25 +408,25 @@
    public static function onUserChange(int $userId): void
    {
//      error_log('[Clearing cache for user change: '.$userId.']');
        self::invalidateItem('user', $userId);
        self::clearUser($userId);
    }
    public static function onUserDelete(int $userId): void
    {
//      error_log('[Clearing cache for user delete: '.$userId.']');
        self::invalidateItem('user', $userId);
        self::clearUser($userId);
    }
    public static function onUserMetaChange(int $metaId, int $objectId): void
    {
//      error_log('[Clearing cache for user meta change: '.$objectId.']');
        self::invalidateItem('user', $objectId);
        self::clearUser($objectId);
    }
    public static function onUserMetaDelete(array $metaIds, int $objectId): void
    {
//      error_log('[Clearing cache for user meta delete: '.$objectId.']');
        self::invalidateItem('user', $objectId);
        self::clearUser($objectId);
    }
    /***************************************************
     * UTILITY
@@ -575,13 +435,33 @@
     * 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
     * @param int|string|array $key Key-value pairs that uniquely identify this cache entry
     * @return string MD5 hash of sorted parameters
     */
    public function generateKey(array $params): string
    public function generateKey(int|string|array $key, bool $trackIt = true): string
    {
        ksort($params);
        return md5(serialize($params));
        $trackID = false;
        $ID = false;
        if (!is_null($this->target) && is_int($key)) {
            //Likely an item id. Store it for reference
            $trackID = true;
            $ID = $key;
        }
        if (is_array($key)) {
            ksort($key);
            if (!is_null($this->target) && array_key_exists('id', $key)) {
                $trackID = true;
                $ID = $key['id'];
            }
            $key = md5(serialize($key));
        }
        $key = $this->resolveKey($key);
        if ($trackIt && !is_null($this->target) && $ID && $trackID) {
            $this->trackID($ID, $key);
        }
        return $key;
    }
    /**
@@ -592,90 +472,25 @@
     */
    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
        $groups = get_option(BASE."cache_groups", []);
        $count = 0;
        foreach (array_keys($groups) as $group) {
            self::invalidateGroup($group);
            $count++;
        foreach ($groups as $group => $conf) {
            $cache = Cache::for($group, $conf['target']??null, $conf['ttl']);
            $cache->flush();
            $count ++ ;
            usleep(50000); // Wait 50ms before retry
        }
        // 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
     * Get all cache groups
     *
     * @return array Format: ['group' => ['connects_to' => [...], 'connected_from' => [...]]]
     * @return array
     */
    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;
        return get_option(BASE."cache_groups", []);
    }
}
inc/managers/CacheBack.php
New file
@@ -0,0 +1,684 @@
<?php
namespace JVBase\managers;
if (!defined('ABSPATH')) {
    exit;
}
class CacheBack
{
    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 = false;
    private bool $varyByAuth = false;
    private bool $varyByUser = false;
    private function __construct(string $group, int $ttl)
    {
        return;
        $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, now handled via Meta.php?
//      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);
    }
    public function auth(): static
    {
        $this->varyByAuth = true;
        return $this;
    }
    public function user(): static
    {
        $this->varyByUser = true;
        return $this;
    }
    private function resolveKey(int|string $key): string
    {
        if ($this->varyByUser) {
            return $key . ':u' . get_current_user_id();
        }
        if ($this->varyByAuth) {
            return $key . ':' . (is_user_logged_in() ? 'auth' : 'guest');
        }
        return (string) $key;
    }
    /* ---------------------------------------------------------------------
     * 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
    {
        //TODO: Redis is fucking this all up for some reason. While we figure it out, let's just return the callback
        return $callback();
        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
    {
        return false;
        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
    {
        return;
        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)) {
            $key = $this->generateKey($key);
        }
        $instanceTags = array_map(fn($tag) => [$this->group, $tag], $this->getTags());
        $tags = array_unique(array_merge(
            $instanceTags,
            array_map(fn($tag) => [sanitize_key($tag[0]), $tag[1]], $tags)
        ), SORT_REGULAR);
        $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;
    }
}
inc/managers/CustomTable.php
@@ -38,6 +38,8 @@
    protected static string $termIDType;
    protected static string $postIDType;
    protected static string $postTable;
    protected ?CustomTable $karmaTable = null;
    protected ?CustomTable $favouritesTable = null;
    protected Cache $cache;
@@ -73,6 +75,15 @@
        return self::$instances[$tableName];
    }
    public function setKarma(CustomTable $karma):void
    {
        $this->karmaTable = $karma;
    }
    public function setFavourite(CustomTable $favourites):void
    {
        $this->favouritesTable = $favourites;
    }
    public function ensureDefined() {
        $this->ensureUserTable();
        $this->ensureUserIDType();
@@ -143,6 +154,11 @@
        return $this;
    }
    public function getColumnKeys():array
    {
        return array_keys($this->columns);
    }
    /**
     * @param array $keys An array of {string} $keys. If a $key is an array, you can define a custom $key => $value, example: 'UNIQUE' => $value, or 'PRIMARY' => $value
     * @return $this
@@ -318,6 +334,26 @@
        return $this;
    }
    public function orderByKarma(string $metric = 'karmic_score', string $direction = 'DESC'): self
    {
        if (!$this->karmaTable || !in_array($metric, ['upvotes', 'downvotes', 'karmic_score'])) {
            return $this->orderBy('created_at');
        }
        $this->builder['orderby'] = $metric;
        $this->builder['order'] = strtoupper($direction);
        return $this;
    }
    public function orderByFavourites(string $direction = 'DESC'): self
    {
        if (!$this->favouritesTable) {
            return $this->orderBy('created_at');
        }
        $this->builder['orderby'] = 'favourites_count';
        $this->builder['order'] = strtoupper($direction);
        return $this;
    }
    /**
     * Set pagination
     *
@@ -577,7 +613,7 @@
    public function get(array $where, string $output = OBJECT): object|array|null
    {
        return $this->cache->remember(
            $this->cache->generateKey(array_merge(['output' => $output], $where)),
            array_merge(['output' => $output], $where),
            function() use ($where, $output) {
                $query = "SELECT * FROM {$this->fullTableName}";
                $where = $this->buildWhereClause($where);
@@ -607,7 +643,7 @@
    public function getMany(array $args = [], bool $itemsOnly = true, string $output = OBJECT): array
    {
        $items = $this->cache->remember(
            $this->cache->generateKey(array_merge($args, ['output' => $output])),
            array_merge($args, ['output' => $output]),
            function () use ($args, $output) {
                $query = "SELECT * FROM {$this->fullTableName}";
                $values = [];
@@ -622,7 +658,7 @@
                // ORDER BY
                if (!empty($args['orderby'])) {
                    $orderby = sanitize_sql_orderby($args['orderby']);
                    $orderby = $this->resolveOrderby($args['orderby']);
                    $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
                    $query .= " ORDER BY {$orderby} {$order}";
                }
@@ -664,6 +700,26 @@
        ];
    }
    protected function resolveOrderby(string $orderby):string
    {
        $orderby = strtolower($orderby);
        if (in_array($orderby, ['upvotes', 'downvotes', 'karmic_score']) && $this->karmaTable) {
            $tableName = $this->karmaTable->getFullTableName();
            return match ($orderby) {
                'upvotes'   => "(SELECT COUNT(*) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id AND k.vote = 'up')",
                'downvotes' => "(SELECT COUNT(*) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id AND k.vote = 'down')",
                'karmic_score'     => "(SELECT COALESCE(SUM(CASE WHEN k.vote = 'up' THEN 1 WHEN k.vote = 'down' THEN -1 ELSE 0 END), 0) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id)",
            };
        }
        if ($orderby === 'favourites_count' && $this->favouritesTable) {
            $tableName = $this->favouritesTable->getFullTableName();
            return "(SELECT COUNT(*) FROM {$tableName} f WHERE f.target_id = {$this->fullTableName}.id)";
        }
        return sanitize_sql_orderby($orderby);
    }
    /**
     * Get a specific column value from all matches
@@ -676,7 +732,7 @@
        $key = array_merge($where, ['column' => $column, 'orderby' => $orderby, 'order' => $order, 'limit' => $limit]);
        return $this->cache->remember(
            $this->cache->generateKey($key),
            $key,
            function() use ($column, $where, $orderby, $order, $limit) {
                if (!empty($this->columns) && !array_key_exists($column, $this->columns)) {
                    $this->logError('pluck', ['reason' => "Column '{$column}' not in definition"]);
@@ -684,7 +740,7 @@
                }
                $args = ['where' => $where];
                if ($orderby) {
                    $args['orderby'] = $orderby;
                    $args['orderby'] = $this->resolveOrderby($orderby);
                }
                if ($order) {
                    $args['order'] = $order;
@@ -708,7 +764,7 @@
    public function count(array $where = []): int
    {
        return $this->cache->remember(
            $this->cache->generateKey(array_merge(['source' => 'count'], $where)),
            array_merge(['source' => 'count'], $where),
            function() use ($where) {
                $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
                $values = [];
@@ -1045,16 +1101,34 @@
        foreach ($where as $column => $value) {
            $column_safe = esc_sql($column);
            // OR grouping: ['OR' => [ [...cond1], [...cond2] ]]
            if ($column === 'OR' && is_array($value)) {
                $orConditions = [];
                foreach ($value as $orWhere) {
                    $orClause = $this->buildWhereClause($orWhere);
                    $orConditions[] = $orClause['sql'];
                    $values = array_merge($values, $orClause['values']);
                }
                $conditions[] = '(' . implode(' OR ', $orConditions) . ')';
                continue;
            }
            if ($value === null) {
                $conditions[] = "`{$column_safe}` IS NULL";
            } elseif (is_array($value) && count($value) === 2 && is_string($value[0])) {
                [$operator, $operand] = $value;
                $operator = strtoupper(trim($operator));
                if (in_array($operator, ['IN', 'NOT IN']) && is_array($operand)) {
                if ($operator === 'IS NOT NULL') {
                    $conditions[] = "`{$column_safe}` IS NOT NULL";
                } elseif (in_array($operator, ['IN', 'NOT IN']) && is_array($operand)) {
                    $placeholders = implode(',', array_map([$this, 'getPlaceholder'], $operand));
                    $conditions[] = "`{$column_safe}` {$operator} ({$placeholders})";
                    $values       = array_merge($values, $operand);
                } elseif (in_array($operator, ['BETWEEN', 'NOT BETWEEN']) && is_array($operand) && count($operand) === 2) {
                    $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand[0]) . " AND " . $this->getPlaceholder($operand[1]);
                    $values[]     = $operand[0];
                    $values[]     = $operand[1];
                } else {
                    // <, >, <=, >=, !=, LIKE, etc.
                    $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand);
@@ -1114,4 +1188,10 @@
            error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . $this->wpdb->last_error);
        }
    }
    public function sum(string $column, array $where):float
    {
        $rows = $this->pluck($column, $where);
        return array_sum($rows);
    }
}
inc/managers/Dashboard/Dashboard.php
File was renamed from inc/managers/Dashboard/DashboardManager.php
@@ -1,12 +1,13 @@
<?php
namespace JVBase\managers\Dashboard;
use JetBrains\PhpStorm\NoReturn;use JVBase\base\Site;use JVBase\forms\TaxonomySelector;use JVBase\managers\Cache;use JVBase\managers\IconsManager;use JVBase\managers\LoginManager;use JVBase\managers\RoleManager;use JVBase\meta\Form;use JVBase\meta\Meta;use JVBase\registrar\Fields;use JVBase\registrar\Registrar;use JVBase\ui\Navigation;use JVBase\ui\Tabs;
use JetBrains\PhpStorm\NoReturn;use JVBase\base\Site;use JVBase\forms\TaxonomySelector;use JVBase\Instance;use JVBase\integrations\ServiceMap;use JVBase\managers\Cache;use JVBase\managers\IconsManager;use JVBase\managers\Login;use JVBase\managers\Roles;use JVBase\meta\Form;use JVBase\meta\Meta;use JVBase\registrar\Fields;use JVBase\registrar\Registrar;use JVBase\ui\Navigation;use JVBase\ui\Tabs;
if (!defined('ABSPATH')) {
    exit;
}
class DashboardManager {
class Dashboard {
    use Instance, ServiceMap;
    public static string $based;
    protected string $slug = 'dash';
    protected string $baseURl;
@@ -14,9 +15,10 @@
    protected array $pages = [];
    protected array $sections = [];
    protected Cache $cache;
    public function __construct() {
    private function __construct() {
        self::$based = BASE.$this->slug;
        $this->cache = Cache::for('dashboard')->connect('post');
        $this->cache = Cache::for('dashboard', 'post');
        if (JVB_TESTING) {
            $this->cache->flush();
        }
@@ -289,7 +291,7 @@
                return;
            }
            //Redirect to custom dashboard
            if (is_user_logged_in() && isOurPeople()) {
            if (is_admin() && isOurPeople()) {
                $this->redirectToDashboard();
            }
        }
@@ -351,11 +353,11 @@
            foreach ($content as $c) {
                $registrar = Registrar::getInstance($c);
                $page = $this->addPage($registrar->getPlural(), $c, $registrar->getIcon());
                $page->setPermission(RoleManager::getPermissionName('edit', $c));
                $page->setPermission(Roles::getPermissionName('edit', $c));
                $page->setSection('content');
                $page->setScripts(['jvb-crud', 'jvb-creator']);
                $this->sections[$c] = new Section($registrar->getPlural(), $c, $registrar->getIcon(), 'content');
                $this->sections[$c]->setPermission(RoleManager::getPermissionName('edit', $c));
                $this->sections[$c]->setPermission(Roles::getPermissionName('edit', $c));
                $this->sections[$c]->setIsLink(true);
                $taxonomies = $registrar->registrar->taxonomies;
@@ -363,7 +365,7 @@
                    $taxReg = Registrar::getInstance($tax);
                    if ($taxReg) {
                        $taxPage = $this->addPage($taxReg->getPlural(), $tax, $registrar->getIcon(), $page->getID());
                        $taxPage->setPermission(RoleManager::getPermissionName('edit', $c));
                        $taxPage->setPermission(Roles::getPermissionName('edit', $c));
                        $taxPage->setSection($c);
                        $taxPage->setScripts(['jvb-crud', 'jvb-creator']);
@@ -376,7 +378,7 @@
            foreach ($contentTax as $c) {
                $registrar = Registrar::getInstance($c);
                $page = $this->addPage($registrar->getPlural(), $c, $registrar->getIcon());
                $page->setPermission(RoleManager::getPermissionName('edit', $c));
                $page->setPermission(Roles::getPermissionName('edit', $c));
                $page->setSection($c);
                //TODO: add artist management and invitations, if applicable
            }
@@ -511,18 +513,7 @@
                $this->sections['integrations']->setIsLink(true);
                foreach (array_keys(Site::getIntegrations()) as $integration) {
                    $integration = match($integration) {
                        'maps' => 'JVBase\integrations\services\GoogleMaps',
                        'square' => 'JVBase\integrations\services\Square',
                        'facebook' => 'JVBase\integrations\services\Facebook',
                        'helcim' => 'JVBase\integrations\services\Helcim',
                        'instagram' => 'JVBase\integrations\services\Instagram',
                        'bluesky' => 'JVBase\integrations\services\BlueSky',
                        'gmb' => 'JVBase\integrations\services\GoogleMyBusiness',
                        'cloudflare' => 'JVBase\integrations\services\Cloudflare',
                        'umami' => 'JVBase\integrations\services\Umami',
                        'postmark' => 'JVBase\integrations\services\PostMark',
                    };
                    $integration = $this->getService($integration);
                    $title = $integration::title();
                    $icon = $integration::icon();
                    if ($integration::hasExtraOptions()) {
@@ -765,7 +756,7 @@
            echo '<p>Alternatively, you can always login using the magic link, which sets a temporary password for 15 minutes.</p>';
        }
         ?>
        <?= LoginManager::getInstance()->renderLoginForm('resetpass'); ?>
        <?= Login::getInstance()->renderLoginForm('resetpass'); ?>
        <?php
    }
inc/managers/Dashboard/DashboardPage.php
@@ -47,7 +47,7 @@
            $this->slug.'_ID',
            function() use ($parentID) {
                $existing = new WP_Query([
                    'post_type' => DashboardManager::$based,
                    'post_type' => Dashboard::$based,
                    'name'      => $this->slug,
                    'fields'    => 'ids',
                    'posts_per_page'=> 1,
@@ -59,7 +59,7 @@
                $args = [
                    'post_title'    => $this->title,
                    'post_name'     => $this->slug,
                    'post_type'     => DashboardManager::$based,
                    'post_type'     => Dashboard::$based,
                    'post_status'   => 'publish'
                ];
                if ($parentID > 0) {
@@ -83,7 +83,7 @@
    {
        if ($this->slug === 'dash') {
            $this->URL = get_post_type_archive_link(DashboardManager::$based);
            $this->URL = get_post_type_archive_link(Dashboard::$based);
            return;
        }
        $this->URL = get_the_permalink($this->ID);
inc/managers/Dashboard/_setup.php
@@ -3,6 +3,6 @@
    exit;
}
require_once JVB_DIR . '/inc/managers/Dashboard/DashboardManager.php';
require_once JVB_DIR . '/inc/managers/Dashboard/Dashboard.php';
require_once JVB_DIR . '/inc/managers/Dashboard/DashboardPage.php';
require_once JVB_DIR . '/inc/managers/Dashboard/Section.php';
inc/managers/Dashboard/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/DashboardManager.php
@@ -3,7 +3,7 @@
use JetBrains\PhpStorm\NoReturn;use JVBase\forms\TaxonomySelector;
use JVBase\base\Site;
use JVBase\meta\Form;
use JVBase\integrations\ServiceMap;use JVBase\meta\Form;
use JVBase\registrar\Registrar;
use JVBase\ui\Navigation;
use WP_Error;use WP_Query;use WP_User;
@@ -17,6 +17,8 @@
 */
class DashboardManager
{
    use ServiceMap;
    protected WP_User $user;
    protected Cache $cache;
    protected string $role;
@@ -25,7 +27,7 @@
    public function __construct()
    {
        $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->connect('user')->user();
        $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->user();
        if (JVB_TESTING) {
            $this->cache->flush();
        }
@@ -49,7 +51,6 @@
    }
    public function excludeDashboard(array $IDs):array {
        $this->cache->flush();
        $exclude = $this->cache->remember(
            'dashboardIDs',
            function() {
@@ -297,11 +298,10 @@
     */
    protected function buildIntegrationPages(int $parentID):void
    {
        $integrations = JVB()->getAvailableServices(false);
        foreach ($integrations as $name => $integration) {
        $integrations = $this->getAvailableServices();
        foreach ($integrations as $service) {
            $integration = $this->getService($service);
            $title = $integration->getTitle();
            $slug = sanitize_title($title);
            $this->createDashboardPage($slug, $title, $parentID);
        }
@@ -360,7 +360,7 @@
        }
        $integrationSlugs = array_map(function($name) {
            return sanitize_title(str_replace('_', '-', $name));
        }, array_keys(JVB()->getAvailableServices(false)));
        }, array_keys($this->getAvailableServices()));
        // Check if this is an integration subpage
        if (in_array($page, $integrationSlugs)) {
@@ -747,7 +747,7 @@
            Registrar::withFeature('is_content', 'term')
        );
        $availableContent = array_filter($pages, function($page, $key) use($all) {
            return !is_numeric($key) && in_array($key, $all) && JVB()->roles()->checkRole($this->user, $key);
            return !is_numeric($key) && in_array($key, $all) && Roles::getInstance()()->checkRole($this->user, $key);
        }, ARRAY_FILTER_USE_BOTH);
        if (!empty ($availableContent)){
            $content = $menu->addItem('Your Content', jvbDashIcon('book-bookmark'))
@@ -871,7 +871,7 @@
        }
        if (Site::has('referrals')) {
            $whatever = JVB()->referrals()->getReferralWelcomeMessage($this->user->ID);
            $whatever = Referrals::getInstance()->getReferralWelcomeMessage($this->user->ID);
            if (!empty($whatever)) {
                return $whatever;
            }
@@ -933,18 +933,19 @@
    protected function getIntegrationsMenu():string
    {
        $integrations = JVB()->getAvailableServices(false);
        $integrations = $this->getAvailableServices();
        $out = '';
        if (!empty($integrations)) {
            $out = '<nav class="integrations"><ul>';
            $url = get_home_url(null, '/dash/integrations/');
            $out .= '<li><a href="'.$url.'">'.jvbDashIcon('plugs-connected').'Integrations</a></li>';
            foreach ($integrations as $name=> $integration) {
                if (!JVB()->userCanConnect($name, $this->user->ID) || !$integration->hasDefaults()) {
            foreach ($integrations as $service) {
                $integration = $this->getService($service);
                if (method_exists($integration, 'userCanConnect') && !$integration->userCanConnect($this->user->ID)) {
                    continue;
                }
                $link = sanitize_title(str_replace('_', '-',$name));
                $link = sanitize_title(str_replace('_', '-',$service));
                $out .= '<li><a href="'.$url.$link.'">'.jvbDashIcon($integration->icon).$integration->getTitle().'</a></li>';
            }
            $out .= '</ul></nav>';
@@ -971,15 +972,19 @@
            ?>
            <section class="item-grid integrations">
            <?php
            $all = JVB()->getAvailableServices();
            foreach ($all as $name) {
            foreach ($this->getAvailableServices() as $name) {
                if (current_user_can('manage_options')) {
                    $userID = null;
                } else {
                    $userID = $this->user->ID;
                }
                JVB()->connect($name, $userID)->renderConnection();
                $integration = $this->getService($name);
                if (method_exists($integration, 'determineUser')
                    && method_exists($integration, 'renderConnection')) {
                    $integration->determineUser();
                    $integration->renderConnection();
                }
            }
            ?>
            </section>
@@ -1352,7 +1357,7 @@
                    $membership = Site::membership();
                    $type  = $registrar? $registrar->getType() : false;
                    if ($type) {
                        $permission = JVB()->roles()->getPermission('edit', $key);
                        $permission = Roles::getInstance()()->getPermission('edit', $key);
                    }
                    switch ($type) {
                        case 'content':
@@ -1362,7 +1367,7 @@
                            break;
                        case 'taxonomy':
                            $registrar = Registrar::getInstance($key);
                            if ($registrar && $registrar->hasFeature('is_content') && (!empty(JVB()->roles()->getOwnedTerms($userID, $key)) || !empty(JVB()->roles()->getManagedTerms($userID, $key)))){
                            if ($registrar && $registrar->hasFeature('is_content') && (!empty(Roles::getInstance()()->getOwnedTerms($userID, $key)) || !empty(Roles::getInstance()()->getManagedTerms($userID, $key)))){
                                $remove = false;
                            } else if (count(array_intersect($registrar->registrar->for, array_keys($pages))) > 0) {
                                $remove = false;
inc/managers/DirectoryManager.php
@@ -5,6 +5,7 @@
    exit;
}
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_Block;
@@ -12,6 +13,7 @@
class DirectoryManager
{
    use Instance;
    protected array $directories;
    protected array $directoryPageIDs;
    protected array $directoryList;
@@ -20,7 +22,7 @@
    protected static string $slug = BASE.'for_type_slug';
    protected Cache $cache;
    public function __construct($perPage = 100)
    private function __construct($perPage = 100)
    {
        $this->directories = $this->getDirectories();
        if (empty($this->directories)) {
@@ -28,9 +30,6 @@
        }
        $this->perPage = $perPage;
        $this->cache = Cache::for('directory', WEEK_IN_SECONDS);
        $this->cache->connect('post', true)
            ->connect('taxonomy', true)
            ->connect('user', true);
        if (JVB_TESTING) {
            $this->cache->flush();
@@ -39,8 +38,16 @@
        jvb_register_do_once('buildDirectories', [$this, 'activate']);
        add_action('init', [$this, 'registerDirectories']);
        add_filter('pre_render_block', [$this, 'renderBlock'], 20, 3);
        add_action('save_post', [$this, 'flushCache']);
        add_action('edit_terms', [$this, 'flushCache']);
    }
    public function flushPostCache(int $postID):void
    {
        //TODO: We can probably use a bit more finesse here.
        $this->cache->flush();
    }
    public function registerDirectories():void
    {
inc/managers/Emails.php
File was renamed from inc/managers/EmailManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use WP_User;
/**
@@ -21,9 +22,9 @@
 * Hooks into wp_mail to ensure a consistent look in our automatic emails
 */
class EmailManager
class Emails
{
    use Instance;
   public array $colours;
   private string $title = JVB_EMAIL['content']['title'];
   private string $prefix = JVB_EMAIL['content']['subjectPrefix'];
inc/managers/Errors.php
File was renamed from inc/managers/ErrorHandler.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\Instance;
use WP_Error;
use Exception;
use WP_REST_Request;
@@ -10,8 +10,9 @@
    exit; // Exit if accessed directly
}
class ErrorHandler
class Errors
{
    use Instance;
    protected int $notification_threshold = 5; // Critical errors within 1 hour
    protected array $error_levels = [
@@ -24,7 +25,7 @@
    protected CustomTable $table;
    public function __construct()
    private function __construct()
    {
        $this->defineTables();
//        global $wpdb;
@@ -71,7 +72,7 @@
    }
    public function registerAdminAction():void
    {
        $admin = JVB()->admin();
        $admin = AdminPages::getInstance();
        $admin->registerSubpage(
            'Error Log',
@@ -265,25 +266,25 @@
        $admin_email = get_option('admin_email');
        $subject = "[" . get_bloginfo('name') . " Critical Error] {$component}";
        $body = JVB()->email()->alert(
        $body = Emails::getInstance()->alert(
            'A critical error has occurred and requires immediate attention',
            'error'
        );
        $body .= JVB()->email()->h2('Error Details');
        $body .= JVB()->email()->card(
        $body .= Emails::getInstance()->h2('Error Details');
        $body .= Emails::getInstance()->card(
            '<p><strong>Component:</strong> ' . esc_html($component) . '</p>' .
            '<p><strong>Message:</strong></p>' .
            JVB()->email()->codeBlock($message),
            Emails::getInstance()->codeBlock($message),
            'Error Information'
        );
        if (!empty($context)) {
            $body .= JVB()->email()->h3('Additional Context');
            $body .= JVB()->email()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
            $body .= Emails::getInstance()->h3('Additional Context');
            $body .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
        }
        return JVB()->email()->sendEmail($admin_email, $subject, $body, 'CRITICAL ERROR');
        return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'CRITICAL ERROR');
    }
    /**
@@ -332,77 +333,77 @@
        $subject = "[{$site_name}] Daily Error Summary - " . date('Y-m-d');
        // Header with alert
        $body = JVB()->email()->h1('Daily Error Summary');
        $body = Emails::getInstance()->h1('Daily Error Summary');
        $body .= sprintf('<p>Error summary for <strong>%s</strong></p>', $yesterday);
        // Summary stats in a grid
        if (!empty($summary['stats'])) {
            $stats = [
                JVB()->email()->stat($summary['stats']->total_errors, 'Total Errors'),
                JVB()->email()->stat($summary['stats']->critical_count, 'Critical', 'Requires attention'),
                JVB()->email()->stat($summary['stats']->error_count, 'Errors'),
                JVB()->email()->stat($summary['stats']->warning_count, 'Warnings')
                Emails::getInstance()->stat($summary['stats']->total_errors, 'Total Errors'),
                Emails::getInstance()->stat($summary['stats']->critical_count, 'Critical', 'Requires attention'),
                Emails::getInstance()->stat($summary['stats']->error_count, 'Errors'),
                Emails::getInstance()->stat($summary['stats']->warning_count, 'Warnings')
            ];
            $body .= JVB()->email()->grid($stats, 4);
            $body .= Emails::getInstance()->grid($stats, 4);
        }
        // Alert if critical errors exist
        if (!empty($summary['critical'])) {
            $body .= JVB()->email()->alert(
            $body .= Emails::getInstance()->alert(
                sprintf('Found %d critical errors that need immediate attention', count($summary['critical'])),
                'error'
            );
        }
        $body .= JVB()->email()->spacer(20);
        $body .= Emails::getInstance()->spacer(20);
        // Frequent errors section
        if (!empty($summary['frequent'])) {
            $body .= JVB()->email()->h2('Most Frequent Errors');
            $body .= Emails::getInstance()->h2('Most Frequent Errors');
            foreach ($summary['frequent'] as $error) {
                $cardContent = JVB()->email()->badge($error->count . 'x', 'warning') . ' ';
                $cardContent = Emails::getInstance()->badge($error->count . 'x', 'warning') . ' ';
                $cardContent .= '<strong>' . esc_html($error->error_type) . '</strong>';
                $cardContent .= '<p style="margin:10px 0 5px 0;font-size:13px;">' . esc_html(wp_trim_words($error->message, 15)) . '</p>';
                $cardContent .= '<p style="margin:0;font-size:12px;color:' . JVB()->email()->colours['dark-200'] . ';">
                $cardContent .= '<p style="margin:0;font-size:12px;color:' . Emails::getInstance()->colours['dark-200'] . ';">
                Source: ' . esc_html($error->source) . ' |
                Logged in: ' . $error->logged_in_count . ' |
                Logged out: ' . $error->logged_out_count . '
            </p>';
                $body .= JVB()->email()->card($cardContent, $error->component);
                $body .= Emails::getInstance()->card($cardContent, $error->component);
            }
        }
        // Critical errors section
        if (!empty($summary['critical'])) {
            $body .= JVB()->email()->spacer(30);
            $body .= JVB()->email()->h2('Recent Critical Errors');
            $body .= Emails::getInstance()->spacer(30);
            $body .= Emails::getInstance()->h2('Recent Critical Errors');
            foreach ($summary['critical'] as $error) {
                $cardContent = '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>';
                $cardContent .= '<p><strong>Message:</strong></p>';
                $cardContent .= JVB()->email()->codeBlock($error->message);
                $cardContent .= Emails::getInstance()->codeBlock($error->message);
                // Include context if available
                if (!empty($error->context)) {
                    $context = json_decode($error->context, true);
                    if (is_array($context)) {
                        $cardContent .= '<p><strong>Context:</strong></p>';
                        $cardContent .= JVB()->email()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
                        $cardContent .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
                    }
                }
                $body .= JVB()->email()->card($cardContent, $error->component . ': ' . $error->error_type);
                $body .= Emails::getInstance()->card($cardContent, $error->component . ': ' . $error->error_type);
            }
        }
        // Dashboard link
        $admin_url = admin_url('admin.php?page=jvb-error-logs');
        $body .= JVB()->email()->spacer(30);
        $body .= JVB()->email()->button($admin_url, 'View Detailed Logs');
        $body .= Emails::getInstance()->spacer(30);
        $body .= Emails::getInstance()->button($admin_url, 'View Detailed Logs');
        return JVB()->email()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY');
        return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY');
    }
    /**
inc/managers/Favourites.php
File was renamed from inc/managers/FavouritesManager.php
@@ -2,22 +2,27 @@
namespace JVBase\managers;
use Exception;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\routes\FavouritesRoutes;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class FavouritesManager
class Favourites
{
    private Cache $cache;
    use Instance;
    protected CustomTable $favourites;
    protected CustomTable $lists;
    protected CustomTable $listItems;
    protected CustomTable $listShares;
    protected Rest $route;
    public function __construct()
    {
        $this->route = FavouritesRoutes::getInstance();
        $this->defineTables();
        $this->registerHooks();
    }
@@ -28,6 +33,11 @@
        add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
        add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
        add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
        //adds favourite ordering to wp_query/tax_query
        add_filter('posts_join', [$this, 'maybeJoinFavouriteOrderby'], 10, 2);
        add_filter('posts_orderby', [$this, 'maybeApplyFavouriteOrderby'], 10, 2);
        add_filter('terms_clauses', [$this, 'maybeApplyFavouriteTermOrderby'], 10, 3);
    }
    protected function defineTables():void
    {
@@ -210,7 +220,7 @@
            );
            if ($owner) {
                JVB()->notification()->addNotification(
                NotificationManager::getInstance()->addNotification(
                    $owner,
                    'new_favourite',
                    $user_id,
@@ -229,10 +239,10 @@
            ]);
            if (in_array($registerType, ['term', 'user'])) {
                JVB()->notification()->deleteUserPreference($user_id, $target_id, $type);
                NotificationManager::getInstance()->deleteUserPreference($user_id, $target_id, $type);
            }
            if ($owner) {
                JVB()->notification()->removeNotification(
                NotificationManager::getInstance()->removeNotification(
                    $owner,
                    [
                        'from_user' => $user_id,
@@ -254,6 +264,45 @@
            return $this->toggleFavourite(false, $user_id, $target_id, $type);
        }
        public function hasFavourited(int $user_id, int $target_id, string $type):bool
        {
            return (bool) $this->favourites->get([
                'user_id'   => $user_id,
                'target_id' => $target_id,
                'type'      => $type
            ]);
        }
        public function batchToggleFavourites(int $user_id, array $items):array
        {
            $items = array_filter($items, function ($item) {
                $keys = ['target_id', 'type', 'action'];
                foreach ($keys as $key) {
                    if (!array_key_exists($key, $item)) {
                        return false;
                    }
                    if (empty($item[$key])) {
                        return false;
                    }
                }
                if (!in_array($item['action'], ['add', 'remove'])) {
                    return false;
                }
                return true;
            });
            if (empty($items)) {
                return [];
            }
            $results = [];
            foreach ($items as $item) {
                $results[$item['target_id']] =$this->toggleFavourite($item['action'] === 'add', $user_id, $item['target_id'], $item['type']);
            }
            return $results;
        }
        public function addNoteFromTarget(int $user_id, int $target_id, string $type, string $note):bool
        {
            $favourite = $this->favourites->get([
@@ -378,7 +427,7 @@
            $args = [];
            if ($shareWithUser) {
                $args['user_id'] = $shareWithUser;
                JVB()->notification()->addNotification($shareWithUser, 'list_shared', $user_id, '', $listId);
                NotificationManager::getInstance()->addNotification($shareWithUser, 'list_shared', $user_id, '', $listId);
            } elseif ($newUser) {
                $email = sanitize_email($newUser['email']);
                $name = sanitize_text_field($newUser['name']);
@@ -408,7 +457,7 @@
            if (is_int($userIDorEmail)) {
                $where['user_id'] = $userIDorEmail;
                $notification = JVB()->notification()->table();
                $notification = NotificationManager::getInstance()->table();
                $n = $notification->get([
                    'for_user' => $userIDorEmail,
                    'from_user' => $user_id,
@@ -455,7 +504,7 @@
                    $message = $accept ? 'accepted' : 'rejected';
                    $message = $user->first_name . ' ' . $message . ' your list share invitation.';
                    JVB()->notification()->addNotification($list->user_id, 'list_share_status', $userIDorEmail, $message);
                    NotificationManager::getInstance()->addNotification($list->user_id, 'list_share_status', $userIDorEmail, $message);
                }
            }else if (is_email($userIDorEmail)) {
                $args['email'] = sanitize_email($userIDorEmail);
@@ -517,7 +566,7 @@
            public function getListDetails(int $list_id, int $user_id, int $page = 1):array
            {
                $list = JVB()->favourites()->getLists(['id' => $list_id, 'user_id' => $user_id]);
                $list = Favourites::getInstance()->getLists(['id' => $list_id, 'user_id' => $user_id]);
                if (!$list) {
                    return [];
                }
@@ -792,4 +841,55 @@
            ]);
        }
    }
    /**
     * Join favourite counts for WP_Query when orderby => 'favourite_count'
     */
    public function maybeJoinFavouriteOrderby(string $join, \WP_Query $query): string
    {
        if ($query->get('orderby') !== 'favourites_count') {
            return $join;
        }
        global $wpdb;
        $table = $this->favourites->getFullTableName();
        $type = $query->get('post_type') ?: 'post';
        $join .= $wpdb->prepare(
            " LEFT JOIN (SELECT target_id, COUNT(*) as fav_count FROM {$table} WHERE type = %s GROUP BY target_id) jvb_fav_counts ON jvb_fav_counts.target_id = {$wpdb->posts}.ID",
            $type
        );
        return $join;
    }
    public function maybeApplyFavouriteOrderby(string $orderby, \WP_Query $query): string
    {
        if ($query->get('orderby') !== 'favourites_count') {
            return $orderby;
        }
        $order = strtoupper($query->get('order')) === 'ASC' ? 'ASC' : 'DESC';
        return "COALESCE(jvb_fav_counts.fav_count, 0) {$order}";
    }
    /**
     * Join + order for WP_Term_Query when orderby => 'favourite_count'
     */
    public function maybeApplyFavouriteTermOrderby(array $clauses, array $taxonomies, array $args): array
    {
        if (($args['orderby'] ?? '') !== 'favourites_count') {
            return $clauses;
        }
        global $wpdb;
        $table = $this->favourites->getFullTableName();
        $type = jvbCheckBase($taxonomies[0] ?? '');
        $clauses['join'] .= $wpdb->prepare(
            " LEFT JOIN (SELECT target_id, COUNT(*) as fav_count FROM {$table} WHERE type = %s GROUP BY target_id) jvb_fav_counts ON jvb_fav_counts.target_id = t.term_id",
            $type
        );
        $order = strtoupper($args['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
        $clauses['orderby'] = "ORDER BY COALESCE(jvb_fav_counts.fav_count, 0)";
        $clauses['order'] = $order;
        return $clauses;
    }
}
inc/managers/IconsManager.php
@@ -43,7 +43,7 @@
    private function __construct(string $source)
    {
        $this->source = $source;
        $this->cache = Cache::for('icons_' . $source, WEEK_IN_SECONDS);
        $this->cache = Cache::for('icons_' . $source);
        $this->style = Site::icon();
        $this->addMap();
@@ -448,7 +448,7 @@
            $instance->archiveCurrentVersion($css);
            if (file_put_contents($css_path, $css) !== false) {
                Cache::touch('icons_' . $source);
                Cache::for('icons_'.$source)->flush();
                error_log("[IconsManager] Updated {$source}.css");
            } else {
                error_log("[IconsManager] Could not write {$source}.css");
@@ -486,7 +486,7 @@
            $instance->archiveCurrentVersion($css);
            if (file_put_contents($css_path, $css) !== false) {
                Cache::touch('icons_' . $source);
                Cache::for('icons_'.$source)->flush();
                error_log("[IconsManager] Updated {$source}.css");
            } else {
                error_log("[IconsManager] Could not write {$source}.css");
@@ -649,7 +649,7 @@
    public function registerStyle(): void
    {
        $timestamp = Cache::lastModified('icons_' . $this->source);
        $timestamp = Cache::for('icons_'.$this->source)->getTimestamp();
        $handle = 'jvb-icons-' . $this->source;
        wp_register_style(
@@ -803,7 +803,7 @@
                if (file_put_contents($css_path, $entry['css']) !== false) {
                    $this->icons = $entry['iconList'];
                    $this->saveIcons();
                    Cache::touch('icons_' . $this->source);
                    Cache::for('icons_'.$this->source)->flush();
                    return true;
                }
@@ -824,7 +824,8 @@
        }
        $needsUpdate[$this->source] = true;
        update_option(BASE.'icons_needs_update', $needsUpdate);
        Cache::touch('icons_' . $this->source);
        Cache::for('icons_'.$this->source)->flush();
    }
    public function mergeVersions(array $timestamps): bool
inc/managers/ImageGenerator.php
@@ -21,6 +21,7 @@
 *
 * Generates artist feature cards as webp images with artist information
 * Uses Imagick with GD fallback and leverages FileUploadManager storage
 * TODO: Integrate better with UploadManager.php
 */
class ImageGenerator
{
@@ -146,7 +147,7 @@
        // Get directory structure from FileUploadManager if available
        if ($this->fileManager) {
            $user_dir = $this->fileManager->getUploadDirectory();
            $user_dir = $this->fileManager->generateDirectory('image', $this->data);
            $relative_dir = str_replace($base_dir, '', dirname($file_path));
            $destination_dir = $base_dir . '/' . $relative_dir;
        } else {
@@ -252,7 +253,7 @@
            $user_data = get_userdata($this->data['user_id']);
            $this->outputFilename = $this->fileManager->generateFilename(
                $title.'.webp',
                $user_data
                $this->data
            );
        } else {
            // Fallback filename generation
inc/managers/InvitationsManager.php
@@ -2,10 +2,13 @@
namespace JVBase\managers;
use Exception;
use JVBase\Instance;
use JVBase\managers\queue\executors\InvitationExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use JVBase\rest\Rest;
use JVBase\rest\routes\Invitations;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -14,16 +17,22 @@
class InvitationsManager
{
    use Instance;
    protected array $inviteConfig;
    protected CustomTable $table;
    protected int $expiryDays = 14;
    protected Cache $cache;
    public function __construct()
    protected Rest $route;
    private function __construct()
    {
        $this->defineTable();
        if (!isset($this->table)) {
            return;
        }
        if (Site::membership() && Site::membership()->has('can_invite')) {
            $this->route = Invitations::getInstance();
        }
        $this->setInviteConfig();
        $this->cache = Cache::for('invitations')->user();
        add_action('init', [$this, 'registerInvitationExecutors'], 5);
@@ -118,7 +127,7 @@
     */
    public function registerInvitationExecutors(): void
    {
        $registry = JVB()->queue()->registry();
        $registry = Queue::getInstance()->registry();
        $executor = new InvitationExecutor();
        // Create invitations - chunked at 20
@@ -214,7 +223,7 @@
        }
        // User must be owner or manager of the term
        return JVB()->roles()->isManager($userID, $termID);
        return Roles::getInstance()()->isManager($userID, $termID);
    }
    /**
@@ -554,9 +563,9 @@
        }
        $termText = implode('', $termContent);
        $button = JVB()->email()->button($signupUrl, 'Join the Scene!');
        $link = JVB()->email()->link($signupUrl);
        $signature = JVB()->email()->signature();
        $button = Emails::getInstance()->button($signupUrl, 'Join the Scene!');
        $link = Emails::getInstance()->link($signupUrl);
        $signature = Emails::getInstance()->signature();
        $message = sprintf(
            '<p>Hi %s!</p>
@@ -593,7 +602,7 @@
            $signature
        );
        JVB()->email()->sendEmail($email, $subject, $message);
        Emails::getInstance()->sendEmail($email, $subject, $message);
    }
    /**
@@ -671,7 +680,7 @@
        $this->notifyInvitersOfAcceptance($invitation, $userID);
        // Invalidate cache
        Cache::for('invitations')->forget('user_' . $userID);
        $this->cache->forget('user_' . $userID);
        return true;
    }
@@ -681,7 +690,7 @@
     */
    protected function processTermMembership(int $userID, object $invitation): void
    {
        foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
        foreach (Roles::getInstance()()->getInvitableTaxonomies() as $taxonomy) {
            $column = 'to_' . $taxonomy;
            if (isset($invitation->$column) && $invitation->$column) {
                $termID = (int) $invitation->$column;
@@ -706,7 +715,7 @@
        $userData = get_userdata($userID);
        foreach ($inviters as $inviter) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $inviter['user_id'],
                'invitation_accepted',
                [
@@ -732,7 +741,7 @@
        );
        // Clear cache after cleanup
        Cache::for('invitations')->flush();
        $this->cache->flush();
    }
    /*****************************************************************
@@ -760,7 +769,7 @@
            $name
        );
        JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
        Emails::getInstance()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
    }
    /**
inc/managers/KarmaManager.php
@@ -2,6 +2,10 @@
namespace JVBase\managers;
use Exception;
use JVBase\managers\queue\Queue;
use JVBase\rest\Rest;
use JVBase\rest\routes\ApprovalRoutes;
use JVBase\rest\routes\VoteRoutes;
use WP_Error;
use WP_Post;
use WP_Term;
@@ -23,15 +27,18 @@
    protected string $references;
    protected CustomTable $table;
    protected static Cache $cache;
    protected Rest $route;
    private function __construct(string $key, ?string $references = null)
    {
        $this->route = VoteRoutes::getInstance();
        $this->key = $key;
        if (is_null($references)) {
            $references = match($key){
                'post'  => 'post',
                'term'  => 'term',
                'user'  => 'user',
                'post', 'posts' => 'post',
                'term','terms', 'taxonomy'  => 'term',
                'user', 'users' => 'user',
                'response', 'responses' => 'response',
                default => false
            };
            if (!$references) {
@@ -60,12 +67,98 @@
//              break;
//      }
        if (!isset(self::$cache)) {
            self::$cache = Cache::for('user_karma')->connect('user')->user();
            self::$cache = Cache::for('user_karma')->user();
        }
        $this->registerHooks();
    }
    protected function registerHooks():void
    {
        if ($this->references === 'post') {
            add_filter('posts_join', [$this, 'maybeJoinKarmaOrderby'], 10, 2);
            add_filter('posts_orderby', [$this, 'maybeApplyKarmaOrderby'], 10, 2);
        } elseif ($this->references === 'term') {
            add_filter('terms_clauses', [$this, 'maybeApplyKarmaTermOrderby'], 10, 3);
        }
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
    }
    /**
     * Supported orderby keys: 'karma_upvotes', 'karma_downvotes', 'karma_score'
     */
    protected function karmaOrderExpr(string $orderby, string $alias = 'jvb_karma'): ?string
    {
        return match ($orderby) {
            'upvotes'   => "COALESCE({$alias}.upvotes, 0)",
            'downvotes' => "COALESCE({$alias}.downvotes, 0)",
            'karmic_score'     => "COALESCE({$alias}.upvotes, 0) - COALESCE({$alias}.downvotes, 0)",
            default           => null,
        };
    }
        public function maybeJoinKarmaOrderby(string $join, \WP_Query $query): string
        {
            if (!in_array($query->get('orderby'), ['upvotes', 'downvotes', 'karmic_score'])) {
                return $join;
            }
            global $wpdb;
            $table = $this->table->getFullTableName();
            $content = $query->get('post_type') ?: 'post';
            $join .= $wpdb->prepare(
                " LEFT JOIN (SELECT item_id,
                SUM(vote = 'up') as upvotes,
                SUM(vote = 'down') as downvotes
                FROM {$table} WHERE content = %s GROUP BY item_id) jvb_karma
                ON jvb_karma.item_id = {$wpdb->posts}.ID",
                $content
            );
            return $join;
        }
        public function maybeApplyKarmaOrderby(string $orderby, \WP_Query $query): string
        {
            $expr = $this->karmaOrderExpr($query->get('orderby'));
            if (!$expr) {
                return $orderby;
            }
            $order = strtoupper($query->get('order')) === 'ASC' ? 'ASC' : 'DESC';
            return "{$expr} {$order}";
        }
        public function maybeApplyKarmaTermOrderby(array $clauses, array $taxonomies, array $args): array
        {
            $expr = $this->karmaOrderExpr($args['orderby'] ?? '');
            if (!$expr) {
                return $clauses;
            }
            global $wpdb;
            $table = $this->table->getFullTableName();
            $content = jvbNoBase($taxonomies[0] ?? '');
            $clauses['join'] .= $wpdb->prepare(
                " LEFT JOIN (SELECT item_id,
                SUM(vote = 'up') as upvotes,
                SUM(vote = 'down') as downvotes
                FROM {$table} WHERE content = %s GROUP BY item_id) jvb_karma
                ON jvb_karma.item_id = t.term_id",
                $content
            );
            $clauses['orderby'] = "ORDER BY {$expr}";
            $clauses['order'] = strtoupper($args['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
            return $clauses;
        }
    public function karmaTable():CustomTable
    {
        return $this->table;
    }
    public static function getInstance(string $key):KarmaManager|false
    {
        if (!isset(self::$instances[$key])) {
@@ -78,7 +171,7 @@
    {
        $key = sanitize_key($key);
        if (!isset(self::$instances[$key])) {
            self::$instances[$key] = new KarmaManager($key, $references);
            self::$instances[$key] = new self($key, $references);
        }
        return self::$instances[$key];
@@ -175,7 +268,7 @@
            'post'  => get_post($itemID),
            'term'  => get_term($itemID, $based),
            'user'  => get_userdata($itemID),
            'response' => JVB()->responses()->response->get(['id' => $itemID])??false,
            'response' => Responses::getInstance()->responseExists($itemID),
            default => false    //TODO if we do have custom tables, we'll have to set that up here
        };
    }
@@ -221,7 +314,7 @@
        }
        self::$cache->forget($userID);
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'karmic',
            0,
            [
@@ -292,16 +385,7 @@
                        update_user_meta($item['itemID'], BASE.'karma', $karma);
                        break;
                    case 'response':
                        JVB()->responses()->response->update(
                            [
                                'upvotes'   => $upvotes,
                                'downvotes' => $downvotes,
                                'karma'     => $karma
                            ],
                            [
                                'id'    => $item['itemID']
                            ]
                        );
                        Responses::getInstance()->updateKarma($item['itemID'], $upvotes, $downvotes, $karma);
                        break;
                }
                $results[$item['itemID']] = true;
inc/managers/Login.php
File was renamed from inc/managers/LoginManager.php
@@ -3,17 +3,19 @@
use JVBase\base\Site;
use JVBase\forms\TaxonomySelector;
use JVBase\meta\Form;
use JVBase\Instance;use JVBase\integrations\services\Cloudflare;use JVBase\meta\Form;
use JVBase\registrar\Registrar;use WP_Error;
use JVBase\registrar\Registrar;use JVBase\rest\routes\LoginRoutes;use WP_Error;
use WP_User;
if (!defined('ABSPATH')) {
    exit;
}
class LoginManager
class Login
{
    use Instance;
    protected ?Form $form = null;
    protected Cache $cache;
@@ -23,7 +25,6 @@
    protected array $fields = [];
    protected ?string $action = null;
    protected string $title = '';
    protected static LoginManager $instance;
    // Token handlers registry
    protected array $messageHandlers = [];
@@ -36,12 +37,13 @@
    ];
    private int $max_file_size = 5242880; // 5MB in bytes
    private LoginRoutes $routes;
    public function __construct()
    protected function __construct()
    {
        self::$instance = $this;
        $this->cache = Cache::for('login');
        $this->cache->flush();
        $this->routes = new LoginRoutes();
//      $this->cache = Cache::for('login');
//      $this->cache->flush();
        // Initialize magic link support if enabled
        if (Site::has('magic_link')) {
            $this->initMagicLinkSupport();
@@ -68,10 +70,6 @@
        add_action('user_register', [$this, 'saveRegistrationFields'], 50, 2);
        add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeLoginSitemap'], 8, 1);
    }
    public static function getInstance():self
    {
        return self::$instance;
    }
    public function excludeLoginSitemap(array $ids): array
    {
@@ -378,15 +376,7 @@
            exit;
        }
        $this->setup();
        $page = $this->cache->remember(
                $this->getAction(),
                function() {
                    return $this->renderPage();
                },
                5
            );
        echo $page;
        echo $this->renderPage();
        return '';
    }
    protected function renderPage() {
@@ -789,12 +779,12 @@
        if (!Site::hasIntegration('cloudflare')) {
            return;
        }
        JVB()->connect('cloudflare')->enqueueTurnstileScripts();
        Cloudflare::getInstance()->enqueueTurnstileScripts();
    }
    protected function verifyTurnstile(): bool
    {
        if (!Site::hasIntegration('cloudflare')) {
        if (!Site::hasIntegration('cloudflare') || Cloudflare::getInstance()->isSetUp()) {
            return true; // Not enabled, pass verification
        }
@@ -803,7 +793,7 @@
            return false;
        }
        return JVB()->connect('cloudflare')->verifyTurnstile($token);
        return Cloudflare::getInstance()->verifyTurnstile($token);
    }
    /************************************************************************
@@ -865,7 +855,7 @@
    protected function maybeMagicLink(): void
    {
        if (!JVB()->magicLink() || !in_array($this->action, ['login', 'lostpassword'])) {
        if (!MagicLinks::getInstance() || !in_array($this->action, ['login', 'lostpassword'])) {
            return;
        }
        ?>
@@ -1037,5 +1027,3 @@
    }
}
// Initialize the login manager
new LoginManager();
inc/managers/MagicLinks.php
File was renamed from inc/managers/MagicLinkManager.php
@@ -3,6 +3,8 @@
use JetBrains\PhpStorm\NoReturn;
use JVBase\base\Site;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\meta\Form;
use JVBase\ui\Tabs;
use WP_User;
@@ -18,10 +20,12 @@
 * NOTE: Login form integration is now handled by LoginManager.php
 * This class focuses solely on magic link generation and verification
 */
class MagicLinkManager
class MagicLinks
{
    use Instance;
    protected Cache $cache;
    protected Cache $referral_cache;
    protected Cache $rateLimitCache;
    // Token settings
    protected int $token_expiry = 900; // 15 minutes in seconds
@@ -36,8 +40,9 @@
    public function __construct()
    {
        $this->cache = Cache::for('magic_links', $this->token_expiry);
        $this->referral_cache = Cache::for('referral_magic_links', 14 * DAY_IN_SECONDS);
        $this->cache = Cache::for('magic_links',null, $this->token_expiry);
        $this->referral_cache = Cache::for('referral_magic_links', null, 14 * DAY_IN_SECONDS);
        $this->rateLimitCache = Cache::for('magic_link_rate_limit', null, $this->rate_limit_window);
        // Hook into WordPress auth flow
        add_action('template_redirect', [$this, 'handleMagicLinkClick']);
@@ -145,20 +150,15 @@
     */
    protected function checkRateLimit(string $email): bool|WP_Error
    {
        $cache_key = 'rate_limit_' . md5($email);
        $attempts = $this->cache->get($cache_key);
        $key = md5($email);
        $attempts = $this->rateLimitCache->get($key);
        if (!$attempts) {
            $attempts = ['count' => 0, 'timestamp' => time()];
        }
        // Reset counter if window has passed
        if (time() - $attempts['timestamp'] > $this->rate_limit_window) {
            $attempts = ['count' => 0, 'timestamp' => time()];
            $attempts = 1;
        }
        // Check if limit exceeded
        if ($attempts['count'] >= $this->max_attempts_per_hour) {
        if ($attempts >= $this->max_attempts_per_hour) {
            return new WP_Error(
                'rate_limit_exceeded',
                'Too many magic link requests. Please try again in an hour.'
@@ -166,8 +166,8 @@
        }
        // Increment counter
        $attempts['count']++;
        $this->cache->set($cache_key, $attempts, $this->rate_limit_window);
        $attempts++;
        $this->rateLimitCache->set($key, $attempts);
        return true;
    }
@@ -200,7 +200,7 @@
        $subject = 'Sign in to ' . get_bloginfo('name');
        $message = $this->getLoginEmailTemplate($user->display_name, $magic_url);
        $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Log in to '. get_bloginfo('name'));
        $sent = Emails::getInstance()->sendEmail($email, $subject, $message, 'Log in to '. get_bloginfo('name'));
        return $sent ? true : new WP_Error('email_failed', 'Failed to send magic link');
    }
@@ -232,7 +232,7 @@
        $subject = 'Complete your ' . get_bloginfo('name') . ' registration';
        $message = $this->getSignupEmailTemplate($context['name'] ?? '', $magic_url);
        $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Complete Registration');
        $sent = Emails::getInstance()->sendEmail($email, $subject, $message, 'Complete Registration');
        return $sent ? true : new WP_Error('email_failed', 'Failed to send signup link');
    }
@@ -267,7 +267,7 @@
        $subject = (array_key_exists('subject', $context) && $context['subject'] !== '') ? $context['subject'] : $referrer_name . ' invited you to join ' . get_bloginfo('name');
        $message = $this->getReferralEmailTemplate($context['name'] ?? '', $referrer_name, $magic_url, $reward_text, $context);
        $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Accept Invitation');
        $sent = Emails::getInstance()->sendEmail($email, $subject, $message, 'Accept Invitation');
        return $sent ? true : new WP_Error('email_failed', 'Failed to send referral link');
    }
@@ -295,7 +295,7 @@
        $subject = 'Reset your password';
        $message = $this->getResetEmailTemplate($user->display_name, $magic_url);
        $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Reset Password');
        $sent = Emails::getInstance()->sendEmail($email, $subject, $message, 'Reset Password');
        return $sent ? true : new WP_Error('email_failed', 'Failed to send reset link');
    }
@@ -436,7 +436,7 @@
            $this->processSignup($token_data);
        }
        $role = JVB()->referrals()->getRole();
        $role = Referrals::getInstance()->getRole();
        $pass = wp_generate_password(20, true, true);
        $name = sanitize_text_field($token_data['name']);
        $user_id = wp_insert_user([
@@ -515,31 +515,31 @@
    protected function getLoginEmailTemplate(string $name, string $magic_url): string
    {
        $content = JVB()->email()->h2('Hey ' . esc_html($name) . '!');
        $content = Emails::getInstance()->h2('Hey ' . esc_html($name) . '!');
        $content .= '<p>Click the button below to sign in to your account instantly - no password needed!</p>';
        $content .= JVB()->email()->button($magic_url, 'Sign In Now');
        $content .= Emails::getInstance()->button($magic_url, 'Sign In Now');
        $content .= '<p>Or copy and paste this link into your browser:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= JVB()->email()->divider();
        $content .= Emails::getInstance()->link($magic_url);
        $content .= Emails::getInstance()->divider();
        $content .= '<p>If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
        return $content;
    }
    protected function getSignupEmailTemplate(string $name, string $magic_url): string
    {
        $content = JVB()->email()->h2('Welcome' . ($name ? ', ' . esc_html($name) : '') . '!');
        $content = Emails::getInstance()->h2('Welcome' . ($name ? ', ' . esc_html($name) : '') . '!');
        $content .= '<p>Click the button below to complete your registration and access your account!</p>';
        $content .= JVB()->email()->button($magic_url, 'Complete Registration');
        $content .= Emails::getInstance()->button($magic_url, 'Complete Registration');
        $content .= '<p>Or copy and paste this link:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= JVB()->email()->spacer(10);
        $content .= Emails::getInstance()->link($magic_url);
        $content .= Emails::getInstance()->spacer(10);
        $content .= '<p><small>This link expires in 24 hours.</small></p>';
        return $content;
    }
    protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text, array $context): string
    {
        $content = JVB()->email()->h2('Hey' . ($name ? ' ' . esc_html($name) : '') . '!');
        $content = Emails::getInstance()->h2('Hey' . ($name ? ' ' . esc_html($name) : '') . '!');
        $content .= sprintf(
            '<p><strong>%s</strong> thinks you\'d love %s!</p>',
            esc_html($referrer_name),
@@ -547,20 +547,20 @@
        );
        if (!empty($context['message'])) {
            $content .= JVB()->email()->callout(nl2br(esc_html($context['message'])));
            $content .= Emails::getInstance()->callout(nl2br(esc_html($context['message'])));
        }
        if ($reward_text) {
            $content .= JVB()->email()->alert(
            $content .= Emails::getInstance()->alert(
                '<strong>Special Welcome Offer:</strong> ' . esc_html($reward_text),
                'success'
            );
        }
        $content .= JVB()->email()->button($magic_url, 'Join Now');
        $content .= Emails::getInstance()->button($magic_url, 'Join Now');
        $content .= '<p>Or copy and paste this link:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= JVB()->email()->spacer(10);
        $content .= Emails::getInstance()->link($magic_url);
        $content .= Emails::getInstance()->spacer(10);
        $content .= '<p><small>This invitation expires in 14 days.</small></p>';
        return $content;
@@ -569,7 +569,7 @@
    public function renderForm():string
    {
        ob_start();
        JVB()->connect('cloudflare')->renderTurnstile();
        Cloudflare::getInstance()->renderTurnstile();
        $turnstile = ob_get_clean();
        global $wp;
@@ -599,7 +599,7 @@
//          wp_login_url()
        );
         $login = LoginManager::getInstance()->renderLoginForm('login', get_home_url(null,'/'.$wp->request));
         $login = Login::getInstance()->renderLoginForm('login', get_home_url(null,'/'.$wp->request));
         $tabs = new Tabs();
         $tabs->addTab('magic')
inc/managers/NewsRelationships.php
@@ -15,8 +15,7 @@
 */
class NewsRelationships
{
    private string $table_name;
    private Cache $cache;
    private CustomTable $relationships;
    public function __construct()
    {
@@ -626,7 +625,7 @@
        $wpdb->query("TRUNCATE TABLE {$this->table_name}");
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'rebuild_news',
            0,
            [
inc/managers/NotificationManager.php
@@ -1,10 +1,13 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\managers\Notifications\Content;
use JVBase\managers\Notifications\EmailDigests;
use JVBase\managers\Notifications\Notifications;
use JVBase\managers\Notifications\Preferences;
use JVBase\rest\Rest;
use JVBase\rest\routes\NotificationsRoutes;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
@@ -18,13 +21,16 @@
 */
class NotificationManager
{
    use Instance;
   protected Notifications $notifications;
   protected Content $content;
   protected EmailDigests $digest;
   protected Preferences $preferences;
   public function __construct()
    protected Rest $route;
   private function __construct()
   {
       $this->route = NotificationsRoutes::getInstance();
       $this->notifications = new Notifications();
       $this->content = new Content();
       $this->digest = new EmailDigests();
inc/managers/Notifications/Content.php
@@ -18,7 +18,7 @@
    protected static Cache $cache;
    public function __construct()
    {
        self::$cache = Cache::for('content_notifications')->connect('post');
        self::$cache = Cache::for('content_notifications', 'post');
    }
    public static function getSinceDate(string $date):string
    {
inc/managers/Notifications/EmailDigests.php
@@ -3,6 +3,7 @@
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\managers\Emails;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Error;
@@ -122,7 +123,7 @@
            return;
        }
        JVB()->queue()->add(
        Queue::getInstance()->add(
            'email_notification_digest',
            0,
            [
@@ -142,7 +143,7 @@
        if (!in_array($frequency, ['never', 'daily', 'weekly', 'monthly'])) {
            return [];
        }
        return JVB()->notification()->getUsersByFrequency($frequency);
        return NotificationManager::getInstance()->getUsersByFrequency($frequency);
    }
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
@@ -176,10 +177,10 @@
    protected function generateUserDigest(int $userID, string $frequency):string|false
    {
        $subscription = JVB()->notification()->getUserSubscriptions($userID, $frequency);
        $subscription = NotificationManager::getInstance()->getUserSubscriptions($userID, $frequency);
        //TODO
        $notifications = JVB()->notification()->getUserNotifications($userID);
        $notifications = NotificationManager::getInstance()->getUserNotifications($userID);
        if (empty($subscription) && empty($notifications)) {
            return false;
@@ -281,18 +282,18 @@
            $image = sprintf(
                '<a href="%s">%s</a>',
                $url,
                JVB()->email()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
                Emails::getInstance()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
            );
            $t = sprintf(
                '<a href="%s">%s</a>',
                $url,
                $m->get('post_title')
            );
            $output[] = JVB()->email()->card($image, $t);
            $output[] = Emails::getInstance()->card($image, $t);
        }
        $final = JVB()->email()->button($goTo, 'Want to See More?');
        return JVB()->email()->grid($output, 3, 'New from '.$title,'',$final);
        $final = Emails::getInstance()->button($goTo, 'Want to See More?');
        return Emails::getInstance()->grid($output, 3, 'New from '.$title,'',$final);
    }
    protected function getTermUpdates(int $termID, string $type, string $frequency):string|false
@@ -373,17 +374,17 @@
            $image = sprintf(
                '<a href="%s">%s</a>',
                $url,
                JVB()->email()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
                Emails::getInstance()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
            );
            $t = sprintf(
                '<a href="%s">%s</a>',
                $url,
                $m->get('post_title')
            );
            $output[] = JVB()->email()->card($image, $t);
            $output[] = Emails::getInstance()->card($image, $t);
        }
        $final = JVB()->email()->button($goTo, 'See More');
        return JVB()->email()->grid($output, 3, 'New in '.$title, '', $final);
        $final = Emails::getInstance()->button($goTo, 'See More');
        return Emails::getInstance()->grid($output, 3, 'New in '.$title, '', $final);
    }
}
inc/managers/Notifications/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/OperationQueue.php
@@ -90,7 +90,7 @@
    }
    public function registerAdminAction():void
    {
        $admin = JVB()->admin();
        $admin = AdminPages::getInstance();
        $admin->registerAction(
            'Restart Stuck Operations',
            'restart-stuck-operations',
@@ -1131,7 +1131,7 @@
        $message .= "Please check the error logs for more details.";
        return JVB()->email()->sendEmail($admin_email, $subject, $message);
        return Emails::getInstance()->sendEmail($admin_email, $subject, $message);
    }
    /**
@@ -1650,36 +1650,36 @@
        $success_rate = round(($total_success / max(1, $total_ops)) * 100, 1);
        $message = JVB()->email()->h1('Daily Queue Performance Report');
        $message = Emails::getInstance()->h1('Daily Queue Performance Report');
        $message .= sprintf('<p>Report for <strong>%s</strong></p>', $yesterday);
        // Summary stats in grid
        $stats = [
            JVB()->email()->stat($total_ops, 'Operations'),
            JVB()->email()->stat($total_success, 'Successful', '✓'),
            JVB()->email()->stat($total_failed, 'Failed', $total_failed > 0 ? 'âš ' : ''),
            JVB()->email()->stat($success_rate . '%', 'Success Rate')
            Emails::getInstance()->stat($total_ops, 'Operations'),
            Emails::getInstance()->stat($total_success, 'Successful', '✓'),
            Emails::getInstance()->stat($total_failed, 'Failed', $total_failed > 0 ? 'âš ' : ''),
            Emails::getInstance()->stat($success_rate . '%', 'Success Rate')
        ];
        $message .= JVB()->email()->grid($stats, 4);
        $message .= Emails::getInstance()->grid($stats, 4);
        $message .= JVB()->email()->spacer(20);
        $message .= Emails::getInstance()->spacer(20);
        // Alert if success rate is low
        if ($success_rate < 90) {
            $message .= JVB()->email()->alert(
            $message .= Emails::getInstance()->alert(
                sprintf('Success rate of %s%% is below the 90%% threshold', $success_rate),
                'warning'
            );
        }
        $message .= JVB()->email()->h2('Details by Operation Type');
        $message .= Emails::getInstance()->h2('Details by Operation Type');
        // Details for each operation type
        foreach ($metrics as $metric) {
            $details = [];
            $details[] = ['label' => 'Total', 'value' => $metric->total_operations];
            $details[] = ['label' => 'Success', 'value' => JVB()->email()->badge($metric->successful_operations, 'success')];
            $details[] = ['label' => 'Failed', 'value' => $metric->failed_operations > 0 ? JVB()->email()->badge($metric->failed_operations, 'error') : '0'];
            $details[] = ['label' => 'Success', 'value' => Emails::getInstance()->badge($metric->successful_operations, 'success')];
            $details[] = ['label' => 'Failed', 'value' => $metric->failed_operations > 0 ? Emails::getInstance()->badge($metric->failed_operations, 'error') : '0'];
            if ($metric->average_duration) {
                $details[] = ['label' => 'Avg Duration', 'value' => round($metric->average_duration, 2) . 's'];
@@ -1692,8 +1692,8 @@
                $details[] = ['label' => 'Peak Memory', 'value' => $memory_mb . ' MB'];
            }
            $message .= JVB()->email()->card(
                JVB()->email()->table($details),
            $message .= Emails::getInstance()->card(
                Emails::getInstance()->table($details),
                esc_html($metric->type)
            );
        }
@@ -1701,16 +1701,16 @@
        // Current queue status
        $pending_count = $this->getCurrentQueueSize();
        if ($pending_count > 0) {
            $message .= JVB()->email()->spacer(20);
            $message .= JVB()->email()->notice(
            $message .= Emails::getInstance()->spacer(20);
            $message .= Emails::getInstance()->notice(
                sprintf('<strong>Current Queue:</strong> %d operations pending', $pending_count)
            );
        }
        $message .= JVB()->email()->spacer(20);
        $message .= JVB()->email()->button(admin_url('admin.php?page=jvb-queue'), 'View Queue Dashboard');
        $message .= Emails::getInstance()->spacer(20);
        $message .= Emails::getInstance()->button(admin_url('admin.php?page=jvb-queue'), 'View Queue Dashboard');
        JVB()->email()->sendEmail($admin_email, $subject, $message, 'QUEUE REPORT');
        Emails::getInstance()->sendEmail($admin_email, $subject, $message, 'QUEUE REPORT');
    }
    /**
inc/managers/Referrals.php
File was renamed from inc/managers/ReferralManager.php
@@ -1,7 +1,13 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\meta\Sanitizer;
use JVBase\rest\Rest;
use JVBase\rest\routes\ReferralRoutes;
use JVBase\ui\CRUDSkeleton;
use JVBase\ui\Tabs;
use JVBase\base\Site;
@@ -18,9 +24,10 @@
 * Handles user referral codes, tracking, rewards, and reporting.
 * Keeps referrals separate from invitations system.
 */
class ReferralManager
class Referrals
{
    protected MagicLinkManager $magic_link;
    use Instance;
    protected MagicLinks $magic_link;
    protected Cache $cache;
    protected Cache $requestCache;
    protected Cache $statsCache;
@@ -33,6 +40,7 @@
    protected CustomTable $janeClients;
    protected CustomTable $rewards;
    protected CustomTable $treatments;
    protected Rest $route;
    // Default reward settings
    protected array $default_settings = [
@@ -48,10 +56,11 @@
    protected array $settings;
    public function __construct()
    private function __construct()
    {
        $this->route = ReferralRoutes::getInstance();
        $this->defineTables();
        $this->role = Site::getDefaultReferralRole();
        $this->role = Site::getReferralRole();
        $this->default_settings['referral_role'] = $this->role;
        $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
@@ -88,9 +97,6 @@
        // Schedule cron jobs for reports
        $this->registerCronJobs();
        // Add admin bar label for referral page
        add_action('admin_bar_menu', [$this, 'addReferralPageLabel'], 999);
        // Add admin notice to referral page edit screen
        add_action('admin_notices', [$this, 'showReferralPageNotice']);
@@ -102,6 +108,8 @@
        add_action('admin_init', [$this, 'registerSettings']);
        // Handle admin page form submission
        add_filter('jvb_admin_page_submission', [$this, 'handleAdminSubmission'], 10, 3);
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
    }
    protected function defineTables():void
@@ -118,14 +126,15 @@
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'from_user' => "{$table->getUserIDType()} NOT NULL",
                'to_user'   => "{$table->getUserIDType()} NOT NULL",
                'to_name'   => 'varchar(255) NOT NULL',
                'to_email'  => 'varchar(255) NOT NULL',
                'to_phone'  => 'varchar(50) NOT NULL',
                'from_user' => "{$table->getUserIDType()} NOT NULL",        //The person doing the referring
                'to_user'   => "{$table->getUserIDType()} DEFAULT NULL",    //The person being referred
                'to_name'   => 'varchar(255) DEFAULT NULL',
                'to_email'  => 'varchar(255) DEFAULT NULL',
                'to_phone'  => 'varchar(50) DEFAULT NULL',
                'referral_code'=> 'varchar(50) NOT NULL',
                'status'    => "ENUM('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
                'status'    => "ENUM('invited', 'pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
                'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
                'last_invited'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
                'consulted_at'=> 'datetime DEFAULT NULL',
                'treated_at'=> 'datetime DEFAULT NULL',
                'treatment_count' => 'int DEFAULT 0',
@@ -134,8 +143,9 @@
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => 'id'],
                ['key' => 'UNIQUE', 'value' => '(`to_user`)'],
//              ['key' => 'UNIQUE', 'value' => '(`to_user`)'],
                'from_user (`from_user`)',
                'to_user (`to_user`)',
                'status (`status`)',
                'code (`referral_code`)',
                'date (`created_at`)',
@@ -329,8 +339,8 @@
            'jvb-data-store',
        ];
        if (Site::hasIntegration('cloudflare') && JVB()->connect('cloudflare')->isSetUp()) {
            JVB()->connect('cloudflare')->enqueueTurnstileScripts();
        if (Site::hasIntegration('cloudflare') && Cloudflare::getInstance()->isSetUp()) {
            Cloudflare::getInstance()->enqueueTurnstileScripts();
        }
        if (is_singular(BASE.'dash')) {
            $requirements[] = 'jvb-form';
@@ -489,109 +499,140 @@
     *
     * @param int $user_id
     * @param array $userData
     * @return bool;
     * @return void
     */
    public function processReferral(int $user_id, array $userData): bool
    public function processReferral(int $user_id, array $userData):void
    {
        $referral = $this->referrals->get(['to_user' => $user_id]);
        if (empty($referral)) {
            $referral = $this->referrals->get(['to_email' => $userData['email']]);
        if (!array_key_exists('ref', $_POST) || empty($_POST['ref'])) {
            return;
        }
        if (empty($referral)) {
            // Check session/cookie if not in meta
            if (session_status() === PHP_SESSION_NONE) {
                session_start();
            }
            $referral_code = $_SESSION[BASE . 'referral_code'] ?? $_COOKIE[BASE . 'referral_code'] ?? '';
            if (!empty ($referral_code)) {
                $referral = [
                    'to_user'   => $user_id,
                    'referral_code' => $referral_code,
                    'to_email'      => $userData['user_email']
                ];
            }
        $code = sanitize_text_field($_POST['ref']);
        $referredBy = $this->codes->pluck('user_id', ['code' => $code]);
        if (!empty($referredBy)) {
            $this->createReferralFromID($referredBy[0], $user_id, $code);
        }
        if (empty($referral)) {
            return false; // No referral code - regular registration
        }
        // Find the from_user
        $from_user = $this->codes->pluck('user_id', ['code' => $referral['referral_code']]);
        if (empty($from_user)) {
            //This should not happen, but whatever
            return false;
        }
        // Check if this email already has a referral record
        if ($this->isEmailInvited($referral['to_email'])) {
            return false;
        }
        $from_user = $from_user[0];
        $record = $this->referrals->findOrCreate([
            'to_user'   => $user_id,
            'referral_code' => $referral['referral_code'],
        ], [
            'from_user'     => $from_user,
            'to_email'      => $referral['to_email'],
            'to_name'       => $userData['first_name'],
//          'to_phone'      =>
            'status'        => 'pending'
        ]);
        if (!$record) {
            error_log('[ReferralManager]::processReferral Could not update record for user: '.print_r($referral, true));
            return false;
        }
        // Clean up temp data
        delete_user_meta($user_id, BASE . 'pending_referral_code');
        if (isset($_SESSION[BASE . 'referral_code'])) {
            unset($_SESSION[BASE . 'referral_code']);
        }
        if (isset($_COOKIE[BASE . 'referral_code'])) {
            setcookie(BASE . 'referral_code', '', time() - 3600, '/');
        }
        // Clear caches
        $this->cache->flush();
        // Fire action for tracking
        do_action('jvb_referral_processed', $user_id, $from_user->ID, $referral['referral_code']);
        // Send notification to from_user
        $this->sendReferrerNotification($from_user->ID, $userData['display_name']);
        return true;
    }
    /**
     * Create a referral record in the database
     *
     * @param int $from_user_id
     * @param int $to_user_id
     * @param string $code
     * @return int|false
     * @param int $from_user_id The person sending the referral
     * @param string $name  The person who was referred
     * @param string $email The person who was referred's email
     * @param ?string $phone The person who was referred's phone (optional)
     * @param ?string $code The person sending the referral's custom code
     * @return array
     */
    public function createReferral(int $from_user_id, int $to_user_id, string $code)
    public function createReferralFromFields(int $from_user_id, string $name, string $email, ?string $phone = null, ?string $code = null):array
    {
        $user = get_user_by('ID', $to_user_id);
        if (!get_userdata($from_user_id)) {
            return [
                'success'   => false,
                'message'   => 'From user does not exist',
            ];
        }
        return $this->referrals->findOrCreate([
            [
                'to_user' => $to_user_id,
            ],
            [
                'from_user' => $from_user_id,
                'to_name' => $user->display_name,
                'to_email' => $user->user_email,
                'to_phone' => get_user_meta($to_user_id, BASE . 'phone', true) ?: '',
                'referral_code' => $code,
                'status' => 'pending',
            ]
        ]);
        if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
            if(email_exists($email)) {
                return [
                    'success'   => false,
                    'message'   => 'Email already exists',
                ];
            }
        }
        if ($phone && !empty($this->referrals->pluck('to_phone', ['to_phone' => Sanitizer::sanitizePhone($phone)]))) {
            return [
                'success'   => false,
                'message'   => 'Phone already used'
            ];
        }
        $insert = [
            'from_user'     => $from_user_id,
            'referral_code' => is_null($code)? $this->getUserReferralCode($from_user_id) : sanitize_text_field($code),
            'to_name'       => $name,
            'to_email'      => $email,
            'status'        => 'invited',
        ];
        if ($phone) {
            $insert['to_phone'] = $phone;
        }
        $created = $this->referrals->insert($insert);
        $this->statsCache->forget($from_user_id);
        return [
            'success'   => (bool) $created,
            'message'   => 'Referral created'
        ];
    }
    /**
     * Create a referral record in the database
     *
     * @param int $from_user_id The person sending the referral
     * @param int $to_user_id The person who was referred
     * @param ?string $code Custom code, if not default
     * @return array
     */
    public function createReferralFromID(int $from_user_id, int $to_user_id, ?string $code = null):array
    {
        $user = get_userdata($to_user_id);
        $code = !is_null($code) ? sanitize_text_field($code) : $this->getUserReferralCode($to_user_id);
        $where = [
            'to_email'  => $user->user_email,
            'to_name'   => $user->first_name ?: $user->display_name
        ];
        $phone = false;
        if (array_key_exists('phone', $_POST) && !empty($_POST['phone'])) {
            $phone = Sanitizer::sanitizePhone($_POST['phone']);
            if (!empty($phone)) {
                $where = [
                    'to_phone'  => $phone,
                ];
            }
        } elseif (!empty(Meta::forUser($to_user_id)->get('phone'))) {
            $phone = Meta::forUser($to_user_id)->get('phone');
        }
        $where = [
            'OR'    => $where,
            'status' => 'invited'
        ];
        $update = [
            'from_user' => $from_user_id,
            'to_user'   => $to_user_id,
            'to_name'   => $user->first_name ?: $user->display_name,
            'to_email'  => $user->user_email,
            'status'    => 'pending',
            'referral_code' => $code
        ];
        if ($phone) {
            $update ['to_phone'] = $phone;
        }
        $existing = $this->referrals->get($where);
        if ($existing) {
            if ($existing->from_user != $from_user_id) {
                $this->referrals->update([
                    'status'    => 'cancelled',
                ], $where);
            } else {
                $updated = $this->referrals->update($update, $where);
                $this->sendReferrerNotification($from_user_id, $update['to_name']);
                return [
                    'success'   => (bool) $updated,
                    'message'   => $updated ? 'Successfully created referral' : 'Referral could not be updated'
                ];
            }
        }
        $created = $this->referrals->insert($update);
        $this->statsCache->forget($from_user_id);
        return [
            'success'   => (bool) $created,
            'message'   => $created ? 'Successfully created referral' : 'Referral could not be added'
        ];
    }
    /**
@@ -714,35 +755,49 @@
            'order' => 'DESC'
        ];
        $where = [
            'from_user' => $user_id,
        ];
        foreach ($this->referrals->getColumnKeys() as $col) {
            if (array_key_exists($col, $args)) {
                if ($col === 'status' && $args[$col] !== 'all') {
                    $where[$col] = $args[$col];
                } else {
                    $where[$col] = $args[$col];
                }
            }
        }
        $args = wp_parse_args($args, $defaults);
        $args['status'] = strtolower($args['status']);
        $tableArgs = [
            'where' => [
                'from_user' => $user_id,
            ],
            'where'     => $where,
            'per_page'      => $args['per_page'],
            'page'      => $args['page'],
            'orderby'   => $args['orderby'],
            'order'     => $args['order']
        ];
        if (in_array($args['status'], ['pending', 'consulted', 'treated', 'cancelled'])){
        if ($args['status'] !== 'all'){
            $tableArgs['status'] = $args['status'];
        }
        $referrals = $this->referrals->getMany($tableArgs);
        $referrals = $this->referrals->getMany($tableArgs, false);
        return array_map(function($referral) {
            $last_invite = get_transient('referral_last_invite_' . md5($referral->to_email));
            $can_resend = !$last_invite || (time() - $last_invite) > WEEK_IN_SECONDS;
            $can_resend = $this->checkCanResendEmail($referral->id);
            $status = match($referral->status) {
                'consulted' => 'Awaiting Treatment',
                'treated'   => 'Rewarded!',
                'invited'   => 'Invited, but has not used',
                default => 'Pending',
            };
            return [
                'id'            => $referral->id,
                'to_name'   => $referral->to_name,
                'to_email'  => $referral->to_email,
                'referred_at'   => JVB()->routes('referral')->formatTimestamp($referral->referred_at),
                'referred_at'   => ReferralRoutes::getInstance()->formatTimestamp($referral->referred_at),
                'referral_status'=> $status,
                'can_resend'    => $can_resend
            ];
@@ -750,6 +805,17 @@
    }
    protected function checkCanResendEmail(int|object $referral): bool
    {
        if (is_int($referral)) {
            $referral = $this->referrals->get(['id'=>$referral]);
            if (!$referral) {
                return false;
            }
        }
        return (time() - $referral->updated_at) > WEEK_IN_SECONDS;
    }
    /**
     * Get referral statistics for a user
     *
@@ -761,25 +827,15 @@
        return $this->statsCache->remember(
            $user_id,
            function() use ($user_id) {
                $stats = $this->referrals->queryResults(
                    "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 {table}
                WHERE from_user = %d",
                    [$user_id],
                    ARRAY_A
                );
                $stats = $stats[0] ?? [];
                $rewards = $this->rewards->queryVar(
                    "SELECT SUM(amount) FROM {table} WHERE user_id = %d AND reward_type = 'from_user'",
                    [$user_id]
                );
                $stats['total_rewards'] = floatval($rewards ?? 0);
                $stats = [
                    'total'     => $this->referrals->count(['user_id' => $user_id]),
                    'invited'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'invited']),
                    'pending'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'pending']),
                    'consulted' =>$this->referrals->count(['user_id' => $user_id, 'status' => 'consulted']),
                    'treated'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'treated']),
                    'total_rewards' => $this->rewards->sum('amount', ['user_id' => $user_id]),
                    'available_rewards' => $this->rewards->sum('amount', ['user_id' => $user_id, 'redeemed_at' => ['NOT NULL', null]]),
                ];
                $stats['user_id'] = $user_id;
                return $stats;
            }
@@ -852,15 +908,15 @@
            return;
        }
        $content = JVB()->email()->h1('Daily Referral Report');
        $content .= JVB()->email()->stat(
        $content = Emails::getInstance()->h1('Daily Referral Report');
        $content .= Emails::getInstance()->stat(
            count($new_referrals),
            count($new_referrals) === 1 ? 'New Referral' : 'New Referrals',
            'From ' . $yesterday
        );
        $content .= JVB()->email()->spacer(20);
        $content .= JVB()->email()->h2('New Referrals');
        $content .= Emails::getInstance()->spacer(20);
        $content .= Emails::getInstance()->h2('New Referrals');
        // Build list of referrals
        foreach ($new_referrals as $ref) {
@@ -871,12 +927,12 @@
            );
            $cardContent .= sprintf(
                '<p style="font-size:13px;color:%s;">Referred by: %s | Code: %s</p>',
                JVB()->email()->colours['dark-200'],
                Emails::getInstance()->colours['dark-200'],
                esc_html($ref->from_name),
                JVB()->email()->badge($ref->referral_code, 'info')
                Emails::getInstance()->badge($ref->referral_code, 'info')
            );
            $content .= JVB()->email()->card($cardContent);
            $content .= Emails::getInstance()->card($cardContent);
        }
        $to = get_option('admin_email');
@@ -887,7 +943,7 @@
            count($new_referrals) !== 1 ? 's' : ''
        );
        JVB()->email()->sendEmail($to, $subject, $content, 'DAILY REPORT');
        Emails::getInstance()->sendEmail($to, $subject, $content, 'DAILY REPORT');
    }
    /**
@@ -895,31 +951,29 @@
     */
    public function sendWeeklyReport(): void
    {
        $top_from_users = $this->getTopReferrers(10, 'week');
        $total_referrals = $this->referrals->queryVar(
            "SELECT COUNT(*) FROM {table} WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)"
        );
        $total_referrals = $this->referrals->count(['created_at' => ['>=', "DATE_SUB(NOW(), INTERVAL 1 WEEK)"]]);
        if ($total_referrals == 0) {
            return;
        }
        $top_from_users = $this->getTopReferrers(10, 'week');
        $content = JVB()->email()->h1('Weekly Referral Summary');
        $content .= JVB()->email()->stat(
        $content = Emails::getInstance()->h1('Weekly Referral Summary');
        $content .= Emails::getInstance()->stat(
            $total_referrals,
            'Total Referrals',
            'This week'
        );
        $content .= JVB()->email()->spacer(30);
        $content .= JVB()->email()->h2('Top 10 Referrers');
        $content .= Emails::getInstance()->spacer(30);
        $content .= Emails::getInstance()->h2('Top 10 Referrers');
        // Leaderboard style
        $rank = 1;
        foreach ($top_from_users as $from_user) {
            $rankBadge = $rank <= 3
                ? JVB()->email()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
                : '<span style="font-weight:600;color:' . JVB()->email()->colours['dark-200'] . ';">#' . $rank . '</span>';
                ? Emails::getInstance()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
                : '<span style="font-weight:600;color:' . Emails::getInstance()->colours['dark-200'] . ';">#' . $rank . '</span>';
            $cardContent = sprintf(
                '<p>%s <strong>%s</strong></p>',
@@ -928,19 +982,19 @@
            );
            $stats = [
                JVB()->email()->stat($from_user->referral_count, 'Total Referrals'),
                JVB()->email()->stat($from_user->treated_count, 'Treated')
                Emails::getInstance()->stat($from_user->referral_count, 'Total Referrals'),
                Emails::getInstance()->stat($from_user->treated_count, 'Treated')
            ];
            $cardContent .= JVB()->email()->grid($stats, 2);
            $cardContent .= Emails::getInstance()->grid($stats, 2);
            $content .= JVB()->email()->card($cardContent);
            $content .= Emails::getInstance()->card($cardContent);
            $rank++;
        }
        $to = get_option('admin_email');
        $subject = '[' . get_bloginfo('name') . '] Weekly Referral Summary - ' . date('F j, Y');
        JVB()->email()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
        Emails::getInstance()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
    }
    /**
@@ -1005,7 +1059,7 @@
            ];
        }
        $content .= JVB()->email()->table($from_users, 'Top 10 Referrers This Week');
        $content .= Emails::getInstance()->table($from_users, 'Top 10 Referrers This Week');
        return $content;
    }
@@ -1220,7 +1274,7 @@
    function getUnloggedInReferral(): string
    {
        ob_start();
        JVB()->connect('cloudflare')->renderTurnstile();
        Cloudflare::getInstance()->renderTurnstile();
        $turnstile = ob_get_clean();
        $reward_text = $this->getRewardText(true);
@@ -1512,8 +1566,8 @@
            <p>Or click the button below:</p>
            %s
            </div>',
                JVB()->email()->link($code),
                JVB()->email()->button($share_url, 'Share Your Code')
                Emails::getInstance()->link($code),
                Emails::getInstance()->button($share_url, 'Share Your Code')
            );
        }
@@ -1555,31 +1609,39 @@
            return new WP_Error('invalid_email', 'Invalid email address');
        }
        // Check if already registered
        if (email_exists($invitee_email)) {
            return new WP_Error('user_exists', 'This person already has an account');
        }
        // Get from_user info
        $from_user = get_user_by('ID', $user_id);
        $from_user = get_userdata($user_id);
        $referral_code = $this->getUserReferralCode($user_id);
        if ($referral_code) {
            return $referral_code;
        // Record the invitation attempt (for rate limiting only)
        if ($this->emailAlreadyTreated($invitee_email)) {
            return [
                'success'   => false,
                'message'   => 'User already treated'
            ];
        }
        // Record the invitation attempt (for rate limiting only)
        $this->recordInvitationAttempt($user_id, $invitee_email, $invitee_name);
        // Create registration URL with token (opens sidebar with prefilled form)
        $token_data = [
            'name' => sanitize_text_field($invitee_name),
            'email' => $invitee_email,
            'expires' => time() + (30 * DAY_IN_SECONDS)
        ];
        $record = $this->referrals->findOrCreate(
            [
                'from_user' => $user_id,
                'to_email'  => $invitee_email,
                'referral_code' => $referral_code,
            ],
            [
                'to_name'   => $invitee_name,
                'status'    => 'invited',
                'last_invited'  => current_time('mysql')
            ]
        );
        // Encode the token
        $token = base64_encode(json_encode($token_data));
        $registration_url = add_query_arg([
            'ref' => $referral_code,
            'rname' => sanitize_text_field($invitee_name),
@@ -1606,11 +1668,11 @@
                esc_html(get_bloginfo('name')),
                nl2br(esc_html($message)),
                esc_html($reward_text),
                JVB()->email()->button($registration_url, 'Register & Get Your Reward')
                Emails::getInstance()->button($registration_url, 'Register & Get Your Reward')
            );
        // Send email
        $sent = JVB()->email()->sendEmail(
        $sent = Emails::getInstance()->sendEmail(
            $invitee_email,
            $subject,
            $email_content
@@ -1642,26 +1704,27 @@
            'failed' => []
        ];
        foreach ($invitations as $invite) {
            $email = $invite['email'] ?? '';
            $name = $invite['name'] ?? '';
            if (empty($email) || empty($name)) {
                $results['failed'][] = [
                    'email' => $email,
                    'name' => $name,
                    'reason' => 'Missing email or name'
                ];
                continue;
        $invitations = array_filter($invitations, function ($invite) {
            if (!array_key_exists('email', $invite) || !filter_var($invite['email'], FILTER_VALIDATE_EMAIL)) {
                return false;
            }
            if (!array_key_exists('name', $invite) || empty ($invite['name'])) {
                return false;
            }
            return true;
        });
        foreach ($invitations as $invite) {
            $email = $invite['email'];
            $name = $invite['name'];
            $result = $this->sendReferralInvitation($user_id, $email, $name, $subject, $message);
            if (is_wp_error($result)) {
            if (!$result['success']) {
                $results['failed'][] = [
                    'email' => $email,
                    'name' => $name,
                    'reason' => $result->get_error_message()
                    'reason' => $result['message']
                ];
            } else {
                $results['success'][] = [
@@ -1693,98 +1756,25 @@
     */
    protected function checkEmailRateLimit(int $user_id):bool|string
    {
        $hourly_key = 'referral_invites_hour_' . $user_id;
        $count = (int) get_transient($hourly_key);
        $key = BASE.'referral_email_limit';
        $stored = get_user_meta($user_id, $key, true);
        $count = empty($stored) ? 0 : count($stored);
        if ($count >= 15) {
            return 'hourly_limit_reached';
            return false;
        }
        set_transient($hourly_key, $count + 1, HOUR_IN_SECONDS);
        $stored = empty($stored) ? [] : $stored;
        $stored[] = time();
        $stored = array_filter($stored, function($time) {
            return time() - $time < HOUR_IN_SECONDS;
        });
        update_user_meta($user_id, $key, $stored);
        return true;
    }
    /**
     * Check if an email has already been invited
     *
     * @param string $email
     * @return bool
     */
    protected function isEmailInvited(string $email): bool
    {
        // Check invitation tracking table
        $invitation_key = 'referral_invite_' . md5($email);
        $invited = get_transient($invitation_key);
        if ($invited) {
            return true;
        }
        // Check if there's a pending referral for this email
        $existing = $this->referrals->pluck('id', ['to_email' => $email]);
        return !empty($existing);
    }
    /**
     * Record invitation attempt (for tracking and preventing duplicates)
     *
     * @param int $user_id
     * @param string $email
     * @param string $name
     */
    protected function recordInvitationAttempt(int $user_id, string $email, string $name): void
    {
        // Store for 30 days (same as magic link invitation validity)
        $invitation_key = 'referral_invite_' . md5($email);
        $data = [
            'inviter_id' => $user_id,
            'email' => $email,
            'name' => $name,
            'sent_at' => current_time('mysql')
        ];
        set_transient($invitation_key, $data, 30 * DAY_IN_SECONDS);
        // Also log in user meta for tracking
        $sent_invites = get_user_meta($user_id, BASE . 'referral_invites_sent', true) ?: [];
        $sent_invites[] = [
            'email' => $email,
            'name' => $name,
            'sent_at' => current_time('mysql')
        ];
        update_user_meta($user_id, BASE . 'referral_invites_sent', $sent_invites);
    }
    /**
     * Get user's invitation stats
     *
     * @param int $user_id
     * @return array
     */
    public function getUserInvitationStats(int $user_id): array
    {
        $sent_invites = get_user_meta($user_id, BASE . 'referral_invites_sent', true) ?: [];
        // Count invites sent in last hour
        $one_hour_ago = strtotime('-1 hour');
        $recent_count = 0;
        foreach ($sent_invites as $invite) {
            if (strtotime($invite['sent_at']) > $one_hour_ago) {
                $recent_count++;
            }
        }
        return [
            'total_sent' => count($sent_invites),
            'sent_last_hour' => $recent_count,
            'remaining_this_hour' => max(0, 15 - $recent_count),
            'can_send_more' => $recent_count < 15
        ];
    }
    /**
     * Export referrals for Jane App cross-reference
     *
     * @param string $start_date Y-m-d format
@@ -1871,22 +1861,12 @@
            'menu_title' => 'Referrals',
            'capability' => 'manage_options',
            'menu_slug' => BASE . 'referral-admin',
            'callback' => [self::class, 'renderAdminPageStatic']
            'callback' => [self::class, 'renderAdminPage']
        ];
        AdminPages::addSubPage(BASE.'referral-admin', $subpage);
    }
    /**
     * Static wrapper for renderAdminPage
     * Called by WordPress when admin page is rendered
     */
    public static function renderAdminPageStatic(): void
    {
        // Get the properly initialized instance from JVB singleton
        JVB()->referrals()->renderAdminPage();
    }
    /**
     * Register settings
     */
    public function registerSettings(): void
@@ -2496,95 +2476,42 @@
     */
    protected function renderReferralStats(bool $wrapCard = false): string
    {
        global $wpdb; // Use fresh global instead of stored reference
        $isAdmin = current_user_can('manage_options');
        $current = get_current_user_id();
        $types = ['total' => 'Total Referrals', 'invited' => 'Invited', 'pending' => 'Pending', 'treated' => 'Treated'];
        ob_start();
        $out = '<table class="widefat">';
        foreach ($types as $type => $label) {
            $status = $type === 'total' ? [] : ['status' => $type];
        // Get fresh table name references
        $referrals_table = $wpdb->prefix . BASE . 'referrals';
        // Use proper WordPress prepare even for COUNT
        $total_referrals = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM `{$referrals_table}` WHERE 1=%d",
                1
            )
        );
        $pending_referrals = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s",
                'pending'
            )
        );
        $treated_referrals = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s",
                'treated'
            )
        );
        ?>
        <table class="widefat">
            <tr>
                <th>Total Referrals</th>
                <td><?= esc_html($total_referrals ?? 0) ?></td>
            </tr>
            <tr>
                <th>Pending</th>
                <td><?= esc_html($pending_referrals ?? 0) ?></td>
            </tr>
            <tr>
                <th>Treated</th>
                <td><?= esc_html($treated_referrals ?? 0) ?></td>
            </tr>
        </table>
        <?php
        $table = ob_get_clean();
            $count = $isAdmin ?
                $this->referrals->count($status) :
                $this->referrals->count(array_merge(
                    ['from_user' => $current],
                    $status
                ));
            $out .= sprintf(
            '<tr>
                    <th>%s</th>
                    <td>%s</td>
                </tr>',
                $label,
                esc_html($count)
            );
        }
        $out .= '</table>';
        if ($wrapCard) {
            $table = '<div class="card">
            $out = '<div class="card">
            <h2>Referral Statistics</h2>
            ' . $table . '
            ' . $out . '
        </div>';
        }
        return $table;
        return $out;
    }
    /**
     * Add "Referral Page" label to admin bar
     *
     * @param WP_Admin_Bar $wp_admin_bar
     */
    public function addReferralPageLabel($wp_admin_bar): void
    {
        if (!is_admin()) {
            return;
        }
        if (!$this->referralPage) {
            $this->referralPage = $this->getReferralPageId();
        }
        if (!$this->referralPage) {
            return;
        }
        global $pagenow, $post;
        // Check if we're editing the referral page
        if ('post.php' === $pagenow && $post && $post->ID === $this->referralPage) {
            $wp_admin_bar->add_node([
                'id' => 'referral-page',
                'parent' => 'top-secondary',
                'title' => __('Referral Page', 'jvbase'),
                'meta' => [
                    'class' => 'referral-page-notice'
                ]
            ]);
        }
    }
    /**
     * Get the referral page ID
@@ -2612,7 +2539,7 @@
        }
        if ($post->ID === $this->referralPage) {
            echo '<div class="notice notice-info">';
            echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvbase') . '</p>';
            echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvb') . '</p>';
            echo '</div>';
        }
    }
@@ -2826,7 +2753,7 @@
     * @param array $post_data POST data
     * @return array|null Result array or null if not our page
     */
    public function handleAdminSubmission($result, string $page_slug, array $post_data): ?array
    public function handleAdminSubmission(mixed $result, string $page_slug, array $post_data): ?array
    {
        // Only handle our page
        if ($page_slug !== BASE . 'referral-admin') {
@@ -2961,7 +2888,7 @@
            home_url('/dash/referrals')
        );
        JVB()->email()->sendEmail(
        Emails::getInstance()->sendEmail(
            $from_user->user_email,
            $subject,
            $message
@@ -3033,11 +2960,14 @@
        return ob_get_clean();
    }
    public function updateStatus(int $referral_id, string $status): bool|WP_Error
    public function updateStatus(int $referral_id, string $status):array
    {
        $referral = $this->referrals->get(['id' => $referral_id]);
        if (!$referral) {
            return new WP_Error('not_found', 'Referral not found');
            return [
                'success'   => false,
                'message'   => 'Referral not found'
            ];
        }
        $data = ['status' => $status, "{$status}_at" => current_time('mysql')];
@@ -3046,48 +2976,78 @@
        }
        $result = $this->referrals->update($data, ['id' => $referral_id]);
        if ($result === false) {
            return new WP_Error('update_failed', 'Failed to update referral status');
        if (!$result) {
            return [
                'success'   => false,
                'message'   => 'Failed to update referral status'
            ];
        }
        if ($status === 'treated') {
            $this->createRewardRecords($referral_id);
        }
        $this->cache->flush();
        return true;
        $this->cache->forget($referral_id);
        return [
            'success'   => true,
            'message'   => 'Successfully set status to '.$status
        ];
    }
    public function removeReferral(int $referral_id, int $user_id): bool|WP_Error
    public function removeReferral(int $referral_id, int $user_id):array
    {
        $referral = $this->referrals->get(['id' => $referral_id]);
        if (!$referral) {
            return new WP_Error('not_found', 'Referral not found');
            return [
                'success'   => false,
                'message'   => 'Referral not found'
            ];
        }
        if ($referral->from_user != $user_id && !current_user_can('manage_options')) {
            return new WP_Error('unauthorized', 'Unauthorized');
            return [
                'success'   => false,
                'message'   => 'Unauthorized'
            ];
        }
        if ($referral->status !== 'pending') {
            return new WP_Error('invalid_status', 'Can only remove pending referrals');
        if (!in_array($referral->status, ['invited', 'pending'])) {
            return [
                'success'   => false,
                'message'   => 'Cannot remove a completed or cancelled referral'
            ];
        }
        $this->referrals->delete(['id' => $referral_id]);
        $this->cache->flush();
        return true;
        $this->cache->forget($referral_id);
        return [
            'success' => true,
            'message'   => 'Successfully removed referral'
        ];
    }
    public function resendInvitation(int $referral_id, int $user_id): bool|WP_Error
    public function resendInvitation(int $referral_id, int $user_id):array
    {
        $referral = $this->referrals->where(['id' => $referral_id, 'from_user' => $user_id])->first();
        if (!$referral) {
            return new WP_Error('not_found', 'Referral not found');
            return [
                'success'   => false,
                'message'   => 'Referral not found'
            ];
        }
        $transient_key = 'referral_last_invite_' . md5($referral->to_email);
        if (get_transient($transient_key)) {
            return new WP_Error('rate_limit', 'Can only resend once per week');
        if (!$this->checkCanResendEmail($referral)) {
            $days = (time() - $referral->last_invited) / DAY_IN_SECONDS;
            $days = match (true) {
                $days <= 1 => 'until tomorrow',
                $days <= 3 => 'a couple days',
                default => absint($days).' days'
            };
            return [
                'success'   => false,
                'message'   => 'Can\'t resend email. Wait '.$days.'.'
            ];
        }
        $result = $this->sendReferralInvitation(
@@ -3098,15 +3058,13 @@
            'Just a friendly reminder about my invitation!'
        );
        if (is_wp_error($result)) {
            return $result;
        }
        set_transient($transient_key, time(), WEEK_IN_SECONDS);
        return true;
        return [
            'success'   => (bool) $result,
            'message'   => $result ? 'Sent email' : 'Could not send email'
        ];
    }
    public function getAllReferrals(array $args = []): array
    public function getReferrals(array $args = []): array
    {
        $conditions = ['1=1'];
        $values = [];
@@ -3145,5 +3103,60 @@
            $values
        );
    }
    public function emailAlreadyTreated(string $email):bool
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return false;
        }
        if (email_exists($email)) {
            return false;
        }
        $referral = $this->referrals->get([
            'to_email'  => $email,
            'status'    => [
                'IN',
                ['pending', 'consulted', 'treated', 'cancelled']
            ]
        ]);
        return !empty($referral);
    }
    /**
     * Process queued referral operations
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
    {
        if ($operation->type !== 'referral_invite') {
            return $result;
        }
        $result = $this->sendBatchReferralInvitations(
            $operation->user_id,
            $data['invitations'],
            $data['subject'],
            $data['message']
        );
        if ($result['success']) {
            $this->cache->flush();
        }
        return [
            'success' => true,
            'message' => sprintf(
                'Sent invitations. Success: %d. Failed: %d.',
                count($result['result']['success']),
                count($result['result']['failed'])
            ),
            'details' => [
                'successful' => $result['result']['success'],
                'failed' => $result['result']['failed'],
                'total' => count($data['invitations'])
            ]
        ];
    }
}
inc/managers/ResponseManager.php
File was deleted
inc/managers/Responses.php
New file
@@ -0,0 +1,413 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\routes\ResponseRoutes;
use WP_Error;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Response Manager
 *
 * Handles a responses
 * TODO
 */
class Responses
{
    use Instance;
    protected CustomTable $responses;
    protected KarmaManager $karma;
    protected ResponseRoutes $route;
    protected int $perPage = 20;
    private function __construct()
    {
        $this->route = ResponseRoutes::getInstance();
        $this->defineTables();
        $this->karma = KarmaManager::for('responses', $this->responses->getFullTableName());
        $this->responses->setKarma($this->karma->karmaTable());
        add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1);
    }
    protected function defineTables():void
    {
        $table = CustomTable::for('responses');
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'item_id'       => "{$table->getPostIDType()} NOT NULL",
            'type'          => 'varchar(50) NOT NULL',
            'user_id'       => "{$table->getUserIdType()} NOT NULL",
            'parent_id'     => 'bigint(20) unsigned DEFAULT 0',
            'response'      => 'text NOT NULL',
            'upvotes'       => 'int NOT NULL DEFAULT 0',
            'downvotes'     => 'int NOT NULL DEFAULT 0',
            'karmic_score'  => 'int NOT NULL DEFAULT 0',
            'history'       => 'json DEFAULT {}',
            'status'        => "ENUM('publish', 'hidden','flagged','deleted') DEFAULT 'publish'",
            'is_user_deleted'=> 'tinyint(1) DEFAULT 0',
            'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
            'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '`id`'],
            '`item_lookup` (`item_id`)',
            '`user_lookup` (`user_id`)',
            '`parent_lookup` (`parent_id`)',
            '`karma` (`karmic_score`)',
            '`upvote` (`upvotes`)',
            '`downvote` (`downvotes`)',
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}responses_item` FOREIGN KEY (`item_id`)
            REFERENCES `{$table->getPostTable()}` (`ID`) ON DELETE CASCADE",
        ]);
        $table->defineTable();
        $this->responses = $table;
    }
    public function responseExists(int $id):bool
    {
        return (bool) $this->responses->get([
            'id'    => $id,
        ]);
    }
    public function updateKarma(int $id, int $upvotes, int $downvotes, int $score):bool
    {
        return $this->responses->update([
            'upvotes'       => $upvotes,
            'downvotes'     => $downvotes,
            'karmic_score'  => $score,
        ], [
            'id'    => $id
        ]);
    }
    protected function itemExists(int $item_id, string $type):bool
    {
        return (bool)$this->getOriginalItem($item_id, $type);
    }
    public function getOriginalItem(int $item_id, string $type):object|false
    {
        [$method, $type] = $this->determineType($type);
        if (!$method) {
            return false;
        }
        $item = match($method) {
            'post'  => get_post($item_id),
            'term'  => get_term($item_id, $type),
            'user'  => get_userdata($item_id),
            'response' => $this->responses->get(['id' => $item_id]),
            default => false
        };
        return (is_wp_error($item)) ? false : $item;
    }
    public function getOriginalItemAuthors(int $item_id, string $type):array
    {
        [$method, $type] = $this->determineType($type);
        $item = $this->getOriginalItem($item_id, $type);
        return match ($method) {
            'post'  => [$item->post_author],
            'term'  => jvbGetTermOwners($item_id),
            'user'  => [$item->ID],
            'response'  => [$item->user_id],
        };
    }
        protected function determineType(string $type):array
        {
            $method = match ($type) {
                'post', 'page', 'product' => 'post',
                'post_tag', 'category', 'term', 'taxonomy' => 'term',
                'user'  => 'user',
                'response'  => 'response',
                default => false
            };
            if (!$method) {
                $registrar = Registrar::getInstance($type);
                $type = jvbCheckBase($type);
                if ($registrar) {
                    $method = $registrar->getType();
                }
            }
            if (!$method) {
                error_log('[Responses]itemExists Invalid type: '.$type);
                return [false, false];
            }
            return [$method, $type];
        }
    public function respondTo(int $item_id, string $type, int $user_id, string $response, ?int $parent_id = null):array
    {
        if (!$this->itemExists($item_id, $type)) {
            return [
                'success'   => false,
                'message'   => 'Item does not exist',
            ];
        }
        if (empty($response)) {
            return [
                'success'   => false,
                'message'   => 'Response content is empty'
            ];
        }
        $created = $this->responses->insert([
            'item_id'   => $item_id,
            'user_id'   => $user_id,
            'parent_id' => $parent_id,
            'response'  => $response,
            'type'      => $type
        ]);
        $users = $this->getOriginalItemAuthors($item_id, $type);
        if ($parent_id) {
            $users = array_merge($users, $this->getNotifyableUsers($parent_id));
        }
        $users = array_unique($users);
        NotificationManager::getInstance()->unnotify(
            $users,
            'new_response',
            $user_id,
        );
        return [
            'success'   => (bool)$created,
            'message'   => $created ? 'Response posted successfully' : 'Response not posted'
        ];
    }
    public function editResponse(int $id, string $response):array
    {
        $original = $this->responses->get(['id' => $id]);
        if (!$original) {
            return [
                'success'   => false,
                'message'   => 'Original response not found'
            ];
        }
        if (get_current_user_id() !== $original->user_id) {
            return [
                'success'   => false,
                'message'   => 'This is not your response to edit'
            ];
        }
        $history = $this->pushHistory($original);
        $updated = $this->responses->update([
            'history'   => $history,
            'response'  => sanitize_text_field($response)
        ], [
            'user_id'   => $original->user_id,
            'id'        => $id
        ]);
        return [
            'success'   => (bool)$updated,
            'message'   => $updated ? 'Response edited successfully' : 'Response not edited'
        ];
    }
    public function deleteResponse(int $id):array
    {
        $original = $this->responses->get(['id' => $id]);
        if (!$original) {
            return [
                'success'   => true,
                'message'   => 'Item does not exist',
            ];
        }
        if ($original->user_id !== get_current_user_id()) {
            return [
                'success'   => false,
                'message'   => 'This is not your response to delete'
            ];
        }
        $deleted = $this->responses->update([
            'status'    => 'deleted',
            'history'   => $this->pushHistory($original),
            'response'  => '[ deleted response ]'
        ], [
            'id'    => $id,
        ]);
        NotificationManager::getInstance()->unnotify(
            $this->getNotifyableUsers($id),
            'new_response',
            $original->user_id,
        );
        return [
            'success'   => (bool)$deleted,
            'message'   => $deleted ? 'Response deleted successfully' : 'Response not deleted'
        ];
    }
    public function hideResponse(int $id):array
    {
        $original = $this->responses->get(['id' => $id]);
        if (!$original) {
            return [
                'success'   => false,
                'message'   => 'Item does not exist',
            ];
        }
        $currentUser = get_current_user_id();
        $isCommentOwner = $original->user_id === $currentUser;
        if ($isCommentOwner) {
            return $this->deleteResponse($id);
        }
        $item = $this->getOriginalItem($original->item_id, $original->type);
        $authorIDs = $this->getOriginalItemAuthors($original->item_id, $original->type);
        $isAuthor = in_array($currentUser, $authorIDs);
        if (!$isAuthor) {
            return [
                'success'   => false,
                'message'   => 'You cannot hide this response.'
            ];
        }
        $updated = $this->responses->update([
            'status'    => 'hidden',
        ], [
            'id'    => $id,
        ]);
        return [
            'success'   => (bool)$updated,
            'message'   => $updated ? 'Response hidden successfully' : 'Response not hidden'
        ];
    }
    public function getUserResponses(int $userID, $args = [], bool $includeChildren = true):array
    {
        $args = wp_parse_args($args, [
            'orderby'   => 'created_at',
            'order'     => 'DESC',
            'page'      => 1,
            'per_page'  => 20,
        ]);
        $where = [
            'user_id'   => $userID,
            'parent_id' => null
        ];
        if (array_key_exists('item_id', $args)) {
            $where['item_id'] = $args['item_id'];
        }
        $responses =  $this->responses->getMany(array_merge([
            'where' => $where,
        ], $args), false, ARRAY_A);
        if ($includeChildren) {
            $responses['items'] = array_map(function ($r) use ($args) {
                $r['children'] = $this->getChildResponses($r['id'], $args);
                return $r;
            }, $responses['items']);
        }
        return ['items'=> $responses];
    }
    public function getChildResponses(int $parentID, array $args, bool $includeChildren = true):array
    {
        $args = wp_parse_args($args, [
            'orderby'   => 'created_at',
            'order'     => 'DESC',
            'page'      => 1,
            'per_page'  => 20,
        ]);
        $responses = $this->responses->getMany(array_merge([
            'where' => [
                'id'    => $parentID,
            ]
        ], $args), true, ARRAY_A);
        if ($includeChildren) {
            $responses = array_map(function ($r) use ($args) {
                $r['children'] = $this->getChildResponses($r['id'], $args);
                return $r;
            }, $responses);
        }
        return $responses;
    }
    public function getAllItemResponses(int $itemID, array $args = [], bool $includeChildren = true):array
    {
        $args = wp_parse_args($args, [
            'orderby'   => 'created_at',
            'order'     => 'DESC',
            'page'      => 1,
            'per_page'  => 20,
        ]);
        $where = [
            'item_id'   => $itemID,
            'parent_id' => null
        ];
        $responses =  $this->responses->getMany(array_merge([
            'where' => $where,
        ], $args), false, ARRAY_A);
        if ($includeChildren) {
            $responses['items'] = array_map(function ($r) use ($args) {
                $r['children'] = $this->getChildResponses($r['id'], $args);
                return $r;
            }, $responses['items']);
        }
        return ['items'=> $responses];
    }
    public function handleUserDeletion(int $user_id):void
    {
        $updated = $this->responses->update([
            'is_user_deleted'   => 1,
        ], [
            'user_id'   => $user_id,
        ]);
        Errors::getInstance()->log(
            'Responses',
            'Anonymized responses for deleted user',
            [
                'user_id'   => $user_id,
                'count'     => $updated,
            ],
            'info'
        );
    }
    protected function getNotifyableUsers(int $id):array
    {
        $original = $this->responses->get(['id' => $id]);
        if (!$original) {
            return [];
        }
        $users = [$original->user_id];
        if ($original->parent_id) {
            $users = array_merge($users, $this->getNotifyableUsers($original->parent_id));
        }
        $users = array_merge($users, $this->getOriginalItemAuthors($original->item_id, $original->type));
        return array_unique($users);
    }
    protected function pushHistory(object $original):array
    {
        $history = json_decode($original->history, true);
        $history[] = [
            'edited_at' => $original->updated_at,
            'response'  => $original->response,
            'upvotes'   => $original->upvotes,
            'downvotes' => $original->downvotes,
        ];
        return $history;
    }
}
inc/managers/Roles.php
File was renamed from inc/managers/RoleManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_User;
@@ -9,8 +10,10 @@
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class RoleManager
class Roles
{
    use Instance;
    private array $roles;
    private array $content;
    private array $subTypes = [];
inc/managers/SEO/BreadcrumbManager.php
@@ -2,6 +2,7 @@
namespace JVBase\managers\SEO;
use JVBase\managers\Cache;
use JVBase\managers\DirectoryManager;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_Post;
@@ -20,15 +21,17 @@
class BreadcrumbManager
{
    private Cache $cache;
    private Cache $termCache;
    private Cache $postCache;
    private Cache $userCache;
    private static ?self $instance = null;
    private function __construct()
    {
        $this->cache = Cache::for('breadcrumbs', MONTH_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
        if (JVB_TESTING) {
            $this->cache->flush();
        }
        $this->cache->flush();
        $this->cache = Cache::for('breadcrumbs');
        $this->termCache = Cache::for('breadcrumbs_tax', 'term');
        $this->postCache = Cache::for('breadcrumbs_post', 'post');
        $this->userCache = Cache::for('breadcrumbs_user', 'user');
    }
    public static function getInstance(): self
@@ -53,12 +56,18 @@
        $key = match(true) {
            is_singular() => get_queried_object_id(),
            is_tax() => get_queried_object()->taxonomy,
            is_tax() => get_queried_object()->term_id,
            is_post_type_archive() => get_queried_object()->name,
            default => 'home'
        };
        $cache = match(true) {
            is_singular()   => $this->postCache,
            is_tax()        => $this->termCache,
            is_author()     => $this->userCache,
            default         => $this->cache
        };
        return $this->cache->remember(
        return $cache->remember(
            $key,
            function() {
                $crumbs = $this->buildCrumbs();
@@ -111,9 +120,9 @@
                $content = is_array($registrar->registrar->for) ? $registrar->registrar->for[0] : $registrar->registrar->for;
                $contentRegistrar = Registrar::getInstance($content);
                if($contentRegistrar && $contentRegistrar->hasFeature('show_directory')) {
                    $directory = JVB()->directories();
                    if ($directory && !empty($directory->directories($content)??[])){
                if(Site::has('is_directory') && $contentRegistrar && $contentRegistrar->hasFeature('show_directory')) {
                    $directory = DirectoryManager::getInstance();
                    if (!empty($directory->directories($content)??[])){
                        $crumbs[] = [
                            'name'  => $directory->directories($content)['title'],
                            'url'   =>$directory->directories($content)['url']
@@ -135,7 +144,7 @@
        // Add directory if exists
        if ($registrar && $registrar->hasFeature('show_directory')) {
            $directory = JVB()->directories();
            $directory = DirectoryManager::getInstance();
            if ($directory && !empty($directory->directories($tax)??[])) {
                $crumbs[] = [
                    'name'  => $directory->directories($tax)['title'],
@@ -162,14 +171,14 @@
        }
        // Handle directory posts specially
        if (JVB()->directories() && JVB()->directories()->isDirectory()) {
        if (Site::has('is_directory') && DirectoryManager::getInstance()->isDirectory()) {
            $pos = jvbGetDirectoryInfo();
            if (!empty($pos)) {
                // Special case for map
                if ($pos['title'] == 'Map') {
                    $crumbs[] = [
                        'name' => 'Tattoo Shops',
                        'url'  => JVB()->directories()->directories(BASE.'shop')['url']
                        'url'  => DirectoryManager::getInstance()->directories(BASE.'shop')['url']
                    ];
                }
@@ -203,8 +212,8 @@
        $registrar = Registrar::getInstance($name);
        if($registrar && $registrar->hasFeature('show_directory') && JVB()->directories()) {
            $directory = JVB()->directories();
        if($registrar && $registrar->hasFeature('show_directory') && DirectoryManager::getInstance()) {
            $directory = DirectoryManager::getInstance();
            if ($directory && !empty($directory->directories($name)??[])){
                $crumbs[] = [
@@ -215,7 +224,7 @@
        } elseif (Site::has('is_directory') && $name === 'directory') {
            $crumbs[] = [
                'name'  => JVB()->directories()->referAs(true),
                'name'  => DirectoryManager::getInstance()->referAs(true),
                'url'   => get_post_type_archive_link($type)
            ];
        } elseif ($registrar) {
@@ -224,11 +233,22 @@
                'url'   => get_post_type_archive_link($type)
            ];
        } else {
            $postTypeObject = get_post_type_object($type);
            $crumbs[] = [
                'name'  => $postTypeObject->label,
                'url'   => get_post_type_archive_link($type)
            ];
            if (function_exists('is_shop') && is_shop()) {
                $ID = get_option( 'woocommerce_shop_page_id' );
                $crumbs[] = [
                    'name'  => get_the_title($ID),
                    'url'   => get_the_permalink($ID)
                ];
            } else {
                $postTypeObject = get_post_type_object($type);
                $crumbs[] = [
                    'name'  => $postTypeObject->label,
                    'url'   => get_post_type_archive_link($type)
                ];
            }
        }
        return $crumbs;
@@ -371,17 +391,6 @@
        ];
    }
    /**
     * Invalidate breadcrumb cache for specific object
     */
    public function invalidateCache(?int $objectId = null): void
    {
        if ($objectId) {
            $this->cache->forget($objectId);
        } else {
            $this->cache->flush();
        }
    }
    public function addTaxToCrumbs(array $crumbs, string|array $taxonomy):array
    {
inc/managers/SEO/SEOAdminPage.php
@@ -19,6 +19,7 @@
 */
class SEOAdminPage
{
    use Instance;
//    private SchemaBuilder $registry;
    public function __construct()
@@ -55,7 +56,7 @@
    public static function renderAdminPageStatic():void
    {
        JVB()->seoAdmin()->renderAdminPage();
        SEOAdmin::getInstance()->renderAdminPage();
    }
    /**
inc/managers/SEO/_edmonotonink.php
@@ -1,5 +1,7 @@
    <?php
    if (!defined('ABSPATH')) {
        exit;
    }
/**
 * Edmonton.ink Configuration
 *
inc/managers/SEO/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
//New System
require(JVB_DIR . '/inc/managers/SEO/render/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/BreadcrumbManager.php');
inc/managers/SEO/render/DataType/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/DataType/Date.php');
require(JVB_DIR . '/inc/managers/SEO/render/DataType/DateTime.php');
require(JVB_DIR . '/inc/managers/SEO/render/DataType/Time.php');
inc/managers/SEO/render/DataType/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/SchemaOutput.php
@@ -1,17 +1,22 @@
<?php
namespace JVBase\managers\SEO\render;
use JVBase\base\SchemaHelper;
use JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\SEO\BreadcrumbManager;
use JVBase\managers\SEO\render\Thing\Intangible\Service;
use JVBase\managers\SEO\render\Thing\Organization\LocalBusiness\LocalBusiness;
use JVBase\registrar\config\seo\Resolver;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
class SchemaOutput {
    use Instance;
    protected array $types;
    public function __construct() {
    protected function __construct() {
        add_action('wp_head', [$this, 'outputSchema'], 0);
        $this->init();
    }
@@ -91,7 +96,7 @@
        if (!empty($schema)) {
            if (!is_front_page()) {
                $website = JVB()->schemaHelper()::schema('website');
                $website = SchemaHelper::getInstance()::schema('website');
                if (!empty($website)) {
                    if (JVB_TESTING) {
                        Cache::for('websiteSchema')->flush();
@@ -117,8 +122,8 @@
    protected function websiteSchema():array
    {
        $stored = JVB()->schemaHelper()::schema('website');
        $website = JVB()->schemaHelper()::classFromConfig($stored);
        $stored = SchemaHelper::getInstance()::schema('website');
        $website = SchemaHelper::getInstance()::classFromConfig($stored);
        if (!$website->getUrl() || empty($website->getUrl())){
            $website->setUrl(get_home_url());
@@ -164,7 +169,7 @@
        if (JVB_TESTING){
            Cache::for('websiteSchema')->flush();
        }
        $storedWebsite = JVB()->schemaHelper()::schema('website');
        $storedWebsite = SchemaHelper::getInstance()::schema('website');
        if (!$full) {
            return Cache::for('websiteSchema')->remember(
@@ -175,7 +180,7 @@
                        return in_array($key, $allowed);
                    }, ARRAY_FILTER_USE_KEY);
                    $website = JVB()->schemaHelper()::classFromConfig($storedWebsite);
                    $website = SchemaHelper::getInstance()::classFromConfig($storedWebsite);
                    if (!$website->getName() || empty($website->getName())) {
                        $website->setName(get_bloginfo('name'));
                    }
@@ -207,7 +212,7 @@
        return Cache::for('websiteSchema')->remember(
            'full',
            function () use ($storedWebsite) {
                $website = JVB()->schemaHelper()::classFromConfig($storedWebsite);
                $website = SchemaHelper::getInstance()::classFromConfig($storedWebsite);
                if (!$website->getName() || empty($website->getName())) {
                    $website->setName(get_bloginfo('name'));
                }
@@ -307,9 +312,9 @@
        if ($reference) {
            return $this->getOptionSchemaReference('organization');
        }
        $config = JVB()->schemaHelper()::schema('organization');
        $config = SchemaHelper::getInstance()::schema('organization');
        return JVB()->schemaHelper()::classFromConfig($config);
        return SchemaHelper::getInstance()::classFromConfig($config);
    }
    public function getOptionSchemaReference(string $option):mixed
@@ -323,7 +328,7 @@
            return null;
        }
        $stored = JVB()->schemaHelper()::reference($option);
        $stored = SchemaHelper::getInstance()::reference($option);
        if (empty($stored)){
            error_log('Attempted to get schema reference for: '.$option.', but defaults not set.');
@@ -354,9 +359,9 @@
    public function buildOrganizationSchema():array
    {
        $config = JVB()->schemaHelper()::schema('organization');
        $config = SchemaHelper::getInstance()::schema('organization');
        $class = JVB()->schemaHelper()::classFromConfig($config);
        $class = SchemaHelper::getInstance()::classFromConfig($config);
        return ($class)? $class->outputSchema() : [];
    }
}
inc/managers/SEO/render/Thing/Action/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/Action.php');
    require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/InteractAction.php');
        require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/CommunicateAction.php');
inc/managers/SEO/render/Thing/Action/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/Article/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
$base = JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Article/';
require ($base.'Article.php');
require ($base.'Report.php');
inc/managers/SEO/render/Thing/CreativeWork/Article/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/Comment/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Comment.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Answer.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Question.php');
inc/managers/SEO/render/Thing/CreativeWork/Comment/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/MediaObject/MediaObject.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/MediaObject/AudioObject.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/MediaObject/ImageObject.php');
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/VisualArtwork.php');
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/WebPage/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/CreativeWork/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/CreativeWork.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Article/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php');
inc/managers/SEO/render/Thing/CreativeWork/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Event/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Event/Event.php');
inc/managers/SEO/render/Thing/Event/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/Brand/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Brand/Brand.php');
inc/managers/SEO/render/Thing/Intangible/Brand/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/ContactPoint/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ContactPoint/PostalAddress.php');
inc/managers/SEO/render/Thing/Intangible/ContactPoint/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/Enumeration/_setup.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Enumeration/Enumeration.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Enumeration/AdultOrientedEnumeration.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Enumeration/BusinessEntityType.php');
inc/managers/SEO/render/Thing/Intangible/Enumeration/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/ItemList/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/ItemList.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/BreadcrumbList.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/OfferCatalog.php');
inc/managers/SEO/render/Thing/Intangible/ItemList/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/Quantity/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Quantity/Distance.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Quantity/Duration.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Quantity/Mass.php');
inc/managers/SEO/render/Thing/Intangible/Quantity/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/Rating/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Rating/Rating.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Rating/AggregateRating.php');
inc/managers/SEO/render/Thing/Intangible/Rating/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/Reservation/_setup.php
@@ -1,3 +1,6 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Reservation/Reservation.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Reservation/FoodEstablishmentReservation.php');
inc/managers/SEO/render/Thing/Intangible/Reservation/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/StructuredValue/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/StructuredValue.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/ContactPoint.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/GeoCoordinates.php');
inc/managers/SEO/render/Thing/Intangible/StructuredValue/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Intangible/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Brand/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ContactPoint/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Enumeration/_setup.php');
inc/managers/SEO/render/Thing/Intangible/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/MedicalEntity/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
$base = JVB_DIR . '/inc/managers/SEO/render/Thing/MedicalEntity/';
require ($base.'MedicalEntity.php');
require ($base.'MedicalProcedure.php');
inc/managers/SEO/render/Thing/MedicalEntity/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/FoodEstablishment.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/Bakery.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/BarOrPub.php');
inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/MedicalBusiness.php');
inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Organization/LocalBusiness/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/LocalBusiness.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/FoodEstablishment/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/MedicalBusiness/_setup.php');
inc/managers/SEO/render/Thing/Organization/LocalBusiness/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Organization/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/Organization.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Organization/LocalBusiness/_setup.php');
inc/managers/SEO/render/Thing/Organization/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Person/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Person/Person.php');
inc/managers/SEO/render/Thing/Person/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Place/AdministrativeArea/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/AdministrativeArea.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/City.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/Country.php');
inc/managers/SEO/render/Thing/Place/AdministrativeArea/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Place/_setup.php
@@ -1,3 +1,6 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/Place.php');
inc/managers/SEO/render/Thing/Place/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Product/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Product/Product.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Product/ProductGroup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Product/ProductModel.php');
inc/managers/SEO/render/Thing/Product/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/Properties/_setup.php
@@ -1,3 +1,6 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Properties/about.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Properties/alternativeHeadline.php');
inc/managers/SEO/render/Thing/Properties/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Thing/_setup.php
@@ -1,5 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Thing.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/_setup.php');
inc/managers/SEO/render/Traits/Organization/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/Organization/OrganizationSchema.php');
inc/managers/SEO/render/Traits/Organization/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Traits/Place/_setup.php
@@ -1,2 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/Place/PlaceSchema.php');
inc/managers/SEO/render/Traits/Place/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Traits/ThingSchema.php
@@ -135,7 +135,7 @@
    public static function createImageFromID(int $ID):ImageObject|false
    {
        $cache = Cache::for('schemaImage')->connect('post');
        $cache = Cache::for('schemaImage', 'post');
        return $cache->remember(
            $ID,
            function() use($ID) {
@@ -168,4 +168,5 @@
    {
        $this->showID = $showIt;
    }
}
inc/managers/SEO/render/Traits/_Helpers/_setup.php
@@ -1,3 +1,6 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Helpers/enumerationHelper.php');
inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php
@@ -28,7 +28,7 @@
        return array_filter(array_map(function($item) {
            if (is_array($item) && array_key_exists('type', $item)) {
                return JVB()->schemaHelper()::classFromConfig($item);
                return SchemaHelper::getInstance()::classFromConfig($item);
            } else {
                return $item;
            }
inc/managers/SEO/render/Traits/_Helpers/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Traits/_Properties/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/aboutTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/abstractTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/acceptedAnswerTrait.php');
inc/managers/SEO/render/Traits/_Properties/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/Traits/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Helpers/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/ThingSchema.php');
inc/managers/SEO/render/Traits/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/SEO/render/_setup.php
@@ -1,4 +1,7 @@
<?php
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/DataType/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/_setup.php');
inc/managers/SEO/render/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/managers/ScriptLoader.php
@@ -1,5 +1,11 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
use JVBase\base\Site;
use JVBase\registrar\Registrar;
use JVBase\rest\routes\LoginRoutes;
add_action('init', 'jvbRegisterScripts', 5);
@@ -570,3 +576,206 @@
        $strategy
    );
}
/**
 * Scripts
 */
add_action('wp_enqueue_scripts', 'jvbScripts', 10);
function jvbScripts():void
{
    add_action('wp_head', 'jvbInlineNavStyles');
    if (Site::has('dashboard')) {
        wp_enqueue_script('jvb-queue');
    }
    wp_enqueue_script('jvb-auth');
    wp_enqueue_script('jvb-settings');
    wp_enqueue_script('jvb-navigation');
//    wp_enqueue_script('jvb-ui');
//    wp_enqueue_script('jvb-media');
    wp_enqueue_script('jvb-gallery');
    wp_enqueue_script('jvb-cache');
    $interactions = [];
    if (Site::has('favourites')) {
        $interactions[] = 'favourites';
    }
    if (!empty(Registrar::withFeature('karma'))) {
        $interactions[] = 'karma';
    }
    if (Site::has('notifications')) {
        $interactions[] = 'notifications';
    }
    if (!empty($interactions)) {
        foreach($interactions as $interaction) {
            wp_enqueue_script('jvb-'.$interaction);
        }
    }
    $queue = [
        'api' => rest_url('jvb/v1/'),
        'redirect' => get_home_url(null, '/login/'),
        'labels' => Registrar::getLabels(),
    ];
    wp_localize_script('jvb-auth', 'jvbSettings', $queue);
    $initUserSettings = 'async function initUserItems() {
    if (!jvbSettings.currentUser) return;
    window.userFavourites = {};
    window.userVotes = {};
    ';
    if (Site::has('favourites')) {
        wp_enqueue_script('jvb-favourites');
        $initUserSettings .= '
        //Fetch user favourites
        try {
            const result = await window.jvbCache.fetchWithCache(
                `${jvbSettings.api}favourites?all=true`,
                {
                    method: \'GET\',
                    headers: {\'X-WP-Nonce\': jvbSettings.nonce}
                }
            );
            if (result && result.favourites) {
                for (const key in result.favourites) {
                    window.userFavourites[key] = new Set();
                    result.favourites[key].forEach(id => {
                        window.userFavourites[key].add(parseInt(id));
                    });
                }
            }
        } catch (error) {
            console.error(\'Failed to fetch user favourites:\', error);
        }';
    }
    if (!empty(Registrar::withFeature('karma'))) {
        wp_enqueue_script('jvb-votes');
        $initUserSettings .= '// Fetch user votes
        try {
            const result = await window.jvbCache.fetchWithCache(
                `${jvbSettings.api}vote?user=${jvbSettings.currentUser}`,
                {
                    method: \'GET\',
                    headers: {\'X-WP-Nonce\': jvbSettings.nonce}
                },
                {forceRefresh: true}
            );
            window.userVotes = {};
            if (result) {
                for (const key in result) {
                    window.userVotes[key] = new Map();
                    for (const [id, vote] of Object.entries(result[key])) {
                        window.userVotes[key].set(parseInt(id), vote);
                    }
                }
            }
        } catch (error) {
            console.error(\'Failed to fetch user votes:\', error);
        }';
    }
    $initUserSettings .= '} initUserItems();';
//  wp_add_inline_script('jvb-queue', $initUserSettings, 'after');
    if (is_search() ||
        is_tax(BASE.'shop') ||
        is_singular(BASE.'artist') ||
        jvbIsDirectory()
    ) {
        wp_enqueue_script('jvb-page-nav');
    }
    if (has_block('jvb/summaryBlock')) {
        wp_enqueue_script('jvb-page-nav');
    }
    // Only load on single shop pages or other relevant pages
    if (is_tax(BASE.'shop') ||
        is_singular(BASE.'partner')) {
        $ID = get_queried_object_id();
        if (is_tax()) {
            $check = get_term_meta($ID, BASE.'has_map', true);
        } else {
            $check = get_post_meta($ID, BASE.'has_map', true);
        }
//        if ($check && $check !== '') {
////            wp_enqueue_script(
////                'google-maps-frontend',
////                'https://maps.googleapis.com/maps/api/js?key=' . GOOGLE_API . '&callback=googleMapsLoaded',
////                [],
////                null,
////                ['strategy' => 'async',
////                 'in_footer' => true]
////            );
//
//            // Add the callback function
//            wp_add_inline_script('google-maps-frontend', '
//            function googleMapsLoaded() {
//                if (window.googleMapsCallbacks && window.googleMapsCallbacks.length) {
//                    window.googleMapsCallbacks.forEach(callback => callback());
//                }
//            }
//        ');
//        }
    }
    if (is_user_logged_in() && Site::has('notifications')) {
        wp_enqueue_script('jvb-notifications');
        wp_localize_script('jvb-notifications', 'notificationSettings', array(
            'homeUrl' => home_url(),
            'strings' => array(
                'loading' => 'Loading...',
                'error' => 'Failed to load notifications',
                'noNotifications' => 'No new notifications',
                'viewAll' => 'View All Notifications',
                'markRead' => 'Mark as read',
                'review' => 'Review'
            )
        ));
    }
//    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-queue');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-selector');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-data-store');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-cache');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-a11y');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-utility');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-gallery');
//    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-loading');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-queue');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-quill');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-form');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-tabs');
    jvbAddScriptDependency('jvb-timeline-view-script', 'jvb-gallery');
}
function jvbAddScriptDependency(string $handle, string $dep):bool
{
    global $wp_scripts;
    $script = $wp_scripts->query($handle);
    if (!$script) {
        return false;
    }
    if (!in_array($dep, $script->deps)) {
        $script->deps[] = $dep;
    }
    return true;
}
inc/managers/TaxonomyRelationships.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use Exception;
use JVBase\Instance;
use WP_Error;
use WP_Post;
use WP_Query;
@@ -12,15 +13,15 @@
class TaxonomyRelationships
{
    use Instance;
    protected Cache $cache;
    protected CustomTable $relationships;
    public function __construct()
    private function __construct()
    {
        $this->defineTables();
        $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS);
        $this->cache->connect('terms');
        $this->cache = Cache::for('term_relationship', 'term', WEEK_IN_SECONDS);
        // Ensure the table exists
//        $this->create_table_if_not_exists();
@@ -196,7 +197,7 @@
    {
        $term_posts = $this->cache->remember(
            $this->cache->generateKey(['term' => $term_id, 'taxonomy' => $taxonomy]),
            $this->cache->generateKey(['id' => $term_id, 'taxonomy' => $taxonomy]),
            function () use ($term_id, $taxonomy) {
                global $wpdb;
                return $wpdb->get_col($wpdb->prepare(
@@ -211,7 +212,7 @@
        $related_term_posts = $this->cache->remember(
            $this->cache->generateKey(['term' => $related_term_id, 'taxonomy' => $related_taxonomy]),
            $this->cache->generateKey(['id' => $related_term_id, 'taxonomy' => $related_taxonomy]),
            function () use($related_term_id, $related_taxonomy) {
                global $wpdb;
                return $wpdb->get_col($wpdb->prepare(
@@ -292,7 +293,7 @@
        // Queue the operation
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $queue->queueOperation(
            'taxonomy_relationships',
inc/managers/UploadManager.php
@@ -1,9 +1,14 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\base\Site;
use JVBase\Instance;
use Exception;
use Imagick;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\mergers\UploadMerger;
use JVBase\managers\queue\Queue;
use JVBase\managers\queue\TypeConfig;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -11,11 +16,32 @@
}
/**
 * Flexible file upload manager for images, videos, and documents
 * Supports configurable directory structures and processing options
 * The goal here was mainly for membership sites, to organize files by userID, rather than the date structure.
 * This way, if a user deletes their account, or performs a data request, we can simply copy their directory.
 *
 * I started with user name so we could search by name, but issues arose if a user changed their name/display name. So I set it to go to user_id.
 *
 * The general flow is:
 *      -> Upload, ensure files are stored in appropriate directory, usually {root}/tmp
 *      -> Conversion is queued with Queue, as with the /tmp cleanup
 *
 * Proposed directory structure:
 *      Site::has('membership') || Site::has('is_directory')
 *          -> defaults to {user_id}/{post_type}/file
 *      Default:
 *          -> defaults to {post_type}/file
 *              Drawback: if a post is uploaded to one post type, then used in another, the organization is slightly flawed.
 *
 * Also, we wanted to ensure images were converted to webp, for speed.
 *      -> The front-end javascript attempts to do this in browser
 *      -> If this couldn't be handled, it is queued for server processing
 *  Of course, some integrations (even google, sillies) only accept jpeg/png, so in some cases we need a jpeg fallback
 *
 *
 */
class UploadManager
{
    use Instance;
    protected array $allowedTypes = [
        // Images
        'image/jpeg'    => 'image',
@@ -73,16 +99,179 @@
        'original_retention' => 2592000, // 30 days
    ];
    /******************************************************************
     * UPLOAD SETTINGS
    ******************************************************************/
    /**
     * @var array|int[] Max size of file types
     */
    protected array $maxSize = [
        'image'     => 5242880,     // 5MB
        'video'     => 104857600,  // 100MB
        'document'  => 15728640 // 15MB
    ];
    //Image Specific
    /**
     * @var bool Whether to convert the image
     */
    protected bool $convertImage = true;
    /**
     * @var string Default image conversion
     */
    protected string $convertImageTo = 'webp';
    /**
     * @var int Default image quality
     */
    protected int $convertQuality = 80;
    /**
     * @var bool Whether to optimize images
     */
    protected bool $optimizeImages = true;
    /**
     * @var bool Whether to create image thumbnails
     */
    protected bool $createThumbnails = true;
    //Video specific
    /**
     * @var bool Whether to automatically extract a thumbnail from a video
     */
    protected bool $extractVideoThumbnail = true;
    /**
     * @var int Time, in microseconds, to grab thumbnail from
     */
    protected int $videoThumbnailPosition = 0;
    //Document specific
    /**
     * @var bool Whether to extract thumbnail from document.
     */
    protected bool $extractDocumentThumbnail = false;
    /**
     * @var int How long to keep originals (default 3 months)
     */
    protected int $keep_originals;
    /******************************************************************
     * UPLOAD DIRECTORIES
    ******************************************************************/
    /**
     * @var string WordPress' base upload directory
     */
    protected string $upload_dir;
    /**
     * @var string WordPress' base upload directory url
     */
    protected string $upload_url;
    public function __construct()
    protected string $dir_pattern;
    /*****************************************************************
     * FEATURE FLAG
    *****************************************************************/
    protected bool $has_imagick = false;
    private function __construct()
    {
        $upload = wp_upload_dir();
        $this->upload_dir = $upload['basedir'];
        $this->upload_url = $upload['baseurl'];
        // Check for Imagick
        $this->defaultConfig['use_imagick'] = extension_loaded('imagick');
        if (Site::has('membership') || Site::has('is_directory')) {
            $this->dir_pattern = '{user_id}/{post_type}';
        } else {
            $this->dir_pattern = '{post_type}';
        }
        $this->has_imagick = extension_loaded('imagick');
        $this->keep_originals = MONTH_IN_SECONDS * 3;
        add_action('init', [$this, 'registerUploadExecutors'], 5);
    }
    /**
     * Register upload operation types with the queue's TypeRegistry
     */
    public function registerUploadExecutors(): void
    {
        $registry = Queue::getInstance()->registry();
        $executor = new UploadExecutor();
        $merger = new UploadMerger('secured_files');
        // Image uploads - chunked at 5 files
        $registry->register('image_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 3
        ));
        // Video uploads - one at a time (heavy processing)
        $registry->register('video_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 1
        ));
        // Document uploads - chunked at 10
        $registry->register('document_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 5
        ));
        // Metadata updates
        $registry->register('update_image_meta', new TypeConfig(
            executor: $executor
        ));
        // Cleanup - chunked at 5
        $registry->register('temporary_cleanup', new TypeConfig(
            executor: $executor,
            chunkKey: 'files',
            chunkSize: 5
        ));
        // Attach to content (depends on upload completing)
        $registry->register('attach_upload_to_content', new TypeConfig(
            executor: $executor
        ));
        // Process upload groups into posts
        $registry->register('process_upload_groups', new TypeConfig(
            executor: $executor,
            chunkKey: 'posts',
            chunkSize: 5
        ));
    }
    /**
     * @param string|array $file_path
     * @param array $context
     * @return ?string
     * @throws Exception
     */
    protected function testFile(string|array $file_path, array $context):?string
    {
        $file_type = null;
        if (is_array($file_path)) {
            if (!isset($file_path['tmp_name']) || $file_path['error'] !== UPLOAD_ERR_OK) {
                throw new Exception('Invalid file upload');
            }
            $file_path = $file_path['tmp_name'];
        }
        // Validate file upload
        if (!isset($file_path)) {
            throw new Exception('Invalid file upload');
        }
        $mime_type = mime_content_type($file_path);
        $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
        if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
        }
        // Check allowed types if specified
        if (array_key_exists('allowed_types', $context) && !in_array($mime_type, $context['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
        }
        return $file_type;
    }
    /**
@@ -94,26 +283,10 @@
    public function secureUploadedFile(array $file_data, array $context): array
    {
        try {
            // Validate file upload
            if (!isset($file_data['tmp_name']) || $file_data['error'] !== UPLOAD_ERR_OK) {
                throw new Exception('Invalid file upload');
            }
            $file_type = $this->testFile($file_data, $context);
            $mime_type = mime_content_type($file_data['tmp_name']);
            $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
            if ($file_type === 'unknown') {
                throw new Exception('Unsupported file type: ' . $mime_type);
            }
            // Check allowed types if specified
            if ($this->defaultConfig['allowed_types'] && !in_array($mime_type, $this->defaultConfig['allowed_types'])) {
                throw new Exception('File type not allowed: ' . $mime_type);
            }
            $temp_dir = $this->generateDirectory($file_type, "{user_id}/temp", $context);
            // Ensure directories exist
            $this->ensureDirectories($temp_dir);
            $context['pattern'] = $this->dir_pattern.'/tmp';
            $temp_dir = $this->generateDirectory($file_type, $context);
            // Generate a unique filename for temporary storage
            $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
@@ -129,15 +302,15 @@
            // Return metadata about the stored file
            return [
                'temp_path' => $temp_path,
                'temp_path'     => $temp_path,
                'original_name' => $file_data['name'],
                'mime_type' => $file_data['type'],
                'file_type' => $file_type,
                'file_size' => $file_data['size'],
                'stored_at' => current_time('mysql')
                'mime_type'     => $file_data['type'],
                'file_type'     => $file_type,
                'file_size'     => $file_data['size'],
                'stored_at'     => current_time('mysql')
            ];
        } catch (Exception $e) {
            JVB()->error()->log('Failed to store uploaded file: ' . print_r($e->getMessage(), true));
            Errors::getInstance()->log('UploadManager','Failed to store uploaded file: ' . print_r($e->getMessage(), true));
            return [];
        }
    }
@@ -152,60 +325,32 @@
    public function processUpload(string $temp_path, array $context): array|WP_Error
    {
        try {
            // Validate temp file exists
            if (!file_exists($temp_path)) {
                throw new Exception('Temporary file not found: ' . $temp_path);
            }
            // Validate MIME type
            $mime_type = mime_content_type($temp_path);
            if (!$this->validateMimeType($temp_path, $mime_type)) {
                throw new Exception('Invalid or potentially dangerous file type');
            }
            $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
            if ($file_type === 'unknown') {
                throw new Exception('Unsupported file type: ' . $mime_type);
            }
            // Merge config
            $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context);
            $file_type = $this->testFile($temp_path, $context);
            // Extract context
            $user_id = $context['user_id'] ?? get_current_user_id();
            $post_id = $context['post_id'] ?? 0;
            // Check allowed types if specified
            if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) {
                throw new Exception('File type not allowed: ' . $mime_type);
            }
            // Validate file size
            $this->validateFileSize($temp_path, $file_type, $config);
            $this->validateFileSize($temp_path, $file_type, $context);
            // Generate directory structure for final storage
            $directory = $this->generateDirectory($file_type, $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}', $context);
            // Ensure directories exist
            $this->ensureDirectories($directory);
            $directory = $this->generateDirectory($file_type, $context);
            // Generate filename
            $original_name = $context['original_name'] ?? basename($temp_path);
            $filename = $this->generateFilename($original_name, $context);
            // Store original from temp
            $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename);
            // Process based on file type
            return match($file_type) {
                'image' => $this->processImage($original_path, $directory, $filename, $post_id, $config, $context),
                'video' => $this->processVideo($original_path, $directory, $filename, $post_id, $config, $context),
                'document' => $this->processDocument($original_path, $directory, $filename, $post_id, $config, $context),
                'image' => $this->processImage($temp_path, $directory, $filename, $post_id, $context),
                'video' => $this->processVideo($temp_path, $directory, $filename, $post_id, $context),
                'document' => $this->processDocument($temp_path, $directory, $filename, $post_id, $context),
                default => throw new Exception('Unknown file type')
            };
        } catch (Exception $e) {
            JVB()->error()->log(
            Errors::getInstance()->log(
                '[UploadManager]:processUpload',
                $e->getMessage(),
                ['temp_path' => $temp_path, 'context' => $context]
@@ -215,21 +360,6 @@
        }
    }
    /**
     * Store original file from temp location (not from PHP upload)
     */
    protected function storeOriginalFromTemp(string $temp_path, string $directory, string $filename): string
    {
        $ext = pathinfo($temp_path, PATHINFO_EXTENSION);
        $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
        // Copy from temp to final location
        if (!copy($temp_path, $original_path)) {
            throw new Exception('Failed to store original file');
        }
        return $original_path;
    }
    /**
     * Clean up empty temporary directories
@@ -262,24 +392,52 @@
            @rmdir($temp_base);
        }
    }
    /**
     * Generate directory structure based on pattern
     * @param string $file_type
     * @param array $config
     * @return string
     * @throws Exception
     */
    protected function generateDirectory(string $file_type, string $pattern, array $context): string
    public function generateDirectory(string $file_type, array $config): string
    {
        $replacements = [
            '{user_id}' => $context['user_id'] ?? get_current_user_id(),
            '{post_type}' => $context['content'] ?? '',
            '{content}' => $context['content'] ?? '',
            '{file_type}' => $file_type,
            '{year}' => date('Y'),
            '{month}' => date('m'),
        ];
        $pattern = array_key_exists('pattern', $config) && is_string($config['pattern']) ? $config['pattern'] : $this->dir_pattern;
        $directory = str_replace(array_keys($replacements), array_values($replacements), $pattern);
        $pattern = explode('/', $pattern);
        $parts = [];
        foreach ($pattern as $part) {
            $newPart = match($part) {
                '{user_id}'                 => $context['user_id'] ?? null,
                '{post_type}', '{content}'  => $context['content'] ?? null,
                '{tax}', '{taxonomy}'       => $context['taxonomy'] ?? null,
                '{term}'                    => $context['term'] ?? null,
                '{file_type}'               => $file_type,
                '{year}'                    => date('Y'),
                '{month}'                   => date('m'),
                '{day}'                     => date('d'),
                default                     => $part
            };
            if (is_null($newPart)) {
                error_log('[Uploader] Pattern key was not provided for '.$part);
            }
            if (str_contains($newPart, '{')) {
                error_log('[Uploader] Unhandled pattern found: '. $newPart);
            }
            $newPart = $this->sanitizeFileName($newPart);
            $parts[] = $newPart;
        }
        //Remove any null values
        $parts = array_filter($parts);
        $directory = implode('/', $parts);
        // Allow filtering
        return apply_filters('jvb_upload_directory', $directory, $file_type, $context);
        $directory = apply_filters('jvb_upload_directory', $directory, $file_type, $config);
        // Ensure directories exist
        $this->ensureDirectories($directory);
        return $directory;
    }
    /**
@@ -288,8 +446,7 @@
    protected function ensureDirectories(string $rel_path): void
    {
        $dirs = [
            "{$this->upload_dir}/{$rel_path}",
            "{$this->upload_dir}/{$rel_path}/originals"
            "{$this->upload_dir}/{$rel_path}"
        ];
        foreach ($dirs as $dir) {
@@ -299,28 +456,26 @@
        }
    }
    /**
     * Store original file
     */
    protected function storeOriginal(string $tmp_file, string $directory, string $filename): string
    {
        $ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
        $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
        if (!move_uploaded_file($tmp_file, $original_path)) {
            throw new Exception('Failed to store original file');
        }
        return $original_path;
    }
    /**
     * Validate file size based on type and config
     * @param string $file_path
     * @param string $file_type
     * @param array $config
     * @return void
     * @throws Exception
     */
    protected function validateFileSize(string $file_path, string $file_type, array $config): void
    {
        $size = filesize($file_path);
        $max_size = $config['max_size'][$file_type] ?? $this->defaultConfig['max_size'][$file_type];
        $max_size = $this->maxSize[$file_type];
        if (array_key_exists('max_size', $config)) {
            if (is_int($config['max_size'])) {
                $max_size = $config['max_size'];
            } else if (is_array($config['max_size']) && array_key_exists($file_type, $config['max_size'])) {
                $max_size = $config['max_size'][$file_type];
            }
        }
        if ($size > $max_size) {
            $max_mb = round($max_size / 1048576, 2);
@@ -354,7 +509,7 @@
     * Process image file
     * @throws Exception
     */
    protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
    protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $context): array
    {
        $full_dir = "{$this->upload_dir}/{$directory}";
        $original_mime = mime_content_type($original_path);
@@ -363,17 +518,18 @@
        $should_convert = false;
        $target_format = false;
        if ($config['convert'] && $config['convert'] !== false) {
            $target_format = strtolower($config['convert']);
            // Validate format
            if (!isset($this->supportedFormats[$target_format])) {
                throw new Exception("Unsupported conversion format: {$target_format}");
        if ((array_key_exists('convert', $context) && $context['convert'] !== false) || $this->convertImage) {
            $target_format = empty($context['convertTo']??'') ? $this->convertImage : $context['convertTo'];
            if (!empty($target_format)) {
                // Validate format
                if (!isset($this->supportedFormats[$target_format])) {
                    error_log('[Uploader] Unsupported conversion format: '.$target_format.'... will not convert');
                } else {
                    // Check if conversion is needed
                    $target_mime = $this->supportedFormats[$target_format];
                    $should_convert = ($original_mime !== $target_mime);
                }
            }
            // Check if conversion is needed
            $target_mime = $this->supportedFormats[$target_format];
            $should_convert = ($original_mime !== $target_mime);
        }
        // Convert or copy based on configuration
@@ -381,15 +537,18 @@
            $final_path = "{$full_dir}/{$filename}.{$target_format}";
            try {
                $this->convertImage(
                    $original_path,
                    $final_path,
                    $target_format,
                    $config['quality'],
                    $config['use_imagick']
                    array_key_exists('quality', $context) && is_int($context['quality']) && $context['quality'] <= 100 ?
                        $context['quality'] :
                        $this->convertQuality,
                );
            } catch (Exception $e) {
                JVB()->error()->log('Image conversion failed: ' . print_r($e->getMessage(), true));
                Errors::getInstance()->log('Uploader', 'Image conversion failed: ' . print_r($e->getMessage(), true));
                // Fallback to original
                $ext = pathinfo($original_path, PATHINFO_EXTENSION);
                $final_path = "{$full_dir}/{$filename}.{$ext}";
@@ -412,17 +571,19 @@
        }
        // Generate thumbnails
        if ($config['create_thumbnails']) {
        $hasOption = array_key_exists('create_thumbnails', $context);
        if (($hasOption && $context['create_thumbnails']) || (!$hasOption && $this->createThumbnails)) {
            $this->generateThumbnails($attachment_id);
        }
        return [
            'success' => true,
            'success'   => true,
            'attachment_id' => $attachment_id,
            'url' => wp_get_attachment_url($attachment_id),
            'file' => $final_path,
            'type' => 'image',
            'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION)
            'url'       => wp_get_attachment_url($attachment_id),
            'file'      => $final_path,
            'type'      => 'image',
            'format'    => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION),
            'context'   => $context
        ];
    }
@@ -430,7 +591,7 @@
     * Process video file
     * @throws Exception
     */
    protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
    protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $context): array
    {
        $full_dir = "{$this->upload_dir}/{$directory}";
        $ext = pathinfo($original_path, PATHINFO_EXTENSION);
@@ -443,29 +604,32 @@
        try {
            $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
        } catch (Exception $e) {
            JVB()->error()->log('Attachment creation failed: '.print_r($e->getMessage(), true));
            Errors::getInstance()->log('Uploader', 'Attachment creation failed: '.print_r($e->getMessage(), true));
            return ['success' => false];
        }
        // Extract thumbnail if enabled
        if ($config['extract_video_thumbnail']) {
            $this->extractVideoThumbnail($attachment_id, $final_path, $config['video_thumbnail_time']);
        $hasOption = array_key_exists('extract_thumbnail', $context);
        if (($hasOption && $context['extract_thumbnail']) || (!$hasOption && $this->extractVideoThumbnail)) {
            $time = $context['thumbnail_time']??$this->videoThumbnailPosition;
            $this->extractVideoThumbnail($attachment_id, $final_path, $time);
        }
        return [
            'success' => true,
            'success'   => true,
            'attachment_id' => $attachment_id,
            'url' => wp_get_attachment_url($attachment_id),
            'file' => $final_path,
            'type' => 'video'
            'url'       => wp_get_attachment_url($attachment_id),
            'file'      => $final_path,
            'type'      => 'video',
            'context'   => $context
        ];
    }
    /**
     * Process document file
     */
    protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
    protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $context): array
    {
        $full_dir = "{$this->upload_dir}/{$directory}";
        $ext = pathinfo($original_path, PATHINFO_EXTENSION);
@@ -478,36 +642,36 @@
        try {
            $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
        } catch (Exception $e) {
            JVB()->error()->log('Conversion failed: '.print_r($e->getMessage(), true));
            Errors::getInstance()->log('Uploader','Conversion failed: '.print_r($e->getMessage(), true));
            return ['success' => false];
        }
        return [
            'success' => true,
            'success'   => true,
            'attachment_id' => $attachment_id,
            'url' => wp_get_attachment_url($attachment_id),
            'file' => $final_path,
            'type' => 'document'
            'url'       => wp_get_attachment_url($attachment_id),
            'file'      => $final_path,
            'type'      => 'document',
            'context'   => $context
        ];
    }
    /**
     * Generate SEO-friendly filename
     */
    protected function generateFilename(string $original_name, array $context): string
    public function generateFilename(string $original_name, array $context): string
    {
        $name_parts = pathinfo($original_name);
        $base_name = sanitize_title($name_parts['filename']);
        $base_name = $this->sanitizeFileName($name_parts['filename']);
        $user_data = array_key_exists('user_id', $context) ? get_userdata((int)$context['user_id']) : get_current_user();
        $username = $user_data ? sanitize_title($user_data->display_name) : 'user';
        $timestamp = time();
        return apply_filters(
        return $this->sanitizeFileName(apply_filters(
            'jvb_upload_filename',
            "{$base_name}-{$timestamp}",
            $context
        );
        ));
    }
    protected function sanitizeFileName(string $filename): string
@@ -570,7 +734,7 @@
     * @param bool $replace Replace original file (default: true)
     * @return array|WP_Error Result with new attachment details
     */
    public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = true): array|WP_Error
    public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = false): array|WP_Error
    {
        try {
            // Validate format
@@ -608,8 +772,7 @@
            $new_path = $path_info['dirname'] . '/' . $new_filename;
            // Perform conversion
            $use_imagick = $this->defaultConfig['use_imagick'];
            $this->convertImage($original_path, $new_path, $format, $quality, $use_imagick);
            $this->convertImage($original_path, $new_path, $format, $quality);
            if ($replace) {
                // Replace the original file
@@ -645,16 +808,31 @@
    /**
     * Generic image conversion method
     */
    protected function convertImage(string $source, string $destination, string $format, int $quality, bool $use_imagick): void
    /**
     * @param string $source
     * @param string $destination
     * @param string $format
     * @param int $quality
     * @return void
     * @throws Exception
     */
    protected function convertImage(string $source, string $destination, string $format, int $quality): void
    {
        if ($use_imagick) {
        if ($this->has_imagick) {
            $this->convertWithImagick($source, $destination, $format, $quality);
        } else {
            $this->convertWithGd($source, $destination, $format, $quality);
        }
    }
    /**
     * Convert image with Imagick (supports multiple formats)
     * Convert image with Imagick
     * @param string $source
     * @param string $destination
     * @param string $format
     * @param int $quality
     * @return void
     * @throws Exception
     */
    protected function convertWithImagick(string $source, string $destination, string $format, int $quality): void
    {
@@ -678,14 +856,19 @@
            $image->writeImage($destination);
            $image->clear();
            $image->destroy();
        } catch (Exception $e) {
            throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage());
        }
    }
    /**
     * Convert image with GD (supports multiple formats)
     * Convert image with GD
     * @param string $source
     * @param string $destination
     * @param string $format
     * @param int $quality
     * @return void
     * @throws Exception
     */
    protected function convertWithGd(string $source, string $destination, string $format, int $quality): void
    {
@@ -750,160 +933,4 @@
        // Placeholder for future implementation
        apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time);
    }
    /**
     * Phase 1: Validate and process file on disk (no DB writes)
     * Safe to run outside transactions â€” only does file I/O
     *
     * @return array Prepared file info for registerFile()
     */
    public function prepareFile(string $temp_path, array $context): array
    {
        if (!file_exists($temp_path)) {
            throw new Exception('Temporary file not found: ' . $temp_path);
        }
        $mime_type = mime_content_type($temp_path);
        if (!$this->validateMimeType($temp_path, $mime_type)) {
            throw new Exception('Invalid or potentially dangerous file type');
        }
        $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
        if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
        }
        $config  = array_merge($this->defaultConfig, $context['config'] ?? [], $context);
        $post_id = $context['post_id'] ?? 0;
        if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
        }
        $this->validateFileSize($temp_path, $file_type, $config);
        $directory = $this->generateDirectory(
            $file_type,
            $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}',
            $context
        );
        $this->ensureDirectories($directory);
        $original_name = $context['original_name'] ?? basename($temp_path);
        $filename      = $this->generateFilename($original_name, $context);
        $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename);
        // File-type-specific I/O (conversion, copying) â€” no DB
        $final_path = match ($file_type) {
            'image'    => $this->prepareImage($original_path, $directory, $filename, $config),
            'video'    => $this->prepareMedia($original_path, $directory, $filename),
            'document' => $this->prepareMedia($original_path, $directory, $filename),
            default    => throw new Exception('Unknown file type'),
        };
        return [
            'final_path' => $final_path,
            'file_type'  => $file_type,
            'filename'   => $filename,
            'post_id'    => $post_id,
            'config'     => $config,
            'context'    => $context,
        ];
    }
    /**
     * Phase 2: Register processed file in WordPress (DB writes only)
     * Quick operation â€” individual WP functions handle their own queries
     *
     * @param array $prepared Output from prepareFile()
     * @return array Standard upload result
     */
    public function registerFile(array $prepared): array
    {
        $attachment_id = $this->createAttachment(
            $prepared['final_path'],
            $prepared['filename'],
            $prepared['context'],
            $prepared['post_id']
        );
        // Type-specific post-registration
        if ($prepared['file_type'] === 'image') {
            $alt_text = apply_filters('jvb_upload_alt_text', '', $prepared['context']);
            if ($alt_text !== '') {
                update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
            }
        }
        if ($prepared['file_type'] === 'video' && ($prepared['config']['extract_video_thumbnail'] ?? false)) {
            $this->extractVideoThumbnail(
                $attachment_id,
                $prepared['final_path'],
                $prepared['config']['video_thumbnail_time'] ?? 0
            );
        }
        return [
            'success'       => true,
            'attachment_id' => $attachment_id,
            'url'           => wp_get_attachment_url($attachment_id),
            'file'          => $prepared['final_path'],
            'type'          => $prepared['file_type'],
        ];
    }
    /**
     * Image file I/O: convert or copy to final location
     */
    protected function prepareImage(string $original_path, string $directory, string $filename, array $config): string
    {
        $full_dir      = "{$this->upload_dir}/{$directory}";
        $original_mime = mime_content_type($original_path);
        $should_convert = false;
        $target_format  = false;
        if ($config['convert'] && $config['convert'] !== false) {
            $target_format = strtolower($config['convert']);
            if (!isset($this->supportedFormats[$target_format])) {
                throw new Exception("Unsupported conversion format: {$target_format}");
            }
            $should_convert = ($original_mime !== $this->supportedFormats[$target_format]);
        }
        if ($should_convert) {
            $final_path = "{$full_dir}/{$filename}.{$target_format}";
            try {
                $this->convertImage($original_path, $final_path, $target_format, $config['quality'], $config['use_imagick']);
            } catch (Exception $e) {
                JVB()->error()->log('Image conversion failed: ' . $e->getMessage());
                $ext = pathinfo($original_path, PATHINFO_EXTENSION);
                $final_path = "{$full_dir}/{$filename}.{$ext}";
                copy($original_path, $final_path);
            }
        } else {
            $ext = pathinfo($original_path, PATHINFO_EXTENSION);
            $final_path = "{$full_dir}/{$filename}.{$ext}";
            copy($original_path, $final_path);
        }
        return $final_path;
    }
    /**
     * Video/document file I/O: copy to final location
     */
    protected function prepareMedia(string $original_path, string $directory, string $filename): string
    {
        $full_dir   = "{$this->upload_dir}/{$directory}";
        $ext        = pathinfo($original_path, PATHINFO_EXTENSION);
        $final_path = "{$full_dir}/{$filename}.{$ext}";
        copy($original_path, $final_path);
        return $final_path;
    }
}
inc/managers/UserTermsManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Error;
@@ -12,10 +13,10 @@
}
class UserTermsManager
{
    use Instance;
    protected CustomTable $index;
    public function __construct()
    private function __construct()
    {
        $this->defineTables();
@@ -188,7 +189,7 @@
            ];
        }
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'rebuild_user_term_index',
            0,
            [
@@ -223,7 +224,7 @@
            ];
        }
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'rebuild_user_term_index',
            0,
            [
inc/managers/VerifyEntryManager.php
@@ -5,13 +5,20 @@
    exit;
}
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
use JVBase\rest\Rest;
use JVBase\rest\routes\ApprovalRoutes;
class VerifyEntryManager {
    use Instance;
    protected CustomTable $table;
    protected int $max = 3;
    public function __construct()
    protected Rest $route;
    private function __construct()
    {
        $this->route = ApprovalRoutes::getInstance();
        $this->defineTables();
    }
@@ -113,7 +120,7 @@
        ]);
        if ($request && !empty($owners)) {
            JVB()->notification()->notify($owners,'entry_requested',$userID, [
            NotificationManager::getInstance()->notify($owners,'entry_requested',$userID, [
                'target_id' => $termID,
                'target_type' => $taxonomy
            ]);
@@ -143,9 +150,9 @@
            wp_set_object_terms($request['content_id'], $termID, jvbCheckBase($request['taxonomy']));
        }
        JVB()->notification()?->notify($request['user_id'],$approve ? 'entry_approved' : 'entry_denied',$userID);
        NotificationManager::getInstance()?->notify($request['user_id'],$approve ? 'entry_approved' : 'entry_denied',$userID);
        //TODO: What if there are multiple managers, and one approved the request? Do we just delete the notification?
        JVB()->notification()?->unnotify($owners, 'entry_requested', $request['user_id'], [
        NotificationManager::getInstance()?->unnotify($owners, 'entry_requested', $request['user_id'], [
            'target_id' => $termID,
            'target_type' => $request['taxonomy']
        ]);
inc/managers/_setup.php
@@ -1,23 +1,26 @@
<?php
use JVBase\managers\ApprovalManager;
use JVBase\managers\Approvals;
use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
if (!defined('ABSPATH')) {
    exit;
}
require(JVB_DIR . '/inc/managers/ScriptLoader.php');
require(JVB_DIR . '/inc/managers/Errors.php');
require(JVB_DIR . '/inc/managers/CustomTable.php');
//require(JVB_DIR . '/inc/managers/CacheManager.php');
require(JVB_DIR . '/inc/managers/Cache.php');
require(JVB_DIR . '/inc/managers/IconsManager.php');
add_action('init', 'jvbInit', 0); // Priority 1 - very early
function jvbInit(): void
add_action('jvbloaded', 'jvbManagers'); // Priority 1 - very early
function jvbManagers(): void
{
    Cache::registerHooks();
//  Cache::registerHooks();
    // Initialize base sources (this registers hooks and includes defaults)
    IconsManager::for();
    IconsManager::for('forms');
@@ -28,7 +31,7 @@
    }
    if (Site::has('magic_link')) {
        require(JVB_DIR . '/inc/managers/MagicLinkManager.php');
        require(JVB_DIR . '/inc/managers/MagicLinks.php');
    }
    if (Site::hasAny(['dashboard', 'feed_block'])) {
@@ -51,7 +54,7 @@
    }
    if (!empty(Registrar::withFeature('approve_new'))) {
        require(JVB_DIR . '/inc/managers/ApprovalManager.php');
        require(JVB_DIR . '/inc/managers/Approvals.php');
    }
@@ -77,7 +80,7 @@
    }
    if (Site::has('referrals')) {
        require(JVB_DIR . '/inc/managers/ReferralManager.php');
        require(JVB_DIR . '/inc/managers/Referrals.php');
    }
    if (!empty(Registrar::withFeature('karma'))) {
@@ -93,10 +96,10 @@
function jvb_load_karma():void
{
}
require(JVB_DIR . '/inc/managers/ErrorHandler.php');
require(JVB_DIR . '/inc/managers/queue/_setup.php');
require(JVB_DIR . '/inc/managers/EmailManager.php');
require(JVB_DIR . '/inc/managers/LoginManager.php');
require(JVB_DIR . '/inc/managers/Emails.php');
require(JVB_DIR . '/inc/managers/Login.php');
//require(JVB_DIR . '/inc/managers/SchemaManager.php');
@@ -105,4 +108,4 @@
require(JVB_DIR . '/inc/managers/ImageGenerator.php');
require(JVB_DIR . '/inc/managers/AdminPages.php');
require(JVB_DIR . '/inc/managers/RoleManager.php');
require(JVB_DIR . '/inc/managers/Roles.php');
inc/managers/queue/Progress.php
@@ -19,7 +19,7 @@
            $this->operation->processedItems + $count,
            $this->operation->totalItems
        );
        JVB()->queue()->storage()->saveProgress($this->operation);
        Queue::getInstance()->storage()->saveProgress($this->operation);
    }
    public function failItem(mixed $item, string $reason): void
@@ -28,6 +28,6 @@
            'item' => $item,
            'reason' => $reason,
        ];
        JVB()->queue()->storage()->saveProgress($this->operation);
        Queue::getInstance()->storage()->saveProgress($this->operation);
    }
}
inc/managers/queue/Queue.php
@@ -4,21 +4,28 @@
    exit;
}
use JVBase\managers\CustomTable;
use JVBase\Instance;
use JVBase\rest\routes\QueueRoutes;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
class Queue
{
    use Instance;
    private Storage $storage;
    private Processor $processor;
    private TypeRegistry $registry;
    public Executor $executor;
    private Locker $locker;
    public function __construct()
    private QueueRoutes $routes;
    private function __construct()
    {
        //Load routes
        $this->routes = new QueueRoutes();
        $this->storage = new Storage();
        $this->registry = new TypeRegistry();
        $this->locker = new Locker();
@@ -422,7 +429,7 @@
    public function registerAdminAction():void
    {
        $admin = JVB()->admin();
        $admin = AdminPages::getInstance();
        $admin->registerAction(
            'Restart Stuck Operations',
            'restart-stuck-operations',
inc/managers/queue/Storage.php
@@ -19,7 +19,7 @@
    public function __construct()
    {
        $this->defineTables();
        $this->cache = Cache::for('queue', DAY_IN_SECONDS)->user();
        $this->cache = Cache::for('queue', null, DAY_IN_SECONDS)->user();
    }
    public function hasProcessingOperations(): bool
inc/managers/queue/TypeRegistry.php
@@ -22,7 +22,7 @@
    public function getExecutor(string $type): ?Executor
    {
        return array_key_exists($type, $this->configs) ? $this->configs[$type]->executor : JVB()->queue()->executor;
        return array_key_exists($type, $this->configs) ? $this->configs[$type]->executor : Queue::getInstance()->executor;
    }
    public function getMergeable(string $type): ?Mergeable
inc/managers/queue/executors/ContentExecutor.php
@@ -125,7 +125,7 @@
        // Send notification
//      if (jvbSiteHasNotifications()) {
//          JVB()->notification()->addNotification(
//          NotificationManager::getInstance()->addNotification(
//              $this->userId,
//              'content_update_complete',
//              null,
@@ -596,7 +596,7 @@
    {
        error_log('Handling term '.$ID.' with data: '.print_r($data, true));
        //Existing term update
        if ($registrar->hasFeature('is_ownable') && (!JVB()->roles()->isOwner($this->userId, $ID) && !JVB()->roles()->isManager($this->userId, $ID))) {
        if ($registrar->hasFeature('is_ownable') && (!Roles::getInstance()()->isOwner($this->userId, $ID) && !Roles::getInstance()()->isManager($this->userId, $ID))) {
            error_log('Term is ownable. User does not own this term.');
            $progress->failItem($ID, 'No permission to modify this term');
            $results['errors'][$ID] = 'No permission';
inc/managers/queue/executors/ContentTermExecutor.php
@@ -6,7 +6,7 @@
use JVBase\managers\queue\Operation;
use JVBase\managers\queue\Progress;
use JVBase\managers\queue\Result;
use JVBase\managers\RoleManager;
use JVBase\managers\Roles;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use Exception;
@@ -25,11 +25,11 @@
 */
class ContentTermExecutor implements Executor
{
    protected RoleManager $roleManager;
    protected Roles $roleManager;
    public function __construct()
    {
        $this->roleManager = new RoleManager();
        $this->roleManager = new Roles();
    }
    public function execute(Operation $operation, Progress $progress): Result
@@ -319,7 +319,7 @@
        $user = get_userdata($userID);
        foreach ($managers as $managerID) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $managerID,
                $notificationType,
                [
inc/managers/queue/executors/IntegrationExecutor.php
@@ -18,7 +18,7 @@
 *          {integration}_sync_from, {integration}_sync_customer, etc.
 *
 * Registration (in Integrations::registerAdditionalHooks):
 *   $queue = JVB()->queue();
 *   $queue = Queue::getInstance();
 *   $queue->registry()->register('helcim_sync_to', new TypeConfig(
 *       executor: new IntegrationExecutor(),
 *       chunkKey: 'items',
inc/managers/queue/executors/InvitationExecutor.php
@@ -6,7 +6,7 @@
use JVBase\managers\queue\Operation;
use JVBase\managers\queue\Progress;
use JVBase\managers\queue\Result;
use JVBase\managers\RoleManager;
use JVBase\managers\Roles;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -16,13 +16,13 @@
class InvitationExecutor implements Executor
{
    protected CustomTable $table;
    protected RoleManager $roleManager;
    protected Roles $roleManager;
    protected int $expiryDays = 14;
    public function __construct()
    {
        $this->table = CustomTable::for('invitations');
        $this->roleManager = new RoleManager();
        $this->roleManager = new Roles();
    }
    public function execute(Operation $operation, Progress $progress): Result
inc/managers/queue/executors/UploadExecutor.php
@@ -1,13 +1,13 @@
<?php
namespace JVBase\managers\queue\executors;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\managers\queue\{Executor, Operation, Progress, Queue, Result};
use JVBase\managers\Cache;
use JVBase\managers\Errors;
use JVBase\managers\UploadManager;
use JVBase\meta\Meta;
use Exception;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -31,10 +31,16 @@
        'process_upload_groups'
    ];
    /**
     * @param Operation $operation
     * @param Progress $progress
     * @return Result
     * @throws Exception
     */
    public function execute(Operation $operation, Progress $progress): Result
    {
        if (!in_array($operation->type, self::HANDLED_TYPES)) {
            throw new Exception("UploadExecutor cannot handle type: {$operation->type}");
            throw new Exception("UploadExecutor cannot handle type: $operation->type");
        }
        try {
@@ -48,11 +54,11 @@
                'temporary_cleanup'       => $this->processTemporaryCleanup($operation, $data, $progress),
                'attach_upload_to_content'=> $this->processAttachToContent($operation, $data, $progress),
                'process_upload_groups'   => $this->processUploadGroups($operation, $data, $progress),
                default                   => throw new Exception("Unknown type: {$operation->type}")
                default                   => throw new Exception("Unknown type: $operation->type")
            };
        } catch (Exception $e) {
            JVB()->error()->log(
            Errors::getInstance()->log(
                '[UploadExecutor]:execute',
                $e->getMessage(),
                [
@@ -74,74 +80,32 @@
     */
    private function processFileUpload(Operation $operation, array $data, string $fileType, Progress $progress): Result
    {
        $uploader = new UploadManager();
        $processedResults = [];
        $uploader = UploadManager::getInstance();
        $errors = [];
        $uploadIds = [];
        $securedFiles = $data['secured_files'] ?? [];
        $args = $data;
        unset($args['secured_files']);
        // Phase 1: File I/O â€” no DB, no transactions
        $prepared = [];
        foreach ($securedFiles as $securedFile) {
            $uploadIds[] = $securedFile['upload_id'];
            try {
                $prepared[$securedFile['upload_id']] = $uploader->prepareFile(
                $prepared[$securedFile['upload_id']] = $uploader->processUpload(
                    $securedFile['temp_path'],
                    [
                        'file_type'     => $fileType,
                        'user_id'       => $operation->userId,
                        'post_id'       => (int)($data['post_id'] ?? 0),
                        'term_id'       => (int)($data['term_id'] ?? 0),
                        'original_name' => $securedFile['original_name'],
                        'mime_type'     => $securedFile['mime_type'],
                        'content'       => $data['content'] ?? '',
                    ]
                    $args
                );
                $progress->advance();
            } catch (Exception $e) {
                $progress->failItem($securedFile['upload_id'], $e->getMessage());
                $errors[] = $e->getMessage();
            }
        }
        // Phase 2: DB registration â€” quick per-file writes
        foreach ($prepared as $uploadId => $fileInfo) {
            try {
                $result = $uploader->registerFile($fileInfo);
                if (!$result['success']) {
                    $progress->failItem($uploadId, 'Registration failed');
                    $errors[] = "Failed to register {$uploadId}";
                    continue;
                }
                $processedResults[$uploadId] = [
                    'upload_id'     => $uploadId,
                    'attachment_id' => $result['attachment_id'] ?? 0,
                    'url'           => $result['url'] ?? '',
                    'sizes'         => $result['sizes'] ?? [],
                ];
                // Apply frontend metadata if provided
                $securedFile = $this->findSecuredFile($securedFiles, $uploadId);
                if ($securedFile && !empty($securedFile['metadata'])) {
                    $this->applyMeta($result['attachment_id'], $securedFile['metadata']);
                }
                $progress->advance();
            } catch (Exception $e) {
                $progress->failItem($uploadId, $e->getMessage());
                $errors[] = $e->getMessage();
            }
        }
        // Destination + cleanup unchanged
        $this->handleUploadDestination($data, $processedResults);
        $this->cleanupTempFiles($securedFiles, $operation->userId);
        $outcome = count($processedResults) > 0 ? 'success' : 'failed';
        if (count($processedResults) > 0 && !empty($errors)) {
        $outcome = count($prepared) > 0 ? 'success' : 'failed';
        if (count($prepared) > 0 && !empty($errors)) {
            $outcome = 'partial';
        }
@@ -149,8 +113,8 @@
            outcome: $outcome,
            result: [
                'upload_ids'      => $uploadIds,
                'uploads'         => $processedResults,
                'processed_count' => count($processedResults),
                'uploads'         => $prepared,
                'processed_count' => count($prepared),
                'total_count'     => count($uploadIds),
                'errors'          => $errors,
            ]
@@ -158,20 +122,12 @@
    }
    /**
     * Find a secured file entry by upload_id
     */
    private function findSecuredFile(array $securedFiles, string $uploadId): ?array
    {
        foreach ($securedFiles as $file) {
            if (($file['upload_id'] ?? null) === $uploadId) {
                return $file;
            }
        }
        return null;
    }
    /**
     * Attach upload results to content
     * @param Operation $operation
     * @param array $data
     * @param Progress $progress
     * @return Result
     * @throws Exception
     */
    private function processAttachToContent(Operation $operation, array $data, Progress $progress): Result
    {
@@ -181,9 +137,9 @@
            throw new Exception('No upload operation ID provided');
        }
        $uploadOp = JVB()->queue()->get($uploadOpId);
        $uploadOp = Queue::getInstance()->get($uploadOpId);
        if (!$uploadOp || !in_array($uploadOp->outcome, ['success', 'partial'])) {
            throw new Exception("Upload operation {$uploadOpId} not completed successfully");
            throw new Exception("Upload operation $uploadOpId not completed successfully");
        }
        $uploadResults = $uploadOp->result['uploads'] ?? [];
@@ -194,7 +150,7 @@
        // Resolve post_id from content_update dependency for new posts
        if (empty($data['post_id']) || str_starts_with((string)($data['item_id'] ?? ''), 'new')) {
            foreach ($operation->dependencies as $depId) {
                $dep = JVB()->queue()->get($depId);
                $dep = Queue::getInstance()->get($depId);
                if ($dep && $dep->type === 'content_update' && !empty($dep->result['new_posts'])) {
                    $itemId = $data['item_id'] ?? null;
                    if ($itemId && isset($dep->result['new_posts'][$itemId])) {
@@ -213,20 +169,13 @@
            if ($data['field_name'] === 'timeline_gallery') {
                $registrar = Registrar::getInstance($data['content']);
                if ($registrar) {
                    switch ($registrar->getType()) {
                        case 'post':
                            $meta = Meta::forPost($data['item_id']);
                            break;
                        case 'term':
                            $meta = Meta::forTerm($data['item_id']);
                            break;
                        case 'user':
                            $meta = Meta::forUser($data['item_id']);
                            break;
                        default:
                            $meta = false;
                    $meta = match ($registrar->getType()) {
                        'post' => Meta::forPost($data['item_id']),
                        'term' => Meta::forTerm($data['item_id']),
                        'user' => Meta::forUser($data['item_id']),
                        default => false,
                    };
                    }
                    if ($meta) {
                        $title = $meta->get('post_title');
                        $current = $meta->get('number');
@@ -295,7 +244,7 @@
            try {
                if (array_key_exists('depends_on', $info)) {
                    // Get the dependency operation to find attachment ID
                    $depOp = JVB()->queue()->get($info['depends_on']);
                    $depOp = Queue::getInstance()->get($info['depends_on']);
                    if (!$depOp || !$depOp->result) {
                        $errors[] = "Dependency {$info['depends_on']} not found or has no result";
                        continue;
@@ -313,6 +262,10 @@
                    $errors[] = "No attachment found for: ".print_r($info, true);
                    continue;
                }
                $postID = wp_get_post_parent_id($attachmentId);
                if ($postID && !in_array($postID, $postsAttachedTo)){
                    Cache::clearPost($postID);
                }
                $this->applyMeta($attachmentId, $info);
                $updatedCount++;
@@ -323,20 +276,6 @@
                $progress->failItem($uploadId, $e->getMessage());
                $errors[] = $e->getMessage();
            }
            if ($success && $attachmentId) {
                $postID = wp_get_post_parent_id($attachmentId);
                if ($postID && !in_array($postID, $postsAttachedTo)){
                    $postsAttachedTo[] = $postID;
                    //TODO: is there a better way?
                }
            }
        }
        if (!empty ($postsAttachedTo)) {
            foreach ($postsAttachedTo as $postId) {
                Cache::invalidateItem('post', $postId);
            }
        }
        $outcome = $updatedCount > 0 ? 'success' : 'failed';
@@ -354,11 +293,14 @@
    }
    /**
     * Cleanup temporary upload files
     * @param Operation $operation
     * @param array $data
     * @param Progress $progress
     * @return Result
     */
    private function processTemporaryCleanup(Operation $operation, array $data, Progress $progress): Result
    {
        $uploader = new UploadManager();
        $uploader = UploadManager::getInstance();
        // Cleanup secured files
        if (!empty($data['files'])) {
@@ -402,7 +344,7 @@
        $uploads = [];
        $uploadIds = [];
        foreach ($dependencies as $dependency) {
            $res = JVB()->queue()->getOperationValue($dependency, 'result');
            $res = Queue::getInstance()->getOperationValue($dependency, 'result');
            if (empty($res)) {
                continue;
            }
@@ -695,6 +637,11 @@
    // Helper methods
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    /**
     * @param int $attachmentId
     * @param array $metadata
     * @return void
     */
    private function applyMeta(int $attachmentId, array $metadata): void
    {
        $postUpdates = [];
@@ -779,25 +726,6 @@
    }
    private function updateFieldValue(array $data, array $results): void
    {
        if (empty($data['field_name'])) {
            return;
        }
        $attachmentIds = array_column($results, 'attachment_id');
        $meta = $this->getMetaManager($data);
        if (!$meta) {
            return;
        }
        $existing = $meta->get($data['field_name']);
        $existingIds = !empty($existing) ? explode(',', $existing) : [];
        $allIds = array_unique(array_merge($existingIds, $attachmentIds));
        $meta->set($data['field_name'], implode(',', $allIds));
    }
    private function getMetaManager(array $data): ?Meta
    {
        if (!empty($data['post_id'])) {
@@ -815,6 +743,11 @@
        return null;
    }
    /**
     * @param array $data
     * @param array $results
     * @return array
     */
    private function createIndividualPosts(array $data, array $results): array
    {
        if (empty($data['content'])) {
@@ -869,7 +802,7 @@
    private function cleanupTempFiles(array $securedFiles, int $userId): void
    {
        $uploader = new UploadManager();
        $uploader = UploadManager::getInstance();
        foreach ($securedFiles as $secured) {
            if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
inc/managers/queue/mergers/DefaultMerger.php
@@ -3,7 +3,9 @@
use JVBase\managers\queue\Mergeable;
use JVBase\managers\queue\Operation;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Default merger, uses the chunkKey as the mergeable array
 */
inc/managers/queue/mergers/UploadMerger.php
@@ -3,7 +3,9 @@
use JVBase\managers\queue\Mergeable;
use JVBase\managers\queue\Operation;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Field-aware merger for upload operations.
 * Ensures uploads only merge when targeting the same field + post/term context.
inc/meta/Form.php
@@ -2,6 +2,8 @@
namespace JVBase\meta;
use DateTime;
use JVBase\base\Site;
use JVBase\integrations\services\GoogleMaps;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
@@ -1448,7 +1450,10 @@
    protected static function renderLocation(string $name, mixed $value, array $config): string
    {
        $googleMaps = JVB()->connect('maps');
        if (!Site::hasIntegration('maps')) {
            return '';
        }
        $googleMaps = GoogleMaps::getInstance();
        if (!$googleMaps->isSetUp()) {
            return '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
        }
inc/meta/Meta.php
@@ -132,14 +132,134 @@
            $meta = [];
        }
        $meta = array_map(fn($value) => maybe_unserialize($value[0]), $meta);
        $this->fields = [];
        if (class_exists('WooCommerce') && $this->type === 'post' && $this->wpObject->post_type??'' === 'product') {
            $fields['post_title'] = [
                'type'  => 'text',
                'label' => 'Title',
            ];
            $fields['post_content'] = [
                'type'  => 'text',
                'label' => 'Content',
            ];
            $fields['post_excerpt'] = [
                'type'  => 'text',
                'label' => 'Short Description',
            ];
            $fields['post_id'] = [
                'type'  => 'text',
                'label' => 'ID',
            ];
            $fields['_stock'] = [
                'type' => 'number',
                'label' => 'Stock',
                'default'   => null
            ];
            $fields['_stock_status'] = [
                'type'  => 'select',
                'label' => 'Stock Status',
                'default'   => 'instock',
                'options'   => [
                    'instock'   => 'In Stock',
                    'outofstock'    => 'Out of Stock',
                    'onbackorder'       => 'On Backorder'
                ]
            ];
            $fields['_price'] = [
                'type'      => 'text',
                'subtype'   => 'number',
                'label'     =>  'Price'
            ];
            $product = wc_get_product($id);
            $fields['sku'] = [
                'type'  => 'text',
                'label' => 'SKU',
            ];
            $out = [];
            if ($product && $product->is_type('variable')) {
                $var = $product->get_children();
                $var = array_filter(array_map(function($v) {
                    return wc_get_product($v);
                }, $var));
                $title= $product->get_title();
                foreach ($var as $variation) {
                    $out[] = [
                        'id'    => $variation->get_id(),
                        'sku'   => $variation->get_sku(),
                        'title' => str_replace($title.' - ', '', $variation->get_name()),
                        'price' => $variation->get_price(),
                        'description'=> $variation->get_short_description(),
                        'sale'  => $variation->get_sale_price(),
                        'img'   => $variation->get_image_id(),
                        'stock' => $variation->get_stock_quantity(),
                        'stock_status'  => $variation->get_stock_status()
                    ];
                }
            }
            if (!empty($out)) {
                $this->fields['variations'] = new Field('variations', $out, [
                    'type'  => 'repeater',
                    'label' => 'Variations',
                    'fields'    => [
                        'id'    => [
                            'type'  => 'text',
                            'subtype'   => 'number',
                            'label' => 'Variation ID',
                        ],
                        'sku'   => [
                            'type'  => 'text',
                            'label' => 'Stock Keeping Unit (SKU)'
                        ],
                        'title' => [
                            'type'  => 'text',
                            'label' => 'Variation Title'
                        ],
                        'price' => [
                            'type'  => 'text',
                            'subtype'   => 'number',
                            'label' => 'Variation Price'
                        ],
                        'sale'  => [
                            'type'  => 'text',
                            'subtype'   => 'number',
                            'label' => 'Sale Price',
                        ],
                        'image' => [
                            'type'  => 'text',
                            'subtype'   => 'number',
                            'label' => 'Variation Image ID'
                        ],
                        'stock' => [
                            'type'  => 'text',
                            'label' => 'Stock Quantity'
                        ],
                        'stock_status'  => [
                            'type'  => 'text',
                            'label' => 'Stock Status'
                        ]
                    ]
                ]);
            }
        }
        foreach ($fields as $fieldName => $config) {
            $fieldName = jvbNoBase($fieldName);
            $value = '';
            if ($this->type === 'option') {
                $value = get_option(BASE . $fieldName, $config['default'] ?? '');
            } else if (
                $this->wpObject && property_exists($this->wpObject, $fieldName)
                || $type === 'user' && $this->wpObject && property_exists($this->wpObject->data, $fieldName)) {
                ($this->wpObject && property_exists($this->wpObject, $fieldName))
                || ($type === 'user' && $this->wpObject && property_exists($this->wpObject->data, $fieldName))) {
                $config['wp'] = true;
                $value = $this->wpObject->$fieldName;
inc/meta/_setup.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
//REFACTOR
require(JVB_DIR . '/inc/meta/Meta.php');        //Main facade
require(JVB_DIR . '/inc/meta/Item.php');        //Data container for one object
inc/meta/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
inc/registrar/Labels.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/Registrar.php
@@ -61,7 +61,7 @@
        //Shared Flags
        'favouritable', 'karma', 'show_feed', 'show_directory', 'approve_new', 'has_responses', 'invitable',
        //Post Flags
        'hide_single', 'redirect_to_author', 'is_calendar', 'single_image', 'is_timeline', 'is_gallery', 'is_faq', 'is_glossary', 'rewrite_taxonomy', 'add_image_column',
        'hide_single', 'redirect_to_author', 'is_calendar', 'single_image', 'is_timeline', 'is_gallery', 'is_faq', 'is_glossary', 'rewrite_taxonomy', 'add_image_column', 'is_profile',
        //Taxonomy Flags
        'is_content', 'is_ownable', 'verify_entry', 'track_changes', 'associate_user_content', 'prefix_post_type',
        //User Flags
@@ -191,6 +191,7 @@
     * @var bool Whether to generate a profile for this user role
     */
    public bool $profile_link;
    public bool $is_profile = false;
    /**
     * @var array|string
     */
@@ -228,6 +229,8 @@
    protected MakeTrackChanges $trackChanges;
    protected MakeVerification $verification;
    protected string $umami_event;
    private static array $instances = [];
    protected string $based;
@@ -905,6 +908,11 @@
            $this->karmaManager = KarmaManager::for($this->slug, $this->type);
        }
    }
    public function getKarmaManager():?KarmaManager
    {
        return $this->karmaManager;
    }
    public static function getInstance(string $slug):Registrar|false
    {
        self::ensureInstanced();
@@ -1220,8 +1228,16 @@
        $this->profile = $slug;
        return Registrar::forPost($slug, $singular, $plural);
        $reg = Registrar::forPost($slug, $singular, $plural);
        $reg->setProfile();
        return $reg;
    }
        public function setProfile():void
        {
            $this->is_profile = true;
        }
    public function getProfile():self|false
    {
        if (!$this->profile_link) {
@@ -1322,4 +1338,46 @@
    {
        $this->hasIntegrations = $has;
    }
    public function isOwnable():bool
    {
        return $this->is_ownable??false;
    }
    public function umamiEvent(string $event):void
    {
        $this->umami_event = str_replace('-', '_', sanitize_title($event));
    }
    public function getUmamiEvent():string
    {
        return $this->umami_event??$this->getDefaultUmamiType();
    }
        protected function getDefaultUmamiType():string
        {
            return match($this->type) {
                'post'  => $this->getDefaultPostUmamiType(),
                'term'  => $this->getDefaultTermUmamiType(),
                'user'  => $this->getDefaultUserUmamiType(),
                default => ''
            };
        }
            protected function getDefaultPostUmamiType():string
            {
                if ($this->is_profile) {
                    return 'view_profile';
                }
                return 'view_content';
            }
            protected function getDefaultTermUmamiType():string
            {
                if ($this->is_content) {
                    return 'view_collective';
                }
                return 'view_term';
            }
            protected function getDefaultUserUmamiType():string
            {
                return 'view_user';
            }
}
inc/registrar/Users.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/_setup.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
require_once(JVB_DIR . '/inc/registrar/config/Config.php');
require_once(JVB_DIR . '/inc/registrar/config/Breadcrumbs.php');
require_once(JVB_DIR . '/inc/registrar/config/Dashboard.php');
inc/registrar/config/Breadcrumbs.php
@@ -2,8 +2,6 @@
namespace JVBase\registrar\config;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
inc/registrar/config/Integration.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\registrar\config;
use JVBase\integrations\ServiceMap;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
@@ -9,11 +10,13 @@
}
final class Integration extends Config{
    use ServiceMap;
    /**
     * @var string Must match as defined in the JVBase\integrations namespace
     */
    protected string $service_name;
    protected ?string $content_type = null;
    /**
     * @var bool Whether to send to the integration on publish
     */
@@ -54,7 +57,7 @@
        if (is_null($content)) {
            return $this;
        }
        $connection = JVB()->connect($this->service_name);
        $connection = $this->getService($this->service_name);
        if (!$connection){
            error_log('[Integration]::setContentType Service is not setup. '.$this->service_name);
            return $this;
@@ -62,7 +65,8 @@
        $allowed = $connection->getAllowedContent();
        if (!in_array($content, $allowed)) {
            error_log($this->service_name.' Connection does not support this content: '.$content);
            //TODO
//          error_log($this->service_name.' Connection does not support this content: '.$content);
            return $this;
        }
        $this->content_type = $content;
@@ -105,7 +109,6 @@
    public function setCustomer(bool $set):self
    {
        $this->isCustomer = $set;
//      JVB()->connect($this->service_name)->addCustomerFields();
        return $this;
    }
inc/registrar/config/Management.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/config/Responses.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/config/TrackChanges.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/config/Verification.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/config/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/registrar/config/seo/Helpers.php
@@ -8,6 +8,9 @@
function jvbTSFDoIt(string $slug, ?array $args):bool
{
    if (!class_exists('THE_SEO_Framework')) {
        return false;
    }
    $tsf = tsf();
    return (null === $args)
inc/registrar/config/seo/Resolver.php
@@ -42,7 +42,7 @@
        switch ($variable) {
            case 'CREATOR':
                return JVB()->seo()->getCreator();
                return SchemaOutput::getInstance()->getCreator();
            case 'name':
                if (!$meta && is_post_type_archive()) {
                    $obj = get_queried_object();
@@ -265,7 +265,7 @@
                //TODO generate from id
            } else if (str_contains($value, 'CREATOR')) {
                return JVB()->seo()->getCreator();
                return SchemaOutput::getInstance()->getCreator();
            }
            return '';
        }
inc/registrar/config/seo/Schema.php
@@ -142,9 +142,9 @@
                    $config = $this->getConfig();
                    $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
                    $class = SchemaHelper::getInstance()::classFromConfig($config, $meta);
                    if (property_exists($class, 'author')) {
                        $class->setAuthor(JVB()->seo()->getCreator(true));
                        $class->setAuthor(SchemaOutput::getInstance()->getCreator(true));
                    }
@@ -165,7 +165,7 @@
                $ID,
                function() use ($ID) {
                    $action = BASE.ucfirst($this->slug).'Schema';
                    $config = JVB()->schemaHelper()::schema($action);
                    $config = SchemaHelper::getInstance()::schema($action);
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
@@ -174,7 +174,7 @@
                        error_log('No class found for archive schema output: '.$config['type']);
                        return [];
                    }
                    $class = JVB()->schemaHelper()::classFromConfig($config);
                    $class = SchemaHelper::getInstance()::classFromConfig($config);
                    $class->setIsPartOf(get_home_url().'/#website');
                    $itemList = new render\Thing\Intangible\ItemList\ItemList();
@@ -218,7 +218,7 @@
                    if (!empty($override)) {
                        return $override;
                    }
                    $config = JVB()->schemaHelper()->archive($this->slug);
                    $config = SchemaHelper::getInstance()->archive($this->slug);
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
                    }
@@ -239,7 +239,7 @@
                    $obj = get_queried_object();
                    $meta = (property_exists($obj, 'taxonomy')) ? Meta::forTerm($obj->term_id) : null;
                    $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
                    $class = SchemaHelper::getInstance()::classFromConfig($config, $meta);
                    $class->setIsPartOf(get_home_url().'/#website');
                    $itemList = new render\Thing\Intangible\ItemList\ItemList();
@@ -306,7 +306,7 @@
                        $meta = null;
                }
                $config = $this->getConfig();
                $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
                $class = SchemaHelper::getInstance()::classFromConfig($config, $meta);
                if ($class) {
                    $class->delete('about');
                }
@@ -335,7 +335,7 @@
            error_log('[SEO]Schema::getConfig Invalid type: '.$type);
            return [];
        }
        return JVB()->schemaHelper()::getConfig($this->slug, $type);
        return SchemaHelper::getInstance()::getConfig($this->slug, $type);
    }
    public function resolveMeta(array $config, Meta $meta):array
@@ -459,7 +459,7 @@
        $question = SchemaHelper::classFromConfig($question);
        $page->setMainEntity($question);
        $page->setAuthor(JVB()->seo()->getCreator(true));
        $page->setAuthor(SchemaOutput::getInstance()->getCreator(true));
        return $page->outputSchema();
    }
@@ -499,7 +499,7 @@
        }
        $page->setImage($images);
        $page->setAuthor(JVB()->seo()->getCreator(true));
        $page->setAuthor(SchemaOutput::getInstance()->getCreator(true));
        return $page->outputSchema();
    }
inc/registrar/config/seo/_setup.php
@@ -1,5 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
require(JVB_DIR . '/inc/registrar/config/seo/Helpers.php');
require(JVB_DIR . '/inc/registrar/config/seo/Resolver.php');
require(JVB_DIR . '/inc/registrar/config/seo/Archive.php');
inc/registrar/config/seo/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/registrar/fields/Calendar.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/registrar/fields/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/registrar/helpers/AddIntegrationFields.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\registrar\helpers;
use JVBase\integrations\ServiceMap;
use JVBase\registrar\config\Integration;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
@@ -10,6 +11,7 @@
}
class AddIntegrationFields {
    use ServiceMap;
    protected string $service_name;
    private Registrar $registrar;
    protected Integration $config;
@@ -65,6 +67,10 @@
    public function getIntegrationFields():array
    {
        if (!$this->getService($this->service_name)) {
            error_log('Attempted to connect to unsupported service: '.$this->service_name);
            return [];
        }
        $fields = [
            'share_to_'.$this->service_name => [
                'type'  => 'true_false',
@@ -131,7 +137,8 @@
                'default'   => 1,
            ];
        }
        $additional = JVB()->connect($this->service_name)->getAdditionalFields($this->config->getContentType());
        $additional = $this->getService($this->service_name)->getAdditionalFields($this->config->getContentType());
        if (!empty($additional)) {
            $additional = array_combine(
                array_map(fn($k) => str_starts_with($k, '_'.$this->service_name) ? $k : "_{$this->service_name}_{$k}", array_keys($additional)),
inc/registrar/helpers/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/registrar/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
inc/rest/RegisterRoutes.php
@@ -126,7 +126,7 @@
                'message'   => 'This shop doesn\'t exist?'
            ]);
        }
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        unset($data['user']);
        $operationID = $data['id'];
        unset($data['id']);
@@ -343,7 +343,7 @@
        //Notify managers
        JVB()->notification()->addNotification(
        NotificationManager::getInstance()->addNotification(
            $owners,
            'entry_request',
            [
inc/rest/Rest.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\rest;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
@@ -30,7 +32,7 @@
    protected string $namespace = 'jvb/v1';
    protected ?Cache $cache = null;
    protected string $cacheName = '';
    protected int $cacheTtl = 3600;
    protected int $cacheTtl = HOUR_IN_SECONDS;
    protected static ?string $action;
    public function __construct()
@@ -180,7 +182,7 @@
    /**
     * Convert MySQL datetime to ISO 8601 timestamp
     */
    protected function formatTimestamp(?string $mysqlDatetime): ?string
    public function formatTimestamp(?string $mysqlDatetime): ?string
    {
        return Response::formatTimestamp($mysqlDatetime);
    }
@@ -555,11 +557,11 @@
     */
    protected function verifyTurnstile(string $token): bool
    {
        if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
        if (!Site::hasIntegration('cloudflare') || !Cloudflare::getInstance()->isSetUp()) {
            return true;
        }
        return !empty($token) && JVB()->connect('cloudflare')->verifyTurnstile($token);
        return !empty($token) && Cloudflare::getInstance()->verifyTurnstile($token);
    }
    /**
inc/rest/RestRouteManager.php
@@ -587,12 +587,9 @@
            $key = $cache->generateKey($key);
        }
        // Prefer tag freshness if available
        $tags = $cache->getTags();
        $lastModified = $tags
            ? $cache->getLastModifiedForTags($tags)
            : $cache::lastModified($group);
        $lastModified = $cache::lastModified($group);
        if (!$lastModified) {
            return false;
inc/rest/_setup.php
@@ -1,5 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
use JVBase\registrar\Registrar;
use JVBase\base\Site;
@@ -10,10 +13,6 @@
require(JVB_DIR . '/inc/rest/Rest.php');
require(JVB_DIR . '/inc/rest/RateLimits.php');
//OLD METHOD
//require(JVB_DIR . '/inc/rest/RateLimiter.php');
//require(JVB_DIR . '/inc/rest/RestRouteManager.php');
//require(JVB_DIR . '/inc/rest/RegisterRoutes.php');
//if (Site::has('feed_block')) {
    require(JVB_DIR . '/inc/rest/routes/FeedRoutes.php');
inc/rest/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
inc/rest/routes/AdminRoutes.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\JVB;
use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
@@ -18,15 +19,13 @@
class AdminRoutes extends Rest
{
    protected array $fields;
    protected $meta;
    protected string $metaType;
    protected string $content;
    use Instance;
    public function __construct()
    {
        $this->cacheName = 'itsme';
//        $this->cacheName = 'itsme';
        parent::__construct();
//      $this->cache = Cache::for($this->cacheName, null, $this->cacheTtl);
    }
    public function registerRoutes():void
    {
@@ -70,25 +69,6 @@
        );
    }
    /**
     * @param array $filters
     *
     * @return array
     */
    protected function checkFilters(array $filters)
    {
        global $karma;
        $out = [];
        foreach ($filters as $type => $value) {
            if (!array_key_exists($type, $karma)) {
                continue;
            }
            $out[$type]  = jvbSanitizeIDList($value);
        }
        return $out;
    }
    /**
     * Handle cache-related actions
     */
inc/rest/routes/ApprovalRoutes.php
@@ -2,7 +2,10 @@
namespace JVBase\rest\routes;
use JVBase\managers\CustomTable;
use JVBase\Instance;
use JVBase\managers\Approvals;
use JVBase\managers\Cache;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
@@ -11,7 +14,6 @@
use JVBase\base\Site;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
@@ -19,6 +21,7 @@
class ApprovalRoutes extends Rest
{
    use Instance;
    protected array $userTypes;
    protected array $termTypes;
    protected array $allTypes;
@@ -31,6 +34,7 @@
        $this->cacheName = 'approvals';
        $this->hasMemberApproval = Site::membership() && Site::membership()->has('member_verified');
        parent::__construct();
        $this->cache = Cache::for('approvals', 'custom', $this->cacheTtl);
        $this->initTypes();
    }
@@ -106,7 +110,7 @@
        if (!in_array($vote, ['approve', 'reject', 'dismiss'])) {
            return false;
        }
        $result = JVB()->approvals()->markApproval($request_id, $user_id, $type, $vote, $notes);
        $result = Approvals::getInstance()->markApproval($request_id, $user_id, $type, $vote, $notes);
        return $result['success'];
    }
@@ -147,11 +151,11 @@
     */
    public function getVerificationDetails(int $requestID, string $type): array|false
    {
        $request = JVB()->approvals()->getRequest($requestID, $type);
        $request = Approvals::getInstance()->getRequest($requestID, $type);
        if (!$request) {
            return false;
        }
        $votes = JVB()->approvals()->getVotes($requestID, $type);
        $votes = Approvals::getInstance()->getVotes($requestID, $type);
        // Join with user data for display names
        foreach ($votes as &$vote) {
@@ -187,7 +191,7 @@
        int $required_approvals = 3
    ): int|false {
        $result = JVB()->approvals()->createApproval(
        $result = Approvals::getInstance()->createApproval(
            $user_id,
            $taxonomy,
            $name,
@@ -196,64 +200,16 @@
        return $result['success'];
    }
    protected function getTableName(string $type, string $suffix): string
    {
        return match ($type) {
            'term' => "approval_term_{$suffix}",
            default => "approval_{$type}_{$suffix}",
        };
    }
    public function getApprovals(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $type = sanitize_text_field($request->get_param('type') ?? 'all');
        $status = sanitize_text_field($request->get_param('status') ?? 'pending');
        $cacheKey = compact('user_id', 'type', 'status');
        $result = $this->cache->remember($cacheKey, function() use ($type, $status) {
            $data = [];
            if ($type === 'user' || $type === 'all') {
                $data['user_approvals'] = $this->getUserApprovals($status);
            }
            if ($type === 'term' || $type === 'all') {
                $data['term_approvals'] = $this->getTermApprovals($status);
            }
            return $data;
        });
        $result = Approvals::getInstance()->getApprovalRequests($user_id, $type === 'all' ? null : $type);
        return $this->success($result);
    }
    private function getUserApprovals(string $status = 'pending'): array
    {
        $table = CustomTable::for($this->getTableName('artist', 'requests'));
        $query = $table;
        if ($status !== 'all') {
            $query = $query->where(['status' => $status]);
        }
        return $query->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
    }
    private function getTermApprovals(string $status = 'pending'): array
    {
        $table = CustomTable::for($this->getTableName('term', 'requests'));
        if ($status === 'all') {
            return $table->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
        }
        return $table
            ->where(['status' => $status])
            ->orderBy('created_at', 'DESC')
            ->getResults(ARRAY_A);
    }
}
inc/rest/routes/CRUDRoutes.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/rest/routes/ContentRoutes.php
@@ -2,7 +2,10 @@
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\queue\executors\ContentExecutor;
use JVBase\managers\queue\Queue;
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
@@ -23,6 +26,7 @@
class ContentRoutes extends Rest
{
    use Instance;
    protected array $fields = [];
    protected array $taxonomies = [];
    protected string $post_type = '';
@@ -33,16 +37,21 @@
    protected array $timelineUniqueFields = [];
    protected static ?string $action = 'dash-';
    protected Meta $meta;
    protected Cache $termCache;
    public function __construct()
    {
        $this->cacheName = 'user_content_' . get_current_user_id();
        $this->cacheName = 'user_content';
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, 'post', DAY_IN_SECONDS);
        $this->cache->user();
        $this->termCache = Cache::for($this->cacheName.'_terms', 'term', DAY_IN_SECONDS);
        $this->termCache->user();
        if (JVB_TESTING) {
            $this->cache->flush();
        }
        $this->cache->connect('post', true);
        $this->cache->connect('term', true);
        add_action('init', [$this, 'registerContentExecutors'], 5);
    }
@@ -51,7 +60,7 @@
     */
    public function registerContentExecutors(): void
    {
        $registry = JVB()->queue()->registry();
        $registry = Queue::getInstance()->registry();
        $executor = new ContentExecutor();
        // Content updates - chunked at 10 posts
@@ -174,7 +183,7 @@
        unset($data['id']);
        error_log('[CONTENT]:'.print_r($data, true));
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $queue->queueOperation(
            'content_update',
            $user_id,
@@ -271,7 +280,7 @@
        }
        $key = $this->cache->generateKey($args);
        $cached = $this->checkCache($key, $request);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
@@ -301,8 +310,7 @@
        $this->cache->set($key, $data);
        $response = Response::success($data);
        return $this->addCacheHeaders($response);
        return Response::success($data);
    }
    public function getTerms(WP_REST_Request $request, array $params, Registrar $registrar):WP_REST_Response
    {
@@ -337,15 +345,14 @@
        $key = $this->cache->generateKey($args);
        // Check HTTP cache headers with the specific content type
        $cache_check = $this->checkHeaders($request, $key);
        if ($cache_check) {
            return $cache_check;
        }
//      $cache_check = $this->checkHeaders($request, $key);
//      if ($cache_check) {
//          return $cache_check;
//      }
        $cache = $this->cache->get($key);
        if ($cache) {
            $response = Response::success($cache);
            return $this->addCacheHeaders($response);
            return Response::success($cache);
        }
        $this->post_type = jvbCheckBase($params['content'] ?? $params['type']);
        // Only expand search to taxonomies if we're actually going to query
@@ -384,8 +391,7 @@
        $this->cache->set($key, $data);
        $response = Response::success($data);
        return $this->addCacheHeaders($response);
        return Response::success($data);
    }
    protected function applySearchFilters(array $args, array $params): array
inc/rest/routes/ContentTermsRoutes.php
@@ -1,8 +1,12 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\queue\executors\ContentTermExecutor;
use JVBase\managers\queue\Queue;
use JVBase\managers\queue\TypeConfig;
use JVBase\managers\Roles;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\Route;
@@ -31,12 +35,13 @@
 */
class ContentTermsRoutes extends Rest
{
    use Instance;
    protected string $taxonomy;
    protected Registrar $registrar;
    protected ?CustomTable $historyTable = null;
    protected ?CustomTable $requestsTable = null;
    public function __construct(string $taxonomy = '')
    //TODO: This needs some work. We need to move most logic into it's own manager class
    protected function __construct(string $taxonomy = '')
    {
        $this->taxonomy = jvbNoBase($taxonomy);
@@ -44,6 +49,7 @@
            $this->registrar = Registrar::getInstance($this->taxonomy);
            $this->cacheName = $this->taxonomy;
            parent::__construct();
            $this->cache = Cache::for($this->taxonomy, 'term', DAY_IN_SECONDS);
            $this->setupTables();
            $this->setupCacheConnections();
        }
@@ -53,7 +59,7 @@
    public function registerContentTermsExecutors():void
    {
        $registry = JVB()->queue()->registry();
        $registry = Queue::getInstance()->registry();
        $executor = new ContentTermExecutor();
        $taxonomies = Registrar::withFeature('is_content', 'term');
@@ -205,7 +211,7 @@
        }
        return user_can($userID, 'manage_options') ||
            JVB()->roles()->isManager($userID, $termID);
            Roles::getInstance()()->isManager($userID, $termID);
    }
    public function checkOwnerPermission(WP_REST_Request $request): bool
@@ -218,7 +224,7 @@
        }
        return user_can($userID, 'manage_options') ||
            JVB()->roles()->isOwner($userID, $termID);
            Roles::getInstance()()->isOwner($userID, $termID);
    }
    /**
@@ -233,7 +239,7 @@
        unset($data['user'], $data['term_id']);
        // Queue the update
        $op = JVB()->queue()->add(
        $op = Queue::getInstance()->add(
            "{$this->taxonomy}_update",
            $userID,
            array_merge(['term_id' => $termID], $data),
@@ -306,7 +312,7 @@
        $targetUserID = (int)$request->get_param('target_user');
        // Queue the operation
        $op = JVB()->queue()->add(
        $op = Queue::getInstance()->add(
            "{$this->taxonomy}_member_{$action}",
            $userID,
            [
@@ -469,7 +475,7 @@
            });
            // Notify user
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $requestData['user_id'],
                'request_' . $status,
                [
@@ -508,7 +514,7 @@
            return $this->error('Invalid target user');
        }
        $roleManager = JVB()->roles();
        $roleManager = Roles::getInstance()();
        try {
            $result = match($role) {
@@ -548,13 +554,13 @@
     */
    protected function notifyTermManagers(int $termID, int $actingUserID, string $notificationType): void
    {
        $roleManager = JVB()->roles();
        $roleManager = Roles::getInstance()();
        $managers = $roleManager->getManagedTerms($actingUserID, $this->taxonomy);
        $term = get_term($termID, jvbCheckBase($this->taxonomy));
        foreach ($managers as $managerID) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $managerID,
                $notificationType,
                [
inc/rest/routes/ErrorRoutes.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Errors;
use JVBase\rest\Response;
use JVBase\rest\Rest;
use JVBase\rest\Route;
@@ -12,6 +14,7 @@
}
class ErrorRoutes extends Rest
{
    use Instance;
    /**
     * Registers error routes
     * @return void
@@ -51,7 +54,7 @@
        // Determine severity based on error type
        $severity = $this->getSeverityFromType($error_type);
        JVB()->error()->log(
        Errors::getInstance()->log(
            $contextData['component'] ?? 'client-js',
            $message,
            $contextData,
inc/rest/routes/FavouritesRoutes.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\managers\Favourites;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Response;
@@ -18,6 +20,7 @@
class FavouritesRoutes extends Rest
{
    use Instance;
    protected array $valid_types;
    protected Cache $listsCache;
    protected Cache $sharedListsCache;
@@ -32,20 +35,16 @@
        $this->cacheName = 'favourites';
        $this->cacheTtl = HOUR_IN_SECONDS;
        parent::__construct();
        $this->cache = Cache::for($this->cacheName)->user();
        // Set up cache connections
        $this->cache->connect('post')->connect('user')->connect('taxonomy')->user();
        $this->listsCache = Cache::for('lists')->connect('favourites', true)->user();
        $this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true)->user();
        $this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true)->user();
        $this->listsCache = Cache::for('fav_lists')->user();
        $this->sharedListsCache = Cache::for('fav_shared_list')->user();
        $this->favouritesCache = Cache::for('allFavourites')->user();
        $this->valid_types = array_merge(Registrar::getRegistered('post'), Registrar::getRegistered('term'));
        // Initialize CustomTable instances
        $this->favourites = CustomTable::for('favourites');
        $this->lists = CustomTable::for('favourites_lists');
        $this->listItems = CustomTable::for('favourites_list_items');
        $this->listShares = CustomTable::for('favourites_list_shares');
    }
    public function registerRoutes(): void
@@ -108,17 +107,17 @@
        }
        $args = $this->buildParams($request);
        $key = $this->cache->generateKey($args);
        $result = $this->cache->remember(
            $args,
            function() use ($user_id, $args) {
                return Favourites::getInstance()->getFavourites($args);
            }
        );
        // Check cache headers
        $cache_check = $this->checkHeaders($request, $key);
        if ($cache_check) {
            return $cache_check;
        }
        $result = JVB()->favourites()->getFavourites($args);
        $result['items'] = $this->formatItems($result['items']);
        return $this->addCacheHeaders(Response::success($result));
        $response = [
            'items' => $this->formatItems($result['items'])
        ];
        return Response::success($response);
    }
@@ -131,29 +130,65 @@
        $user_id = get_current_user_id();
        $action = strtolower(sanitize_text_field($params['action']));
        $action = in_array($action, ['add', 'remove']) ? $action : false;
        $action = in_array($action, ['add', 'remove', 'toggle', 'batch', 'notes']) ? $action : false;
        if (!$action) {
            return $this->error('Invalid favourite action');
        }
        $target_id = absint($params['target_id']??0);
        if ($target_id === 0) {
            return $this->error('Invalid target id');
        }
        $type = sanitize_text_field($params['type']??'');
        if (empty($type)) {
            return $this->error('No type provided');
        }
        $result = JVB()->favourites()->toggleFavourite(
            $action === 'add',
            $user_id,
            $target_id,
            $type
        );
        $target_id = absint($params['target_id']??0);
        if ($action !== 'batch' && $target_id === 0) {
            return $this->error('Invalid target id');
        }
        $favourites = Favourites::getInstance();
        $result = false;
        switch ($action) {
            case 'notes':
                $note = sanitize_text_field($params['note']??'');
                $result = $favourites->addNoteFromTarget($user_id, $target_id, $type, $note);
                break;
            case 'toggle':
                $isFavourited = $favourites->hasFavourited($user_id, $target_id, $type);
                $result = $favourites->toggleFavourite(!$isFavourited, $user_id, $target_id, $type);
                break;
            case 'add':
                $result = $favourites->favourite($user_id, $target_id, $type);
                break;
            case 'remove':
                $result = $favourites->unfavourite($user_id, $target_id, $type);
                break;
            case 'batch':
                $items = $params['items']??[];
                $items = array_filter($items, function ($item) {
                    $keys = ['target_id', 'type', 'action'];
                    foreach ($keys as $key) {
                        if (!array_key_exists($key, $item)) {
                            return false;
                        }
                        if (empty($item[$key])) {
                            return false;
                        }
                    }
                    if (!in_array($item['action'], ['add', 'remove'])) {
                        return false;
                    }
                    return true;
                });
                if (empty($items)) {
                    $result = $this->success(['message' => 'No items to update']);
                } else {
                    $result = $favourites->batchToggleFavourites($user_id, $items);
                }
                break;
        }
        if ($result) {
            return $this->success();
            return $this->success(is_bool($result) ? [] : ['result' => $result]);
        }
        return $this->error('Something went wrong');
    }
@@ -163,10 +198,8 @@
     */
    public function getLists(WP_REST_Request $request): WP_REST_Response
    {
        $params = $request->get_params();
        $user_id = get_current_user_id();
        $args = $this->buildParams($request);
        $args['per_page'] = 20;
        $listId = $request->get_param('id');
@@ -174,21 +207,13 @@
            $args['where']['id'] = sanitize_text_field($listId);
        }
        $key = $this->listsCache->generateKey($args);
        // Check cache headers
        $cache_check = $this->checkHeaders($request, $key);
        if ($cache_check) {
            return $cache_check;
        }
        $includeShares = !empty($request->get_param('include_shares'));
        $response = !empty($listId)
            ? JVB()->favourites()->getListDetails($listId, $user_id)
            : JVB()->favourites()->getAvailableLists($args, $includeShares);
            ? Favourites::getInstance()->getListDetails($listId, $user_id)
            : Favourites::getInstance()->getAvailableLists($args, $includeShares);
        return $this->addCacheHeaders(Response::success($response));
        return Response::success($response);
    }
@@ -202,131 +227,12 @@
        $user_id = get_current_user_id();
        $counts = JVB()->favourites()->getFavouriteCounts($user_id);
        $counts = Favourites::getInstance()->getFavouriteCounts($user_id);
        return Response::success(['counts' => $counts]);
    }
    /**
     * Process favourite operations using transactions
     */
    public function processOperation($result, object $operation, array $data)
    {
        $action_map = [
            'favourite_add' => 'addFavourite',
            'favourite_remove' => 'removeFavourite',
            'favourite_batch' => 'batchFavourites',
            'favourite_note' => 'processNote',
            'favourite_list_create' => 'createList',
            'favourite_list_update' => 'updateList',
            'favourite_list_delete' => 'deleteList',
            'favourite_list_add_items' => 'addToList',
            'favourite_list_remove_items' => 'removeFromList',
            'favourite_list_share' => 'shareList',
            'favourite_list_unshare' => 'unshareList',
        ];
        if (!isset($action_map[$operation->type])) {
            return $result;
        }
        try {
            $method = $action_map[$operation->type];
            $response = $this->$method($operation->user_id, $data);
            // Clear cache on success
            if ($response['success'] ?? false) {
                Cache::invalidateItem('favourites', $operation->user_id);
                $this->listsCache->flush();
                $this->sharedListsCache->flush();
            }
            return $response;
        } catch (Exception $e) {
            $this->logError('processOperation', [
                'error' => $e->getMessage(),
                'operation_id' => $operation->id,
                'type' => $operation->type
            ]);
            return [
                'success' => false,
                'result' => $e->getMessage()
            ];
        }
    }
    /**
     * Add a favourite using findOrCreate pattern
     */
    protected function addFavourite(int $user_id, array $data): array
    {
        $type = $data['type'];
        $target_id = $data['target_id'];
        if (!str_starts_with($type, BASE)) {
            $type = BASE . $type;
        }
        if (!isset($this->valid_types[$type])) {
            return ['success' => false, 'result' => 'Invalid type'];
        }
        // Use findOrCreate pattern
        $result = $this->favourites->findOrCreate([
            'user_id' => $user_id,
            'type' => $type,
            'target_id' => $target_id
        ]);
        if ((bool)$result) {
            $this->updateFavouriteCount($type, $target_id);
            $this->maybeNotifyOwner($type, $target_id, $user_id);
        }
        return [
            'success' => true,
            'result' => [
                'action' => $result['created'] ? 'added' : 'already_exists',
                'favourite_id' => $result['id'],
                'count' => $this->getFavouriteCount($type, $target_id)
            ]
        ];
    }
    /**
     * Remove a favourite
     */
    protected function removeFavourite(int $user_id, array $data): array
    {
        $type = $data['type'];
        $target_id = $data['target_id'];
        if (!str_starts_with($type, BASE)) {
            $type = BASE . $type;
        }
        $deleted = $this->favourites->where([
            'user_id' => $user_id,
            'type' => $type,
            'target_id' => $target_id
        ])->deleteResults();
        if ($deleted) {
            $this->updateFavouriteCount($type, $target_id);
            $this->removeRelatedNotifications($type, $target_id, $user_id);
        }
        return [
            'success' => true,
            'result' => [
                'action' => $deleted ? 'removed' : 'not_found',
                'count' => $this->getFavouriteCount($type, $target_id)
            ]
        ];
    }
    /**
     * Batch favourites using transaction
     */
    protected function batchFavourites(int $user_id, array $data): array
@@ -334,7 +240,7 @@
        $items = $data['items'] ?? [];
        if (empty($items)) {
            return ['success' => false, 'result' => 'No items provided'];
            return $this->success(['result' => 'No items provided']);
        }
        return $this->favourites->transaction(function($table) use ($user_id, $items) {
@@ -605,7 +511,7 @@
    public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
    {
        if (array_key_exists('list_token', $data) && !empty($data['list_token'])) {
            JVB()->favourites()->acceptListShare($data['list_token'], $user_id);
            Favourites::getInstance()->acceptListShare($data['list_token'], $user_id);
        }
    }
@@ -615,8 +521,8 @@
    protected function getListDetails(int $list_id, int $user_id): array
    {
        // Check access - either owner or has share
        $is_owner = JVB()->favourites()->userOwnsList($list_id, $user_id);
        $is_shared = JVB()->favourites()->userCanViewList($list_id, $user_id);
        $is_owner = Favourites::getInstance()->userOwnsList($list_id, $user_id);
        $is_shared = Favourites::getInstance()->userCanViewList($list_id, $user_id);
        if (!$is_owner && !$is_shared) {
@@ -626,7 +532,7 @@
            ];
        }
        $list = JVB()->favourites()->getListDetails($list_id, $user_id);
        $list = Favourites::getInstance()->getListDetails($list_id, $user_id);
        if (empty($list)) {
            return [
@@ -1121,7 +1027,7 @@
                ]);
                // Send notification
                JVB()->notification()->addNotification(
                NotificationManager::getInstance()->addNotification(
                    $share_user->ID,
                    'list_shared',
                    $user_id,
@@ -1241,7 +1147,7 @@
                $list = $this->lists->where(['id' => $list_id])->first();
                if ($list) {
                    JVB()->notification()->addNotification(
                    NotificationManager::getInstance()->addNotification(
                        $existing_share->user_id,
                        'list_share_revoked',
                        $user_id,
@@ -1315,7 +1221,7 @@
            $invite_url
        );
        return JVB()->email()->sendEmail($email, $subject, $message);
        return Emails::getInstance()->sendEmail($email, $subject, $message);
    }
    /**
@@ -1389,7 +1295,7 @@
            $user = get_userdata($user_id);
            $display_name = $user ? $user->display_name : $email;
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $list->user_id,
                'list_share_accepted',
                $user_id,
@@ -1676,7 +1582,7 @@
            return $this->error('Invalid action', 'invalid_action', 400);
        }
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            $operation_type,
            $user_id,
            $data,
inc/rest/routes/FeedRoutes.php
@@ -2,11 +2,13 @@
namespace JVBase\rest\routes;
use JVBase\base\Site;
use JVBase\Instance;
use JVBase\integrations\services\Umami;
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use Umami;
use WP_Post;
use WP_Query;
use WP_REST_Request;
@@ -19,6 +21,7 @@
class FeedRoutes extends Rest
{
    use Instance;
    protected int $per_page = 36;
    protected ?Umami $tracker = null;
@@ -31,10 +34,7 @@
        $this->cacheName = 'feed';
        $this->cacheTtl = 86400;
        parent::__construct();
        $this->cache
            ->connect('post', true)
            ->connect('taxonomy', true)
            ->connect('user', true);
        $this->cache = Cache::for('feed');
        if (JVB_TESTING) {
            $this->cache->flush();
@@ -45,7 +45,7 @@
    public function init():void
    {
        if (Site::hasIntegration('umami')) {
            $this->tracker = JVB()->connect('umami');
            $this->tracker = Umami::getInstance();
        }
    }
@@ -115,11 +115,13 @@
                $post = get_post($postID);
                $type = jvbNoBase($post->post_type);
                $metaType = 'post';
                $this->cache = Cache::for('feedPosts', 'post');
                break;
            default:
                $post = get_term($postID, jvbCheckBase($type));
                $type = jvbNoBase($type);
                $metaType = 'term';
                $this->cache = Cache::for('feedTerms', 'term');
                break;
        }
        if (!$post || is_wp_error($post)) {
@@ -296,6 +298,7 @@
    protected function formatTaxonomy(WP_Term|int $term, int $postID, string $type)
    {
        $this->cache = Cache::for('feedTaxonomies', 'term');
        return $this->cache->remember(
            $term->term_id,
            function () use ($term, $postID, $type) {
@@ -316,6 +319,8 @@
    protected function getAuthorData(WP_Post $post)
    {
        //Connects to their profile, not user id
        $this->cache = Cache::for('feedUsers', 'post');
        $author = $post->post_author;
        $userLink = get_user_meta($author, BASE.'profile_link', true);
        return $this->cache->remember(
@@ -342,6 +347,10 @@
    protected function getTaxonomies(int $postID, string $content): array
    {
        $registrar = Registrar::getInstance($content)??false;
        if (!$registrar) {
            return [];
        }
        $this->cache = Cache::for('feedTaxonomyLinks', 'term');
        $taxonomies = $registrar->registrar->taxonomies;
        $out = [];
        foreach ($taxonomies as $tax) {
@@ -413,41 +422,16 @@
    public function handleFeedRequest(WP_REST_Request $request): WP_REST_Response
    {
        $args = $this->buildRequestArgs($request);
        $key = $this->cache->generateKey($args);
        // Check HTTP cache headers first
        $cache_check = $this->checkHeaders(
            $request,
            $key
        );
        if ($cache_check) {
            return $cache_check; // Returns 304 Not Modified
        }
        $cached = $this->cache->get($key);
        if ($cached) {
            if ($request->get_param('highlight')) {
                $highlight = json_decode($request->get_param('highlight'), true);
                $args['highlight'] = $highlight;
            }
            $cached['items'] = $this->processHighlightedItem($cached['items'], $args);
            $response = $this->success($cached);
            return $this->addCacheHeaders($response);
        }
        // Fetch and format items
        $items = $this->fetchFeedItems($args);
        $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cacheTtl;
        $this->cache->set($key, $items, $ttl);
        if ($request->get_param('highlight')) {
            $highlight = json_decode($request->get_param('highlight'), true);
        $highlight = $request->get_param('highlight');
        if (!empty($highlight)) {
            $highlight = json_decode($highlight, true);
            $args['highlight'] = $highlight;
            $items['items'] = $this->processHighlightedItem($items['items'], $args);
        }
        $items['items'] = $this->processHighlightedItem($items['items'], $args);
        $response = $this->success($items);
        return $this->addCacheHeaders($response);
        return $this->success($items);
    }
    /**
@@ -594,15 +578,11 @@
        $args['fields'] = 'ids';
        // Get post IDs
        $query = new WP_Query($args);
        // Batch prefetch related data
        update_meta_cache('post', $query->posts);
        update_object_term_cache($query->posts, $args['post_type']);
        // Format regular items
        $items = array_map(fn($post) => $this->formatItem($post), $query->posts);
        wp_reset_postdata();
        return [
            'items' => $items,
            'has_more' => $query->max_num_pages > $args['paged'],
@@ -1122,16 +1102,7 @@
     */
    public function getFeedTypes(WP_REST_Request $request): WP_REST_Response
    {
        // Check HTTP cache
        $cache_check = $this->checkHeaders($request, 'feed_types');
        if ($cache_check) {
            return $cache_check;
        }
        $feedTypes = $this->buildFeedTypesConfig();
        $response = $this->success($feedTypes);
        return $this->addCacheHeaders($response);
        return $this->success($this->buildFeedTypesConfig());
    }
    public function getFeedTypesConfig():array
@@ -1151,7 +1122,6 @@
                // Get content types with show_feed
                $contentTypes = Registrar::withFeature('show_feed', 'post');
                foreach ($contentTypes as $slug) {
                    $this->cache->tag('content:'.$slug);
                    $registrar = Registrar::getInstance($slug);
                    if (!$registrar) continue;
@@ -1171,9 +1141,6 @@
                    if (!$registrar || !($registrar->hasFeature('is_content') ?? false)) {
                        continue;
                    }
                    $this->cache->tag('taxonomy:'.$slug);
                    $config[$slug] = [
                        'type' => 'taxonomy',
                        'singular' => $registrar->getSingular(),
inc/rest/routes/FormRoutes.php
@@ -1,6 +1,9 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\managers\Emails;
use JVBase\meta\Sanitizer;
use JVBase\meta\Validator;
use JVBase\rest\PermissionHandler;
@@ -25,6 +28,7 @@
 */
class FormRoutes extends Rest
{
    use Instance;
    protected FormBlock $form_block;
    public function __construct()
@@ -121,15 +125,18 @@
    protected function verifyTurnstile(string $token): bool
    {
        if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
        if (!Site::hasIntegration('cloudflare') ) {
            return true;
        }
        $cloudflare = Cloudflare::getInstance();
        if (!$cloudflare->isSetUp()) {
            return true;
        }
        if (empty($token)) {
            return false;
        }
        return JVB()->connect('cloudflare')->verifyTurnstile($token);
        return $cloudflare->verifyTurnstile($token);
    }
    /**
@@ -313,14 +320,14 @@
        }
        // Build email body
        $body = JVB()->email()->h2($form_config['title']);
        $body .= '<p style="font-size:13px;color:' . JVB()->email()->colours['dark-200'] . ';">
        $body = Emails::getInstance()->h2($form_config['title']);
        $body .= '<p style="font-size:13px;color:' . Emails::getInstance()->colours['dark-200'] . ';">
        Submitted: ' . current_time('F j, Y \a\t g:i A') . '
    </p>';
        // Add thread notice if CC'd
        if ($submitter_email) {
            $body .= JVB()->email()->notice(
            $body .= Emails::getInstance()->notice(
                '<strong>Email Thread Created</strong><br>Reply to this email to continue the conversation with ' .
                esc_html($submitter_name ?: 'the submitter') . '.'
            );
@@ -360,21 +367,21 @@
            ];
        }
        $body .= JVB()->email()->spacer(20);
        $body .= JVB()->email()->table($form);
        $body .= Emails::getInstance()->spacer(20);
        $body .= Emails::getInstance()->table($form);
        // Show attachment info
        $attachments = $this->processFileAttachments($files);
        if (!empty($attachments)) {
            $body .= JVB()->email()->spacer(10);
            $body .= JVB()->email()->alert(
            $body .= Emails::getInstance()->spacer(10);
            $body .= Emails::getInstance()->alert(
                sprintf('%d file(s) attached to this email', count($attachments)),
                'info'
            );
        }
        // Send the email
        return JVB()->email()->sendEmail(
        return Emails::getInstance()->sendEmail(
            $to,
            $subject,
            $body,
inc/rest/routes/ImporterRoutes.php
@@ -4,6 +4,7 @@
use JVBase\importers\JaneAppClientImporter;
use JVBase\importers\JaneAppSalesImporter;
use JVBase\Instance;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_REST_Request;
@@ -21,6 +22,7 @@
 */
class ImporterRoutes extends Rest
{
    use Instance;
    /**
     * Register REST routes
     */
inc/rest/routes/IntegrationsHelcimRoutes.php
@@ -1,6 +1,9 @@
<?php
namespace JVBase\rest\routes;
use JVBase\base\Site;
use JVBase\Instance;
use JVBase\integrations\services\Helcim;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_REST_Request;
@@ -13,6 +16,7 @@
class IntegrationsHelcimRoutes extends Rest
{
    use Instance;
    public function registerRoutes(): void
    {
        Route::for('helcim/initialize-checkout')
@@ -56,6 +60,9 @@
    {
        $data    = $request->get_json_params();
        $user_id = get_current_user_id();
        if (!Site::hasIntegration('helcim')) {
            return $this->validationError(['message' => 'Helcim not setup']);
        }
        if (!$user_id) {
            return $this->unauthorized();
        }
@@ -65,7 +72,12 @@
        }
        try {
            $helcim = JVB()->connect('helcim');
            $helcim = Helcim::getInstance();
            if (!method_exists($helcim, 'resolveCustomerId') ||
                !method_exists($helcim, 'initializeCheckout')
            ) {
                return $this->validationError(['message' => 'Helcim not complete']);
            }
            // Auto-resolve customer ID from logged-in user
            if (empty($data['customerId']) && $user_id) {
@@ -96,9 +108,16 @@
        if (!$user_id) {
            return $this->validationError(['message' => 'Not logged in']);
        }
        if (!Site::hasIntegration('helcim')) {
            return $this->validationError(['message' => 'Helcim not setup']);
        }
        try {
            $helcim    = JVB()->connect('helcim');
            $helcim    = Helcim::getInstance();
            if (!method_exists($helcim, 'handleGetInvoices')) {
                return $this->validationError(['message' => 'Helcim not complete']);
            }
            $user      = get_userdata($user_id);
            $result    = $helcim->handleGetInvoices([
                'email' => $user->user_email,
@@ -122,8 +141,15 @@
            return $this->validationError(['message' => 'Invoice ID required']);
        }
        if (!Site::hasIntegration('helcim')) {
            return $this->validationError(['message' => 'Helcim not setup']);
        }
        try {
            $helcim = JVB()->connect('helcim');
            $helcim = Helcim::getInstance();
            if (!method_exists($helcim, 'handleGetInvoice')) {
                return $this->validationError(['message' => 'Helcim not complete']);
            }
            $result = $helcim->handleGetInvoice(['invoiceId' => $invoiceId]);
            return $this->success($result);
@@ -143,9 +169,15 @@
        if (!$user_id) {
            return $this->validationError(['message' => 'Not logged in']);
        }
        if (!Site::hasIntegration('helcim')) {
            return $this->validationError(['message' => 'Helcim not setup']);
        }
        try {
            $helcim = JVB()->connect('helcim');
            $helcim = Helcim::getInstance();
            if (!method_exists($helcim, 'handleGetCustomerCards')) {
                return $this->validationError(['message' => 'Helcim not complete']);
            }
            $result = $helcim->handleGetCustomerCards([
                'email' => get_userdata($user_id)->user_email,
            ]);
@@ -171,9 +203,15 @@
        if (empty($data['secretToken']) || empty($data['transactionData'])) {
            return $this->validationError(['message' => 'Missing secretToken or transactionData']);
        }
        if (!Site::hasIntegration('helcim')) {
            return $this->validationError(['message' => 'Helcim not setup']);
        }
        try {
            $helcim = JVB()->connect('helcim');
            $helcim = Helcim::getInstance();
            if (!method_exists($helcim, 'validateTransaction')) {
                return $this->validationError(['message' => 'Helcim not complete']);
            }
            $valid  = $helcim->validateTransaction(
                $data['secretToken'],
                $data['transactionData']
inc/rest/routes/IntegrationsRoutes.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\integrations\ServiceMap;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_REST_Request;
@@ -13,7 +15,8 @@
class IntegrationsRoutes extends Rest
{
    use Instance;
    use ServiceMap;
    /**
     * Register REST routes
     */
@@ -22,7 +25,7 @@
        Route::for('integrations')
            ->post([$this, 'handleAction'])
            ->args([
                'service'   => 'string|required|enum:'.implode(',',JVB()->getAvailableServices()),
                'service'   => 'string|required|enum:'.implode(',',$this->getAvailableServices()),
                'action'    => 'string|required',
                'user_id'   => 'int',
                'context'   => 'string|enum:admin,user',
@@ -57,12 +60,17 @@
        if ($theUserID === false) {
            return $this->validationError(['message' => 'Not logged in']);
        }
        $integration = JVB()->connect($service, $theUserID);
        $integration = $this->getService($service);
        if (!method_exists($integration, 'switchUser')) {
            return $this->validationError(['message' => 'Cannot get user integration']);
        }
        if (!$integration) {
        if (!$integration || !method_exists($integration, 'processAction')) {
            return $this->validationError(['message'=>'Invalid service']);
        }
        $integration->switchUser($theUserID);
        $integration->getCredentials();
        // Handle the action
@@ -88,13 +96,26 @@
        $user_id = $request->get_param('user_id');
        $return_url = $request->get_param('return_url');
        $integration = JVB()->connect($service, $user_id);
        $theUserID = current_user_can('manage_options') ? null : get_current_user_id();
        if ($theUserID === false) {
            return $this->validationError(['message' => 'Not logged in']);
        }
        $integration = $this->getService($service);
        if (!method_exists($integration, 'switchUser')) {
            return $this->validationError(['message' => 'Cannot get user integration']);
        }
        if (!$integration || !$integration->isOAuthService) {
        if (!$integration || !method_exists($integration, 'processAction')) {
            return $this->validationError(['message'=>'Invalid service']);
        }
        $auth_url = $integration->getOAuthUrl($return_url);
        $integration->switchUser($theUserID);
        if (!$integration || !method_exists($integration, 'getOAuthBaseURL') || is_null($integration->getOAuthBaseURL())) {
            return $this->validationError(['message'=>'Invalid service']);
        }
        $auth_url = $integration->getOAuthBaseURL($return_url);
        if ($auth_url) {
            return $this->success($auth_url);
inc/rest/routes/IntegrationsSquareRoutes.php
@@ -3,6 +3,7 @@
use Exception;
use JVBase\Instance;
use JVBase\integrations\services\Square;
use JVBase\meta\Meta;
use JVBase\rest\Rest;
@@ -16,6 +17,7 @@
class IntegrationsSquareRoutes extends Rest
{
    use Instance;
    public function registerRoutes():void
    {
        Route::for('square/process-payment')
@@ -80,7 +82,13 @@
        try {
            /** @var Square $square */
            $square = JVB()->connect('square');
            $square = Square::getInstance();
            if (!method_exists($square, 'getOrCreateSquareCustomer') ||
                !method_exists($square, 'createSquareOrder') ||
                !method_exists($square, 'saveOrderToWordPress') ||
                !method_exists($square, 'createSquarePayment')) {
                return $this->error('Square not set up');
            }
            // Step 1: Get or create Square customer
            $customer_id = $square->getOrCreateSquareCustomer($data['customer']);
inc/rest/routes/Invitations.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\InvitationsManager;
use JVBase\rest\Rest;
use JVBase\managers\CustomTable;
use JVBase\rest\Route;
@@ -20,6 +22,7 @@
 */
class Invitations extends Rest
{
    use Instance;
    protected array $inviteConfig;
    protected CustomTable $table;
@@ -29,7 +32,7 @@
        parent::__construct();
        // Get invitation configuration
        $this->inviteConfig = JVB()->invitations()->getInviteConfig();
        $this->inviteConfig = InvitationsManager::getInstance()->getInviteConfig();
        $this->table = CustomTable::for('invitations');
        // Cache connections
@@ -107,7 +110,7 @@
    protected function getTermInvitations(array $args): array
    {
        // Check permission
        if (!JVB()->roles()->isManager($args['user'], $args['to_term'])) {
        if (!Roles::getInstance()()->isManager($args['user'], $args['to_term'])) {
            return $this->forbidden('You cannot view invitations for this ' . $args['taxonomy'])->get_data();
        }
@@ -213,7 +216,7 @@
        }
        // Queue using fluent interface
        $op = JVB()->queue()->add(
        $op = Queue::getInstance()->add(
            'invitation_create',
            $userID,
            ['invitations' => $validated],
@@ -231,7 +234,7 @@
     */
    protected function validateInvitations(int $userID, array $invites): array
    {
        return JVB()->invitations()->validateInvitations($userID, $invites);
        return InvitationsManager::getInstance()->validateInvitations($userID, $invites);
    }
@@ -248,7 +251,7 @@
            return $this->error('Invitation ID required');
        }
        $op = JVB()->queue()->add(
        $op = Queue::getInstance()->add(
            'invitation_revoke',
            $userID,
            ['invitation_id' => $invitationID],
@@ -269,7 +272,7 @@
            return $this->error('Invitation ID required');
        }
        $op = JVB()->queue()->add(
        $op = Queue::getInstance()->add(
            'invitation_resend',
            $userID,
            ['invitation_id' => $invitationID],
@@ -308,7 +311,7 @@
        ];
        // Add term information if present
        foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
        foreach (Roles::getInstance()()->getInvitableTaxonomies() as $taxonomy) {
            $column = 'to_' . $taxonomy;
            if (isset($invitation->$column) && $invitation->$column) {
                $termID = (int) $invitation->$column;
inc/rest/routes/LoginRoutes.php
@@ -1,7 +1,9 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\Emails;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\Route;
@@ -22,6 +24,7 @@
 */
class LoginRoutes extends Rest
{
    use Instance;
    protected ?string $requestId = null;
    protected bool $hasMagicLink = false;
@@ -280,7 +283,7 @@
        // Send magic link for email verification
        if ($this->hasMagicLink) {
            JVB()->magicLink()->sendMagicLink($email, 'signup', [
            MagicLinks::getInstance()->sendMagicLink($email, 'signup', [
                'name' => $name,
                'role' => $role,
            ]);
@@ -331,7 +334,7 @@
        // Use magic link if available, otherwise standard WP reset
        if ($this->hasMagicLink) {
            $result = JVB()->magicLink()->sendMagicLink($email, 'reset');
            $result = MagicLinks::getInstance()->sendMagicLink($email, 'reset');
            if (is_wp_error($result)) {
                $this->logError('Magic link send failed', [
@@ -348,7 +351,7 @@
                    'error' => $key->get_error_message(),
                ]);
            } else {
                $success = JVB()->email()->sendPasswordResetEmail($user, $key);
                $success = Emails::getInstance()->sendPasswordResetEmail($user, $key);
            }
        }
@@ -450,7 +453,7 @@
        }
        // Send magic link
        $result = JVB()->magicLink()->sendMagicLink($email, $type, $context);
        $result = MagicLinks::getInstance()->sendMagicLink($email, $type, $context);
        if (is_wp_error($result)) {
            $this->logError('Magic link send failed', [
@@ -662,7 +665,7 @@
        }
        try {
            JVB()->referrals()->processReferralCode($user_id, $referral_code);
            Referrals::getInstance()->processReferralCode($user_id, $referral_code);
        } catch (\Exception $e) {
            $this->logError('Referral processing failed', [
                'user_id' => $user_id,
inc/rest/routes/MagicLinkRoutes.php
@@ -2,7 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\rest\RestRouteManager;
use JVBase\managers\MagicLinkManager;
use JVBase\managers\MagicLinks;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -110,7 +110,7 @@
            return $this->error('Security verification failed', 'turnstile_failed', 403);
        }
        $email = sanitize_email($request->get_param('email')??$request->get_param('user_email')??'');
        $type = sanitize_text_field($request->get_param('type')) ?? MagicLinkManager::TYPE_LOGIN;
        $type = sanitize_text_field($request->get_param('type')) ?? MagicLinks::TYPE_LOGIN;
        $context = $request->get_param('context') ?? [];
        // Validate email
@@ -123,20 +123,20 @@
        // Check if email exists
        $exists = email_exists($email);
        if ($type === MagicLinkManager::TYPE_LOGIN && !$exists) {
        if ($type === MagicLinks::TYPE_LOGIN && !$exists) {
            return new WP_REST_Response([
                'success' => true,
                'message' => 'Invalid email address'
            ]);
        }
        if ($type === MagicLinkManager::TYPE_SIGNUP && $exists) {
        if ($type === MagicLinks::TYPE_SIGNUP && $exists) {
            // Redirect to login instead
            $type = MagicLinkManager::TYPE_LOGIN;
            $type = MagicLinks::TYPE_LOGIN;
        }
        // Send the magic link
        $result = JVB()->magicLink()?->sendMagicLink($email, $type, $context);
        $result = MagicLinks::getInstance()?->sendMagicLink($email, $type, $context);
        if (is_wp_error($result)) {
            return new WP_REST_Response([
@@ -178,7 +178,7 @@
        $email = sanitize_email($request->get_param('email'));
        // This returns array|WP_Error - check for error first
        $token_data = JVB()->magicLink()?->verifyToken($token, $email);
        $token_data = MagicLinks::getInstance()?->verifyToken($token, $email);
        if (is_wp_error($token_data)) {
            return new WP_REST_Response([
inc/rest/routes/NewsRoutes.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
use JVBase\meta\Meta;
@@ -17,6 +18,7 @@
class NewsRoutes extends Rest
{
    use Instance;
    protected int $per_page;
    protected bool|object $manager = false;
    public function __construct()
@@ -77,7 +79,7 @@
     */
    public function handleNewsOperation(WP_REST_Request $request):WP_REST_Response
    {
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $data = $request->get_params();
        $user = get_current_user_id();
        $operationID = sanitize_text_field($data['id']);
inc/rest/routes/NotificationsRoutes.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\registrar\Registrar;
@@ -23,6 +24,7 @@
 */
class NotificationsRoutes extends Rest
{
    use Instance;
    protected int $user_id;
    protected array $notification_types = [];
    protected object $manager;
@@ -133,7 +135,7 @@
     */
    public function init(): void
    {
        $this->manager = JVB()->notification();
        $this->manager = NotificationManager::getInstance();
        $this->notification_types = $this->manager->getNotificationTypes(true);
    }
@@ -967,7 +969,7 @@
        }
        if (!empty($queueData)) {
            JVB()->queue()->queueOperation(
            Queue::getInstance()->queueOperation(
                'notification_' . $action,
                $args['user_id'],
                $queueData
inc/rest/routes/OptionsRoutes.php
@@ -2,6 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\base\Options;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
@@ -18,6 +19,7 @@
class OptionsRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
@@ -48,7 +50,7 @@
        $operationID = $data['id'];
        unset($data['id']);
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'update_options',
            $user,
            $data,
inc/rest/routes/QueueRoutes.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\queue\Operation;
use JVBase\managers\queue\Queue;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use JVBase\rest\Response;
@@ -15,11 +17,13 @@
class QueueRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
        $this->cacheName = 'queue';
        $this->cacheTtl = 300;
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, 'custom', $this->cacheTtl)->user();
        if (JVB_TESTING) {
            $this->cache->flush();
@@ -91,32 +95,16 @@
        if (!$user_id) {
            return $this->unauthorized();
        }
        $this->cache = Cache::for('queue')->user();
        $status = sanitize_text_field($params['status']);
        $ids = !empty($params['ids'])
            ? array_map('trim', array_map('sanitize_text_field', explode(',', $params['ids'])))
            : [];
        $limit = absint($params['limit']);
        $cacheKey = $this->cache->generateKey(compact('user_id', 'status', 'ids', 'limit'));
        if ($cached = $this->checkHeaders($request, $cacheKey)) {
            return $cached;
        }
        $filters = $this->buildFilters($params);
        $ops = Queue::getInstance()->getUserOperations($user_id, $filters);
        $data = $this->cache->tag("user:{$user_id}")->remember($cacheKey, function() use ($user_id, $params) {
            $filters = $this->buildFilters($params);
            $operations = JVB()->queue()->getUserOperations($user_id, $filters);
            return [
                'items' => array_map([$this, 'formatOperation'], $operations),
                'total' => count($operations),
                'queue_stats' => $this->getQueueStats($user_id),
                'server_time' => date('c'),
            ];
        });
        $response = Response::success($data);
        return $this->addCacheHeaders($response);
        return Response::success([
            'items' => array_map([$this, 'formatOperation'], $ops),
            'total' => count($ops),
            'queue_stats' => $this->getQueueStats($user_id),
            'server_time'   => date('c')
        ]);
    }
    private function buildFilters(array $params): array
@@ -168,7 +156,7 @@
        }
        // Get operations via Queue - verifies ownership
        $operations = JVB()->queue()->getUserOperations($user_id, [
        $operations = Queue::getInstance()->getUserOperations($user_id, [
            'ids' => $ids,
            'limit' => count($ids),
        ]);
@@ -202,7 +190,7 @@
            $filters['ids'] = array_map('trim', explode(',', $ids));
        }
        $operations = JVB()->queue()->getUserOperations($userId, $filters);
        $operations = Queue::getInstance()->getUserOperations($userId, $filters);
        if ($since) {
            $sinceTime = strtotime($since);
@@ -235,7 +223,7 @@
            return $this->unauthorized();
        }
        $this->cache = Cache::for($user_id.'_queue');
        $operations = JVB()->queue()->getUserOperations($user_id, [
        $operations = Queue::getInstance()->getUserOperations($user_id, [
            'state' => 'completed',
            'outcome' => ['failed', 'failed_permanent', 'partial'],
            'has_errors' => true,
@@ -265,7 +253,7 @@
        }
        $this->cache = Cache::for($userId.'_queue');
        $op = JVB()->queue()->get($id);
        $op = Queue::getInstance()->get($id);
        if (!$op || $op->userId !== $userId) {
            return Response::notFound('Operation not found');
@@ -282,7 +270,7 @@
    {
        return array_merge(
            ['queued' => 0, 'localProcessing' => 0, 'uploading' => 0],
            JVB()->queue()->getUserStats($userId)
            Queue::getInstance()->getUserStats($userId)
        );
    }
@@ -390,7 +378,7 @@
    private function processAction(string $action, array $operations, int $userId): array
    {
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $processed = 0;
        $processedIds = [];
inc/rest/routes/ReferralRoutes.php
@@ -4,12 +4,14 @@
use JVBase\base\Site;
use JVBase\importers\JaneAppClientImporter;
use JVBase\importers\JaneAppSalesImporter;
use JVBase\managers\CustomTable;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\queue\Queue;
use JVBase\managers\Referrals;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
@@ -20,13 +22,13 @@
 */
class ReferralRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
        $this->cacheName = 'referrals';
        $this->cacheTtl = (int)HOUR_IN_SECONDS;
        parent::__construct();
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        $this->cache = Cache::for($this->cacheName, 'custom', $this->cacheTtl)->user();
    }
    public function registerRoutes(): void
@@ -35,7 +37,7 @@
        Route::for('referrals')
            ->get([$this, 'getReferrals'])
            ->args([
                'status' => 'string|enum:all,pending,consulted,treated,unused,registered,completed|default:all',
                'status' => 'string|enum:all,invited,pending,consulted,treated,cancelled|default:all',
                'date_start' => 'string',
                'date_end' => 'string',
                'limit' => 'integer|default:50',
@@ -109,31 +111,12 @@
            return $this->getAllReferrals($request);
        }
        // Build cache key
        $args = [
            'status' => $request->get_param('status') ?? 'all',
            'limit' => $request->get_param('limit') ?? 50,
            'offset' => $request->get_param('offset') ?? 0,
            'date_start' => $request->get_param('date_start'),
            'date_end' => $request->get_param('date_end'),
        ];
        $cache_key = "user_{$user_id}_" . $this->cache->generateKey($args);
        // Check 304 Not Modified
        $cache_check = $this->checkHeaders($request, $cache_key);
        if ($cache_check instanceof WP_REST_Response) {
            return $cache_check;
        }
        $args = $this->buildArgs($request);
        // Get referrals from manager
        $referrals = JVB()->referrals()->getUserReferrals($user_id, $args);
        $referrals = Referrals::getInstance()->getUserReferrals($user_id, $args);
        $data = [
            'items' => $referrals,
            'total' => count($referrals)
        ];
        $response = $this->success($data);
        $response = $this->success($referrals);
        return $this->addCacheHeaders($response);
    }
@@ -165,17 +148,8 @@
            return $this->unauthorized();
        }
        //Additional check to not send too many emails in an hour
        $transient_key = "referral_invite_limit_{$user}";
        $recent_invites = get_transient($transient_key) ?: 0;
        if ($recent_invites >= 20) { // Max 5 batch invites per hour
            return $this->error('Too many invitations sent. Please try again later.', 'rate_limit', 429);
        }
        set_transient($transient_key, $recent_invites + 1, HOUR_IN_SECONDS);
        $subject = sanitize_text_field($request->get_param('subject'));
        $message = sanitize_textarea_field($request->get_param('message'));
        $subject = sanitize_text_field($request->get_param('subject')??'');
        $message = sanitize_textarea_field($request->get_param('message')??'');
        $invitations = $request->get_param('invite');
        // Validate and sanitize invitations
@@ -194,7 +168,7 @@
        }
        $operationID = sanitize_text_field($request->get_param('id'));
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'referral_invite',
            $user,
            [
@@ -222,13 +196,12 @@
            return $this->error('referral_id required', 'missing_id', 400);
        }
        $result = JVB()->referrals()->updateStatus($referral_id, $status);
        [$success, $message] = Referrals::getInstance()->updateStatus($referral_id, $status);
        if (is_wp_error($result)) {
            $code = $result->get_error_code();
            return $code === 'not_found'
                ? $this->notFound($result->get_error_message())
                : $this->error($result->get_error_message(), $code, 500);
        if (!$success) {
            return $message === 'Referral not found'
                ? $this->notFound($message)
                : $this->error($message);
        }
        $this->cache->flush();
@@ -245,20 +218,12 @@
            return $this->error('referral_id required', 'missing_id', 400);
        }
        $result = JVB()->referrals()->removeReferral($referral_id, get_current_user_id());
        $result = Referrals::getInstance()->removeReferral($referral_id, get_current_user_id());
        if (is_wp_error($result)) {
            $code = $result->get_error_code();
            $status = match($code) {
                'not_found'      => 404,
                'unauthorized'   => 403,
                'invalid_status' => 400,
                default          => 500
            };
            return $this->error($result->get_error_message(), $code, $status);
        if (!$result['success']) {
            return $this->error($result['message']);
        }
        $this->cache->flush();
        return $this->success(['message' => 'Referral removed']);
    }
@@ -272,16 +237,10 @@
            return $this->error('referral_id required', 'missing_id', 400);
        }
        $result = JVB()->referrals()->resendInvitation($referral_id, get_current_user_id());
        $result = Referrals::getInstance()->resendInvitation($referral_id, get_current_user_id());
        if (is_wp_error($result)) {
            $code = $result->get_error_code();
            $status = match($code) {
                'not_found'  => 404,
                'rate_limit' => 429,
                default      => 500
            };
            return $this->error($result->get_error_message(), $code, $status);
        if (!$result['success']) {
            return $this->error($result['message']);
        }
        return $this->success(['message' => 'Invitation resent']);
@@ -299,7 +258,7 @@
            return $this->unauthorized();
        }
        $code = JVB()->referrals()->getUserReferralCode($user_id);
        $code = Referrals::getInstance()->getUserReferralCode($user_id);
        if (is_wp_error($code)) {
            return $this->error($code->get_error_message(), 'code_error', 400);
@@ -317,13 +276,13 @@
     */
    public function validateCode(WP_REST_Request $request): WP_REST_Response
    {
        $code = strtoupper(sanitize_text_field($request->get_param('code')));
        $code = strtoupper(sanitize_text_field($request->get_param('code')??''));
        if (empty($code)) {
            return $this->error('Code required', 'missing_code', 400);
        }
        $referrer = JVB()->referrals()->getUserByReferralCode($code);
        $referrer = Referrals::getInstance()->getUserByReferralCode($code);
        if (!$referrer) {
            return $this->error('Invalid referral code', 'invalid_code', 404);
@@ -331,7 +290,7 @@
        // Check self-referral
        if (is_user_logged_in() && get_current_user_id() === $referrer->ID) {
            return $this->error('Cannot use your own referral code', 'self_referral', 400);
            return $this->error('Cannot use your own referral code', 'self_referral');
        }
        return $this->success([
@@ -351,18 +310,10 @@
        if (!$user_id) {
            return $this->unauthorized();
        }
        $cache_key = "stats_{$user_id}";
        // Check 304 Not Modified
        $cache_check = $this->checkHeaders($request, $cache_key);
        if ($cache_check instanceof WP_REST_Response) {
            return $cache_check;
        }
        $stats = Referrals::getInstance()->getUserStats($user_id);
        $stats = JVB()->referrals()->getUserStats($user_id);
        $response = $this->success(['items' => [$stats]]);
        return $this->addCacheHeaders($response);
        return $this->success(['items' => [$stats]]);
    }
    /**
@@ -370,7 +321,7 @@
     */
    public function getSettings(WP_REST_Request $request): WP_REST_Response
    {
        $settings = JVB()->referrals()->getRewardSettings();
        $settings = Referrals::getInstance()->getRewardSettings();
        return $this->success(['settings' => $settings]);
    }
@@ -400,57 +351,34 @@
    /**
     * Helper: Get all referrals (admin only)
     */
    protected function buildArgs(WP_REST_Request $request):array
    {
        $params = $request->get_params();
        $args = [
            'limit'     => absint($params['limit']??20),
            'offset'    => absint($params['offset']??0)
        ];
        if (array_key_exists('status', $params) && $params['status'] !== 'all') {
            $args['status'] = sanitize_text_field($params['status']);
        }
        if (array_key_exists('search', $params)) {
            $args['search'] = sanitize_text_field($params['search']);
        }
        if (array_key_exists('date_start', $params) && array_key_exists('date_end', $params)) {
            $args['created_at'] = ['BETWEEN', [sanitize_text_field($params['date_start']), sanitize_text_field($params['date_end'])]];
        }
        return $args;
    }
    protected function getAllReferrals(WP_REST_Request $request): WP_REST_Response
    {
        $items = JVB()->referrals()->getAllReferrals([
            'status'     => $request->get_param('status') ?? 'all',
            'search'     => $request->get_param('search'),
            'date_start' => $request->get_param('date_start'),
            'date_end'   => $request->get_param('date_end'),
            'limit'      => $request->get_param('limit') ?? 50,
            'offset'     => $request->get_param('offset') ?? 0,
        ]);
        $args = $this->buildArgs($request);
        $items = Referrals::getInstance()->getReferrals($args);
        return $this->success(['items' => $items, 'total' => count($items)]);
        return $this->success($items);
    }
    /**
     * Process queued referral operations
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
    {
        if ($operation->type !== 'referral_invite') {
            return $result;
        }
        $result = JVB()->referrals()->sendBatchReferralInvitations(
            $operation->user_id,
            $data['invitations'],
            $data['subject'],
            $data['message']
        );
        if ($result['success']) {
            $this->cache->flush();
        }
        return [
            'success' => true,
            'message' => sprintf(
                'Sent invitations. Success: %d. Failed: %d.',
                count($result['result']['success']),
                count($result['result']['failed'])
            ),
            'details' => [
                'successful' => $result['result']['success'],
                'failed' => $result['result']['failed'],
                'total' => count($data['invitations'])
            ]
        ];
    }
    /**
     * Handle client CSV upload
     */
    public function handleClientUpload(WP_REST_Request $request): WP_REST_Response
@@ -482,7 +410,7 @@
        // Import using JaneAppClientImporter
        $importer = new JaneAppClientImporter();
        $default_role = get_option(BASE . 'referral_role', Site::getDefaultReferralRole());
        $default_role = get_option(BASE . 'referral_role', Site::getReferralRole());
        $options = [
            'update_existing' => true,
inc/rest/routes/ResponseRoutes.php
@@ -1,7 +1,12 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\JVB;
use JVBase\managers\Cache;
use JVBase\managers\NotificationManager;
use JVBase\managers\Notifications\Notifications;
use JVBase\managers\Responses;
use JVBase\rest\Rest;
use JVBase\managers\CustomTable;
use WP_REST_Request;
@@ -19,6 +24,7 @@
 */
class ResponseRoutes extends Rest
{
    use Instance;
    protected int $perPage = 20;
    protected CustomTable $table;
    protected CustomTable $karmaTable;
@@ -28,12 +34,8 @@
        $this->cacheName = 'responses';
        $this->cacheTtl = 1800; // 30 minutes
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, 'response', $this->cacheTtl);
        $this->table = CustomTable::for('responses');
        $this->karmaTable = CustomTable::for('karma_response');
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1);
    }
    public function registerRoutes(): void
@@ -57,38 +59,15 @@
     */
    public function getResponses(WP_REST_Request $request): WP_REST_Response
    {
        $item_id = (int) $request->get_param('item_id');
        if (!$item_id) {
        $params = $request->get_params();
        if (!array_key_exists('item_id', $params)) {
            return $this->error('Missing item ID', 'missing_item_id');
        }
        $item_id = absint($params['item_id']);
        // Build query args
        $args = $this->buildQueryArgs($request);
        $cacheKey = $this->cache->generateKey(array_merge(['item_id' => $item_id], $args));
        // Check headers for 304 Not Modified
        $headerCheck = $this->checkHeaders($request, $cacheKey);
        if ($headerCheck) {
            return $headerCheck;
        }
        // Check cache
        $cached = $this->cache->get($cacheKey);
        if ($cached) {
            return $this->success($cached);
        }
        // Get responses
        $responses = $this->getItemResponse($item_id, $args);
        if (is_wp_error($responses)) {
            return $this->notFound($responses->get_error_message());
        }
        // Cache and return
        $this->cache->set($cacheKey, $responses);
        return $this->success($responses);
        return $this->success(Responses::getInstance()->getAllItemResponses($item_id, $args));
    }
    /**
@@ -103,7 +82,6 @@
            return $this->unauthorized();
        }
        $operation_id = $data['id'] ?? uniqid('response_');
        $action = sanitize_text_field($data['action'] ?? '');
        if (!in_array($action, ['create', 'delete', 'update'])) {
@@ -116,39 +94,26 @@
                return $this->error('Missing required fields', 'missing_fields');
            }
        }
        // Prepare data for queue
        $queue_data = match($action) {
            'create' => [
                'item_id' => (int) $data['item_id'],
                'parent_id' => isset($data['parent_id']) ? (int) $data['parent_id'] : null,
                'response' => wp_kses_post($data['response']),
                'content' => sanitize_text_field($data['content'])
            ],
            'update' => [
                'response_id' => (int) $data['response_id'],
                'response' => isset($data['response']) ? wp_kses_post($data['response']) : null,
                'status' => isset($data['status']) && in_array($data['status'], ['published', 'hidden', 'flagged'])
                    ? $data['status']
                    : null
            ],
            'delete' => [
                'response_id' => (int) $data['response_id']
            ]
        $responses = Responses::getInstance();
        $response = match($action) {
            'create'    => $responses->respondTo(
                absint($data['item_id']),
                sanitize_text_field($data['content']),
                $user_id,
                wp_kses_post($data['response']),
                isset($data['parent_id']) ? absint($data['parent_id']) : null
            ),
            'update'    => $responses->editResponse(
                absint($data['response_id']),
                wp_kses_post($data['response']??'')
            ),
            'delete'    => $responses->deleteResponse(absint($data['response_id']))
        };
        // Queue the operation
        JVB()->queue()->queueOperation(
            $action . '_response',
            $user_id,
            $queue_data,
            [
                'operation_id' => 'u' . $user_id . '_' . $operation_id,
                'priority' => 'high'
            ]
        );
        return $this->queued($operation_id);
        if ($response['success']) {
            return $this->success($response);
        }
        return $this->error($response['message']);
    }
    /**
@@ -156,244 +121,11 @@
     */
    protected function getItemResponse(int $item_id, array $args = []): array|WP_Error
    {
        $defaults = [
            'page' => 1,
            'per_page' => $this->perPage,
            'orderby' => 'created_at',
            'order' => 'DESC',
            'parent_id' => null
        ];
        $args = wp_parse_args($args, $defaults);
        // Verify post exists
        $post = get_post($item_id);
        if (!$post) {
            return new WP_Error('not_found', 'Item not found');
        }
        // Build query with karma calculations
        $query = "
            SELECT r.*,
                COALESCE((SELECT COUNT(*) FROM {$this->table->getFullTableName()} WHERE parent_id = r.id), 0) as reply_count,
                COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'up'), 0) as upvotes,
                COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'down'), 0) as downvotes
            FROM {$this->table->getFullTableName()} r
            WHERE r.item_id = %d
        ";
        $query_args = [$item_id];
        // Filter by parent_id
        if (isset($args['parent_id'])) {
            $query .= " AND r.parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d");
            if ($args['parent_id'] !== null) {
                $query_args[] = $args['parent_id'];
            }
        }
        // Get total count for pagination
        $count_query = str_replace(
            ['SELECT r.*,', 'COALESCE((SELECT COUNT(*) FROM', 'as reply_count,', 'as upvotes,', 'as downvotes'],
            ['SELECT COUNT(*)', '', '', '', ''],
            $query
        );
        $count_query = preg_replace('/COALESCE\([^)]+\)[^,]*,?/', '', $count_query);
        $total_items = (int) $this->table->queryVar($count_query, $query_args);
        // Add ordering
        $order_column = in_array($args['orderby'], ['created_at', 'updated_at'])
            ? "r.{$args['orderby']}"
            : $args['orderby'];
        $query .= " ORDER BY {$order_column} {$args['order']}";
        // Add pagination
        $query .= " LIMIT %d OFFSET %d";
        $query_args[] = $args['per_page'];
        $query_args[] = ($args['page'] - 1) * $args['per_page'];
        // Get responses
        $responses = $this->table->queryResults($query, $query_args);
        $responses = Responses::getInstance()->getAllItemResponses($item_id, $args);
        // Format responses
        $items = array_map([$this, 'formatItem'], $responses);
        $responses['items'] = array_map([$this, 'formatItem'], $responses);
        return [
            'items' => $items,
            'has_more' => $args['page'] < ceil($total_items / $args['per_page']),
            'total_items' => $total_items,
            'total_pages' => (int) ceil($total_items / $args['per_page'])
        ];
    }
    /**
     * Process queue operations
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
    {
        if (!in_array($operation->type, ['create_response', 'update_response', 'delete_response'])) {
            return $result;
        }
        return match($operation->type) {
            'create_response' => $this->createResponse($operation, $data),
            'update_response' => $this->updateResponse($data),
            'delete_response' => $this->deleteResponse($data),
            default => $result
        };
    }
    /**
     * Create a new response
     */
    protected function createResponse(object $operation, array $data): array
    {
        $response_id = $this->table->insert([
            'item_id' => $data['item_id'],
            'content' => $data['content'],
            'user_id' => $operation->user_id,
            'parent_id' => $data['parent_id'],
            'response' => $data['response'],
            'status' => 'published'
        ]);
        if (!$response_id) {
            $this->logError('Failed to insert response', [
                'data' => $data,
                'error' => $this->table->getLastError()
            ]);
            return ['success' => false, 'result' => 'Failed to create response'];
        }
        // Send notifications
        $this->sendNotifications($data['item_id'], $data['parent_id'], $response_id, $operation->user_id);
        // Clear cache
        $this->cache->flush();
        return ['success' => true, 'result' => $response_id];
    }
    /**
     * Update a response
     */
    protected function updateResponse(array $data): array
    {
        $update_data = [];
        if (isset($data['response'])) {
            $update_data['response'] = $data['response'];
            $update_data['updated_at'] = current_time('mysql');
        }
        if (isset($data['status'])) {
            $update_data['status'] = $data['status'];
        }
        if (empty($update_data)) {
            return ['success' => false, 'result' => 'No fields to update'];
        }
        $updated = $this->table->update(
            $update_data,
            ['id' => $data['response_id']]
        );
        if ($updated === false) {
            $this->logError('Failed to update response', [
                'data' => $data,
                'error' => $this->table->getLastError()
            ]);
            return ['success' => false, 'result' => 'Failed to update response'];
        }
        $this->cache->flush();
        return ['success' => true, 'result' => $updated];
    }
    /**
     * Delete a response (or mark as deleted if it has replies)
     */
    protected function deleteResponse(array $data): array
    {
        $response = $this->table->get(['id' => $data['response_id']]);
        if (!$response) {
            return ['success' => false, 'result' => 'Response not found'];
        }
        // Check if it has replies
        $has_replies = $this->table->where(['parent_id' => $data['response_id']])->countResults() > 0;
        if ($has_replies) {
            // Don't delete, just mark as deleted
            $updated = $this->table->update(
                [
                    'response' => '[ deleted ]',
                    'status' => 'deleted',
                    'updated_at' => current_time('mysql')
                ],
                ['id' => $data['response_id']]
            );
            $this->cache->flush();
            return ['success' => true, 'result' => $updated];
        }
        // No replies, safe to delete
        $deleted = $this->table->delete(['id' => $data['response_id']]);
        if ($deleted === false) {
            $this->logError('Failed to delete response', [
                'data' => $data,
                'error' => $this->table->getLastError()
            ]);
            return ['success' => false, 'result' => 'Failed to delete response'];
        }
        $this->cache->flush();
        return ['success' => true, 'result' => $deleted];
    }
    /**
     * Send notifications for new responses
     */
    protected function sendNotifications(int $item_id, ?int $parent_id, int $response_id, int $user_id): void
    {
        // Notify post author
        $post = get_post($item_id);
        if ($post && $post->post_author != $user_id) {
            JVB()->notification()->addNotification(
                $post->post_author,
                'new_response',
                [
                    'message' => 'Someone responded to your post',
                    'item_id' => $item_id,
                    'response_id' => $response_id
                ]
            );
        }
        // Notify parent response author if this is a reply
        if ($parent_id) {
            $parent = $this->table->get(['id' => $parent_id]);
            if ($parent && $parent->user_id != $user_id) {
                JVB()->notification()->addNotification(
                    $parent->user_id,
                    'response_reply',
                    [
                        'message' => 'Someone replied to your response',
                        'item_id' => $item_id,
                        'response_id' => $response_id
                    ]
                );
            }
        }
        return $responses;
    }
    /**
@@ -420,7 +152,7 @@
        // Ordering
        if ($request->has_param('orderby')) {
            $orderby = $request->get_param('orderby');
            $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes'];
            $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes', 'karmic_score'];
            if (in_array($orderby, $valid)) {
                $args['orderby'] = $orderby;
@@ -438,74 +170,26 @@
    }
    /**
     * Get child responses recursively
     */
    protected function getChildren(int $item_id, int $parent_id): array
    {
        return $this->getItemResponse($item_id, ['parent_id' => $parent_id]);
    }
    /**
     * Format response for API output
     */
    protected function formatItem(object $response): array
    protected function formatItem(array $response): array
    {
        $formatted = [
            'id' => (int) $response->id,
            'item_id' => (int) $response->item_id,
            'parent_id' => $response->parent_id ? (int) $response->parent_id : null,
            'response' => $response->response,
            'status' => $response->status,
            'created_at' => $response->created_at,
            'updated_at' => $response->updated_at,
            'children' => $this->getChildren($response->item_id, $response->id),
            'upvotes' => (int) ($response->upvotes ?? 0),
            'downvotes' => (int) ($response->downvotes ?? 0),
            'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0),
            'reply_count' => (int) ($response->reply_count ?? 0)
        ];
        // Handle deleted users
        if ($response->is_user_deleted) {
            $formatted['user'] = [
        if ($response['is_user_deleted']) {
            $response['user'] = [
                'id' => null,
                'name' => '[deleted user]',
                'is_deleted' => true
            ];
        } else {
            // Add artist info
            $artist = jvbContentFromUser($response->user_id);
            if (!empty($artist)) {
                $formatted['artist'] = [
                    'name' => $artist['name'],
                    'url' => $artist['url'],
                    'shop' => $artist['shop'] ?? null
                ];
            }
            $user = get_userdata($response['user_id']);
            $response['user'] = [
                'id'    => $response['user_id'],
                'name'  => $user->display_name,
                'url'   => jvbUserProfileLink($response['user_id']),
            ];
        }
        return $formatted;
    }
    /**
     * Handle user deletion by anonymizing responses
     */
    public function handleUserDeletion(int $user_id): void
    {
        $updated = $this->table->update(
            [
                'is_user_deleted' => 1,
                'updated_at' => current_time('mysql')
            ],
            ['user_id' => $user_id]
        );
        $this->logError(
            'Anonymized responses for deleted user',
            ['user_id' => $user_id, 'count' => $updated],
            'info'
        );
        $this->cache->flush();
        return $response;
    }
}
inc/rest/routes/SEORoutes.php
@@ -1,15 +1,15 @@
<?php
namespace JVBase\rest\routes;
use JVBase\base\SchemaHelper;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\SEO\render\SchemaOutput;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\managers\Cache;
use JVBase\managers\SEO\ConfigManager;
use JVBase\managers\SEO\SchemaBuilder;
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
@@ -23,12 +23,14 @@
 */
class SEORoutes extends Rest
{
    use Instance;
//  protected SchemaBuilder $registry;
    public function __construct()
    {
        $this->cacheName = 'schema';
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, null, $this->cacheTtl);
//      $this->registry = SchemaBuilder::getInstance();
    }
@@ -106,7 +108,7 @@
    {
        $errors = [];
        $config = ConfigManager::for($context);
        $config = SchemaHelper::getInstance()::for($context);
        $result = $config->updateConfig($data);
        if (is_wp_error($result)) {
@@ -131,7 +133,7 @@
     */
    protected function saveTypeSettings(string $type, array $data): WP_REST_Response
    {
        $config = ConfigManager::for($type);
        $config = SchemaHelper::getInstance();
        // Separate meta and schema data if needed
        $meta = $data['meta'] ?? [];
@@ -141,23 +143,17 @@
        // Save meta configuration
        if (!empty($meta)) {
            $metaResult = $config->updateMeta($meta);
            if (is_wp_error($metaResult)) {
                $errors['meta'] = [
                    'message' => $metaResult->get_error_message(),
                    'errors' => $metaResult->get_error_data()
                ];
            $metaResult =  $config->updateMeta($type, $meta);
            if (!$metaResult) {
                $errors[] = 'meta';
            }
        }
        // Save schema configuration
        if (!empty($schema)) {
            $schemaResult = $config->updateConfig($schema);
            if (is_wp_error($schemaResult)) {
                $errors['schema'] = [
                    'message' => $schemaResult->get_error_message(),
                    'errors' => $schemaResult->get_error_data()
                ];
            $schemaResult = $config->updateSchema($type, $schema);
            if (!$schemaResult) {
                $errors[] = 'schema';
            }
        }
@@ -179,23 +175,11 @@
        $context = $request->get_param('context');
        $data = $request->get_json_params();
        $resetMeta = $data['resetMeta'] ?? false;
        $resetSchema = $data['resetSchema'] ?? false;
        $config = ConfigManager::for($context);
        // Reset based on what was requested
        if ($resetMeta) {
            $config->resetMeta();
        }
        if ($resetSchema) {
            $config->resetConfig();
        }
        if (!$resetMeta && !$resetSchema) {
            // Default: reset everything
            $config->resetAll();
        $type = $data['type']??false;
        $format = $data['resetMeta'] ? 'meta' : ($data['resetSchema'] ? 'schema' : ($data['resetArchive'] ? 'archive' : false));
        $hasIt = $type && $format;
        if ($hasIt) {
            SchemaHelper::reset($type, $format);
        }
        // Invalidate cache
@@ -204,8 +188,7 @@
        return $this->success([
            'status'    => 'completed',
            'message' => 'Reset to defaults successfully',
            'meta' => $config->meta(),
            'schema' => $config->schema()
            'result' => $hasIt ? SchemaHelper::getConfig($type, $format) : [],
        ]);
    }
@@ -215,25 +198,42 @@
    protected function previewSchema(WP_REST_Request $request): WP_REST_Response
    {
        $data = $request->get_json_params();
        $schemaType = $data['type'] ?? 'Thing';
        // Get field definitions from registry
        $fieldDefinitions = $this->registry->getMetaConfigForType($schemaType);
        //Whether it is the Schema, Meta, Archive, or Reference schema
        $format = $data['format']??false;
        if (!$format) {
            return $this->error('Required field "Format" missing');
        }
        //WebSite, Organization, or any registered post type/taxonomy
        $type = $data['type']??false;
        if (!$type) {
            return $this->error('Required field "Type" missing');
        }
        $class = $data['class']??false;
        if (!$class) {
            return $this->error('Required "Schema Class" field missing (Ex: Thing, LocalBusiness, etc)');
        }
        // Build schema with actual form values
        $schema = [
            '@type' => $schemaType,
            '@id'   => get_home_url() . '/#' . strtolower($schemaType),
            '@type' => $class,
        ];
        $theClass = SchemaHelper::classFromConfig($schema);
        // Add fields with their actual values
        foreach ($data['fields'] ?? [] as $fieldName => $value) {
            if (empty($value)) {
                continue;
            }
            $schema[$fieldName] = $value;
            $method = 'set'.ucfirst($fieldName);
            if (!method_exists($theClass, $method)) {
                error_log('Attempted field not found in class '.$class.': '.$fieldName);
                continue;
            }
            $theClass->$method($value);
        }
        $schema = $theClass->outputSchema();
        return $this->success([
            'schema' => $schema
@@ -243,11 +243,14 @@
    /**
     * Get fields for a schema type
     * Used for dynamic type switching
     * TODO
     */
    public function getFields(WP_REST_Request $request): WP_REST_Response
    {
        $type = $request->get_param('type');
        $class = SchemaHelper::classFromConfig(['type' => $type]);
        // Get Meta field definitions from registry
        $fields = $this->registry->getMetaConfigForType($type);
inc/rest/routes/SettingsRoutes.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\JVB;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
@@ -18,11 +19,13 @@
}
class SettingsRoutes extends Rest
{
    use Instance;
    protected int $count;
    public function __construct()
    {
        $this->cacheName = 'user_settings';
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, null, $this->cacheTtl);
//        $this->action = 'dash-';
//        $this->operation_type = 'user_settings';
        $this->count = 1;
@@ -84,7 +87,7 @@
            $data['notification_preferences'] = $add;
        }
        JVB()->queue()->queueOperation(
        Queue::getInstance()->queueOperation(
            'user_settings',
            $user_id,
            $data,
inc/rest/routes/ShopRoutes.php
@@ -2,6 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\managers\Emails;
use JVBase\managers\ImageGenerator;
use JVBase\managers\UploadManager;
use JVBase\registrar\Registrar;
@@ -104,7 +105,7 @@
            ]);
        }
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        unset($data['user']);
        $operationID = $data['id'];
        unset($data['id']);
@@ -239,7 +240,7 @@
                }
            }
        }
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $queue->queueOperation(
            'shop_image',
            $user_id,
@@ -261,7 +262,7 @@
            return $result;
        }
        $data['imageType'] = 'shop';
        $fileGenerator = new UploadManager('shop', $operation->user_id);
        $fileGenerator = UploadManager::getInstance(); //('shop', $operation->user_id);
        $generator = new ImageGenerator($data, $fileGenerator);
        $result = $generator->generate();
        if ($result['success']) {
@@ -544,7 +545,7 @@
        // Notify each manager
        foreach ($owners as $owner) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $owner,
                'artist_request',
                [
@@ -680,7 +681,7 @@
        // If user exists, send notification through the system
        if ($existing_user_id) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $existing_user_id,
                'artist_invitation',
                $manager_id,
@@ -737,7 +738,7 @@
            esc_url($invitation_url)
        );
        return JVB()->email()->sendEmail($email, $subject, $message);
        return Emails::getInstance()->sendEmail($email, $subject, $message);
    }
    /**
@@ -801,7 +802,7 @@
        // If invitation was for an existing user, notify them
        $user_id = email_exists($invitation->email);
        if ($user_id) {
            JVB()->notification()->addNotification(
            NotificationManager::getInstance()->addNotification(
                $user_id,
                'invitation_revoked',
                $manager_id,
@@ -869,7 +870,7 @@
        // Notify the artist
        $notification_type = $status === 'accepted' ? 'shop_accepted' : 'shop_rejected';
        JVB()->notification()->addNotification(
        NotificationManager::getInstance()->addNotification(
            $request->user_id,
            $notification_type,
            [
@@ -990,7 +991,7 @@
            $inviters = json_decode($invitation['inviters'], true);
            if (!empty($inviters)) {
                foreach ($inviters as $inviter_id) {
                    JVB()->notification()->addNotification(
                    NotificationManager::getInstance()->addNotification(
                        $inviter_id,
                        'invitation_accepted',
                        [
@@ -1060,7 +1061,7 @@
            $inviters = json_decode($invitation['inviters'], true);
            if (!empty($inviters)) {
                foreach ($inviters as $inviter_id) {
                    JVB()->notification()->addNotification(
                    NotificationManager::getInstance()->addNotification(
                        $inviter_id,
                        'invitation_declined',
                        [
@@ -1226,7 +1227,7 @@
            foreach ($owners as $owner_id) {
                if (!empty($owner_id)) {
                    JVB()->notification()->addNotification(
                    NotificationManager::getInstance()->addNotification(
                        $owner_id,
                        'artist_left',
                        $user_id,
inc/rest/routes/TermRoutes.php
@@ -1,6 +1,9 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\TaxonomyRelationships;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\managers\UserTermsManager;
@@ -15,6 +18,7 @@
}
class TermRoutes extends Rest
{
    use Instance;
    protected object $term_index_manager;
    protected int $per_page;
@@ -22,14 +26,14 @@
    {
        $this->cacheName = 'terms';
        parent::__construct();
        $this->cache = Cache::for('terms', 'term');
        if (JVB_TESTING) {
            $this->cache->flush();
        }
        $this->cache->connect('taxonomy', true);
        $this->per_page = 20;
        add_action('edited_term', [$this, 'deleteTermPath']);
        add_action('wp_login', [$this, 'clearUserTaxonomyCache'], 10, 2);
    }
    /**
@@ -88,7 +92,7 @@
        // Add relationship data if requested
        if ($request->get_param('include_relationships')) {
            $relationship_manager = JVB()->termRelationships();
            $relationship_manager = TaxonomyRelationships::getInstance();
            $related_taxonomies = $request->get_param('related_taxonomies') ?: ['jvb_style', 'jvb_theme', 'jvb_city'];
            $data['relationships'] = [];
@@ -122,15 +126,9 @@
    public function getTermDetails(WP_REST_Request $request): WP_REST_Response
    {
        $data = $request->get_params();
        $taxonomies = array_keys($data);
        $cache_check = $this->checkHeaders($request, $this->cache->generateKey(['termDetails' => $data]));
        if ($cache_check) {
            return $cache_check;
        }
        $terms = $this->cache->remember(
            $this->cache->generateKey(['termDetails' => $data]),
            ['termDetails' => $data],
            function() use ($data) {
                $result = [];
                foreach ($data as $tax => $IDs) {
@@ -144,7 +142,7 @@
            }
        );
        return $this->addCacheHeaders($this->success(['items' => $terms]));
        return $this->success(['items' => $terms]);
    }
    /**
@@ -156,11 +154,8 @@
    {
        $data = $request->get_params();
        $taxonomy = sanitize_text_field($data['taxonomy'])??'';
        // Check HTTP cache headers
        $cache_check = $this->checkHeaders($request, $taxonomy);
        if ($cache_check) {
            error_log('Header Check failed');
            return $cache_check;
        if (empty($taxonomy)) {
            return $this->success(['message' => 'No taxonomy selected']);
        }
        // Handle batch request (multiple taxonomies)
@@ -175,41 +170,40 @@
                'include'   => $data['termIDs'],
                'hide_empty'    => true,
            ];
            $key = $this->cache->generateKey($args);
            $cached = $this->cache->get($key);
            if ($cached) {
                $response = $this->success($cached);
                return $this->addCacheHeaders($response);
            }
            $data = $this->cache->remember(
                $args,
                function() use ($args, $taxonomy) {
                    return $this->formatTerms($args, $taxonomy);
                }
            );
            $formatted = $this->formatTerms($args, $taxonomy);
            $response = [
                'items' => $formatted
                'items' => $data
            ];
            $this->cache->set($key, $response);
            $response = $this->success($response);
            return $this->addCacheHeaders($response);
            return $this->success($response);
        }
        if (array_key_exists('content', $data)) {
            // If content_type is provided, use the specialized endpoint
            $content_type = $request->get_param('content');
            $registrar = Registrar::getInstance($content_type);
            if ($registrar->hasFeature('is_content')) {
                $response = $this->getTermsForContentType($request);
                return $this->addCacheHeaders($response);
                //TODO: Not sure what we're trying for here. Doesn't seem to be used.
                //TODO: Likely for taxonomy content types, like tattoo shops
                return $this->emptyResult();
            }
        }
        $taxonomy = BASE.$request->get_param('taxonomy');
        $taxonomy = jvbCheckBase($request->get_param('taxonomy'));
        if (!taxonomy_exists($taxonomy)) {
            return $this->emptyResult();
        }
        $search = $request->get_param('search');
        $parent = (int)$data['parent']??0;
        $page = max(1, (int)($data['page']??1));
        $per_page = 25;
        if (!taxonomy_exists($taxonomy)) {
            return $this->emptyResult();
        }
        $tax_obj = get_taxonomy($taxonomy);
        $is_hierarchical = $tax_obj->hierarchical;
@@ -217,8 +211,8 @@
        // If searching, handle differently
        if (!empty($search)) {
            error_log('Handling search...');
            $response = $this->handleTermSearch($request);
            return $this->addCacheHeaders($response);
            return $this->handleTermSearch($request);
        }
        // Get terms for current level with child count
@@ -237,12 +231,11 @@
            $main_context = json_decode($request->get_param('main_context'), true);
            $userID = get_post_meta($main_context['id'], BASE.'profile_link', true);
            $manager = new UserTermsManager();
            $manager = UserTermsManager::getInstance();
            $related = $manager->fetchUserTerms($userID, $taxonomy);
            if (empty($related)) {
                $response = $this->emptyResult();
                return $this->addCacheHeaders($response);
                return $this->emptyResult();
            }
            $args['include'] = $related;
@@ -251,12 +244,11 @@
            $main_context = json_decode($request->get_param('main_context'), true);
            $thisTaxonomy = str_replace('taxonomy:', '', $main_context['context']);
            $ID = (int)$main_context['id'];
            $manager = JVB()->termRelationships();
            $manager = TaxonomyRelationships::getInstance();
            $related = $manager->getRelatedTerms($ID, BASE.$request->get_param('taxonomy'));
            if (empty($related)) {
                $response = $this->emptyResult();
                return $this->addCacheHeaders($response);
                return $this->emptyResult();
            }
            $args['tax_query'] = [
                'taxonomy'  => $taxonomy,
@@ -269,7 +261,7 @@
            $match = $request->get_param('match') ?? 'any';
            $context = json_decode($request['context'], true);
            $relationshipManager = JVB()->termRelationships();
            $relationshipManager = TaxonomyRelationships::getInstance();
            // Prepare array to collect term IDs that match the context
            $related_term_ids = [];
@@ -305,49 +297,40 @@
                $args['include'] = $related_term_ids;
            } else {
                // No related terms found, return empty result
                $response = $this->emptyResult();
                return $this->addCacheHeaders($response);
                return $this->emptyResult();
            }
        }
        $response = $this->cache->remember(
            $args,
            function() use ($args, $taxonomy, $parent, $per_page, $page, $is_hierarchical) {
                $formatted_terms = $this->formatTerms($args, $taxonomy);
                // Get total count for pagination
                $total_terms = wp_count_terms([
                    'taxonomy' => $taxonomy,
                    'hide_empty' => false,
                    'parent' => $parent
                ]);
                // Calculate pagination
                $total_pages = ceil($total_terms / $per_page);
                $has_more = $page < $total_pages;
        $key = $this->cache->generateKey($args);
        $cache = $this->cache->get($key);
                return [
                    'items' => $formatted_terms,
                    'is_hierarchical' => $is_hierarchical,
                    'pagination' => [
                        'page' => $page,
                        'per_page' => $per_page,
                        'total_pages' => $total_pages,
                        'total_terms' => (int)$total_terms
                    ],
                    'has_more' => $has_more
                ];
            }
        );
        if ($cache) {
            $response = $this->success($cache);
            return $this->addCacheHeaders($response);
        }
        $formatted_terms = $this->formatTerms($args, $taxonomy);
        // Get total count for pagination
        $total_terms = wp_count_terms([
            'taxonomy' => $taxonomy,
            'hide_empty' => false,
            'parent' => $parent
        ]);
        // Calculate pagination
        $total_pages = ceil($total_terms / $per_page);
        $has_more = $page < $total_pages;
        $response = [
            'items' => $formatted_terms,
            'is_hierarchical' => $is_hierarchical,
            'pagination' => [
                'page' => $page,
                'per_page' => $per_page,
                'total_pages' => $total_pages,
                'total_terms' => (int)$total_terms
            ],
            'has_more' => $has_more
        ];
        $this->cache->set($key, $response);
        $response = $this->success($response);
        return $this->addCacheHeaders($response);
        return $this->success($response);
    }
    protected function handleBatchTermRequest(string $taxonomy, array $data, WP_REST_Request $request):WP_REST_Response
@@ -384,8 +367,7 @@
            'has_more' => true,
        ];
        $response = $this->success($response);
        return $this->addCacheHeaders($response);
        return $this->success($response);
    }
@@ -398,7 +380,7 @@
    protected function formatTerms(array $args, string $taxonomy): array
    {
        return $this->cache->remember(
            $this->cache->generateKey($args),
            $args,
            function() use ($args, $taxonomy) {
                $terms = get_terms($args);
@@ -408,7 +390,7 @@
                $formatted_terms = [];
                foreach ($terms as $term) {
                    $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, true);
                    $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy);
                }
                return $formatted_terms;
@@ -478,7 +460,7 @@
        ];
        $data = $this->cache->remember(
            $this->cache->generateKey($args),
            $args,
            function() use ($args, $taxonomy, $page, $per_page) {
                $terms = get_terms($args);
@@ -508,7 +490,7 @@
            }
        );
        return $this->addCacheHeaders($this->success($data));
        return $this->success($data);
    }
    /**
@@ -550,167 +532,6 @@
        delete_term_meta($termID, BASE.'term_path');
    }
    /**
     * @param string $username
     * @param object $user
     *
     * @return void
     */
    public function clearUserTaxonomyCache(string $username, object $user):void
    {
        global $wpdb;
        // Get all transients for this user
        $like_pattern = $wpdb->esc_like(BASE.'user_' . $user->ID) . '_%';
        $transients = $wpdb->get_col($wpdb->prepare("
            SELECT option_name
            FROM {$wpdb->options}
            WHERE option_name LIKE %s
            AND option_name LIKE %s
        ", '_transient_' . $like_pattern, '%'));
        // Delete all found transients
        foreach ($transients as $transient) {
            delete_transient(str_replace('_transient_', '', $transient));
        }
    }
    /**
     * @param WP_REST_Request $request
     *
     * @return WP_REST_Response
     */
    public function getTermsForContentType(WP_REST_Request $request):WP_REST_Response
    {
        $manager = JVB()->termRelationships();
        $content_type = BASE . $request->get_param('content');
        $taxonomy = BASE . $request->get_param('taxonomy');
        $search = $request->get_param('search');
        $page = max(1, intval($request->get_param('page')));
        $per_page = $request->get_param('per_page') ?: 20;
        // Create cache key
        $cache_key = "terms_for_{$content_type}_{$taxonomy}_" . md5("{$search}_{$page}_{$per_page}");
        $cache_key = $this->cache->generateKey([
            'terms_for'     => $content_type,
            'taxonomy'      => $taxonomy,
            'search'        => $search,
            'page'          => $page,
            'per_page'      => $per_page
        ]);
        $cache = $this->cache->get($cache_key);
        // Try cache first
        if ($cache !== false) {
            $response = $this->success($cache);
            return $this->addCacheHeaders($response);
        }
        try {
            global $wpdb;
            // Starting the query building
            $query_args = [];
            $terms_query = "
                SELECT DISTINCT t.term_id, t.name, t.slug, tt.count, tt.taxonomy,
                       (SELECT COUNT(DISTINCT tm.meta_key) FROM {$wpdb->termmeta} tm WHERE tm.term_id = t.term_id) AS meta_count
                FROM {$wpdb->terms} t
                JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
                JOIN {$manager->getTableName()} tr ON (
                    (tr.primary_taxonomy = %s AND tr.primary_term_id = t.term_id)
                    OR (tr.related_taxonomy = %s AND tr.related_term_id = t.term_id)
                )
            ";
            $query_args[] = $taxonomy;
            $query_args[] = $taxonomy;
            // Add content type filter through posts
            $query_where = " WHERE tt.taxonomy = %s";
            $query_args[] = $taxonomy;
            // Add search condition if provided
            if ($search) {
                $query_where .= " AND (t.name LIKE %s OR t.slug LIKE %s)";
                $search_like = '%' . $wpdb->esc_like($search) . '%';
                $query_args[] = $search_like;
                $query_args[] = $search_like;
            }
            // Add relationship strength ordering
            $query_order = " GROUP BY t.term_id";
            $query_order .= " ORDER BY COUNT(tr.id) DESC, tt.count DESC";
            // Add pagination
            $query_limit = " LIMIT %d OFFSET %d";
            $query_args[] = $per_page;
            $query_args[] = ($page - 1) * $per_page;
            // Combine the query
            $final_query = $query_where . $query_order . $query_limit;
            // Get terms
            $terms = $wpdb->get_results(
                $wpdb->prepare(
                    $terms_query . $final_query,
                    $query_args
                )
            );
            // Get total count for pagination
            $count_query = str_replace($query_limit, '', $query_where);
            $count_query = "SELECT COUNT(DISTINCT t.term_id) FROM {$wpdb->terms} t
                           JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
                           JOIN {$manager->getTableName()} tr ON (
                               (tr.primary_taxonomy = %s AND tr.primary_term_id = t.term_id)
                               OR (tr.related_taxonomy = %s AND tr.related_term_id = t.term_id)
                           ) " . $count_query;
            // Remove pagination args
            array_pop($query_args);
            array_pop($query_args);
            $total = $wpdb->get_var(
                $wpdb->prepare(
                    $count_query,
                    $query_args
                )
            );
            // Format terms
            $formatted_terms = [];
            $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
            foreach ($terms as $term) {
                $formatted = $this->formatSingleTerm($term, $taxonomy, false);
                // Add relationship strength which is unique to this method
                $formatted['relationship_strength'] = $term->relationship_count ?? 0;
                $formatted_terms[] = $formatted;
            }
            // Build response
            $total_pages = ceil($total / $per_page);
            $results = [
                'items' => $formatted_terms,
                'pagination'    => [
                    'page'      => (int)$page,
                    'per_page'  => (int)$per_page,
                    'total_terms'=> $total,
                    'total_pages'=> $total_pages
                ],
                'has_more'  => $page < $total_pages
            ];
            // Cache results
            $this->cache->set($cache_key, $results);
            $response = $this->success($results);
            return $this->addCacheHeaders($response);
        } catch (Exception $e) {
            return $this->error('Error getting terms for content: '.$e->getMessage(), 'get_terms_for_content');
        }
    }
    /**
     * @param WP_REST_Request $request
inc/rest/routes/UploadRoutes.php
@@ -1,9 +1,9 @@
<?php
namespace JVBase\rest\routes;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\mergers\UploadMerger;
use JVBase\managers\queue\TypeConfig;
use JVBase\Instance;
use JVBase\managers\Errors;
use JVBase\managers\queue\Queue;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
@@ -12,7 +12,6 @@
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Exception;
if (!defined('ABSPATH')) {
@@ -20,71 +19,15 @@
}
class UploadRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
        parent::__construct();
        add_action('init', [$this, 'registerUploadExecutors'], 5);
    }
    /**
     * Register upload operation types with the queue's TypeRegistry
     */
    public function registerUploadExecutors(): void
    {
        $registry = JVB()->queue()->registry();
        $executor = new UploadExecutor();
        $merger = new UploadMerger('secured_files');
        // Image uploads - chunked at 5 files
        $registry->register('image_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 3
        ));
        // Video uploads - one at a time (heavy processing)
        $registry->register('video_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 1
        ));
        // Document uploads - chunked at 10
        $registry->register('document_upload', new TypeConfig(
            mergeable: $merger,
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 5
        ));
        // Metadata updates
        $registry->register('update_image_meta', new TypeConfig(
            executor: $executor
        ));
        // Cleanup - chunked at 5
        $registry->register('temporary_cleanup', new TypeConfig(
            executor: $executor,
            chunkKey: 'files',
            chunkSize: 5
        ));
        // Attach to content (depends on upload completing)
        $registry->register('attach_upload_to_content', new TypeConfig(
            executor: $executor
        ));
        // Process upload groups into posts
        $registry->register('process_upload_groups', new TypeConfig(
            executor: $executor,
            chunkKey: 'posts',
            chunkSize: 5
        ));
    }
    /**
     * Registers upload routes
@@ -305,7 +248,7 @@
        } catch (Exception $e) {
            // Error handling...
            JVB()->error()->log(
            Errors::getInstance()->log(
                '[UploadRoutes]:handleUploadRequest',
                $e->getMessage(),
                [
@@ -323,7 +266,7 @@
     */
    protected function secureFiles(array $files, array $args): array
    {
        $uploader = new UploadManager();
        $uploader = UploadManager::getInstance();
        $secured_files = [];
        $errors = [];
@@ -411,7 +354,7 @@
        error_log('Queueing Operation: '.print_r($operation_type, true));
        error_log('With ID: '.print_r($args['upload'], true));
        $queuedProcessing = JVB()->queue()->queueOperation(
        $queuedProcessing = Queue::getInstance()->queueOperation(
            $operation_type,
            get_current_user_id(),
            array_merge(
@@ -435,7 +378,7 @@
            // When merged, the original upload's attach_upload_to_content
            // will handle all files after the merged image_upload completes.
            if (!$queuedProcessing['updated_existing']) {
                JVB()->queue()->queueOperation(
                Queue::getInstance()->queueOperation(
                    'attach_upload_to_content',
                    get_current_user_id(),
                    $args,
@@ -448,21 +391,6 @@
            }
        }
        JVB()->queue()->queueOperation(
            'temporary_cleanup',
            get_current_user_id(),
            [
                'files'     => $secured_data['files'],
                'context'   => $args,
            ],
            [
                'priority'      => 'low',
                'chunk_size'    => 5,
                'chunk_key'     => 'files',
                'depends_on'    => [$uploadOpId]
            ]
        );
        return $args['id'];
    }
@@ -480,145 +408,8 @@
        };
    }
    /**
     * Step 3: Process operation from queue
     * Called by OperationQueue
     * @param WP_Error|array $result
     * @param object $operation
     * @param array $data
     *
     * @return array|WP_Error
     */
    public function processOperation(array $result, object $operation, array $data): array
    {
        // Only handle our operation types
        $handled_types = [
            'image_upload',
            'video_upload',
            'document_upload',
            'update_metadata',
            'temporary_cleanup',
            'attach_upload_to_content',
            'process_upload_groups'
        ];
        if (!in_array($operation->type, $handled_types)) {
            return $result; // Not our operation, pass through
        }
        try {
            // Route to appropriate handler
            $handler_result = match($operation->type) {
                'image_upload' => $this->processImageUpload($operation, $data),
                'video_upload' => $this->processVideoUpload($operation, $data),
                'document_upload' => $this->processDocumentUpload($operation, $data),
                'update_metadata' => $this->processUploadMeta($result, $operation, $data),
                'temporary_cleanup' => $this->processTemporaryCleanup($result, $operation, $data),
                'attach_upload_to_content' => $this->processAttachToContent($operation, $data),
                'process_upload_groups' => $this->processUploadGroups($result, $operation, $data),
                default => new WP_Error('unknown_type', 'Unknown operation type')
            };
            // Handle WP_Error
            if (is_wp_error($handler_result)) {
                return [
                    'success' => false,
                    'result' => $handler_result->get_error_message()
                ];
            }
            return $handler_result;
        } catch (Exception $e) {
            JVB()->error()->log(
                '[UploadRoutes]:processOperation',
                $e->getMessage(),
                [
                    'operation_id' => $operation->id,
                    'operation_type' => $operation->type
                ]
            );
            return [
                'success' => false,
                'result' => $e->getMessage()
            ];
        }
    }
    /**
     * Standardize processing result
     */
    protected function standardizeResult(array $result): array
    {
        return [
            'attachment_id' => $result['attachment_id'],
            'url' => $result['url'],
            'file' => $result['file'],
            'upload_id' => $result['upload_id'] ?? null
        ];
    }
    protected function processAttachToContent(object $operation, array $data): array
    {
        try {
            // Get the results from the upload operation
            $upload_results = JVB()->queue()->getOperationValue($data['upload'], 'result', true);
            if (empty($upload_results)) {
                throw new Exception('No upload results found for operation: ' . $data['upload']);
            }
            if (empty($data['post_id']) || str_starts_with((string)($data['item_id'] ?? ''), 'new')) {
                foreach ($operation->dependencies as $depId) {
                    $dep = JVB()->queue()->get($depId);
                    if ($dep && $dep->type === 'content_update' && !empty($dep->result['new_posts'])) {
                        $itemId = $data['item_id'] ?? null;
                        if ($itemId && isset($dep->result['new_posts'][$itemId])) {
                            $data['post_id'] = $dep->result['new_posts'][$itemId];
                            break;
                        }
                    }
                }
                if (empty($data['post_id'])) {
                    throw new Exception('Could not resolve post_id from dependencies');
                }
            }
            // Now attach to the specified content
            if (!empty($data['field_name'])) {
                $this->updateFieldValue($data, $upload_results);
            }
            return [
                'success' => true,
                'result' => 'Attachments linked to content'
            ];
        } catch (Exception $e) {
            return [
                'success' => false,
                'result' => $e->getMessage()
            ];
        }
    }
    /**
     * Cleanup temporary files after processing
     */
    protected function cleanupTempFiles(array $secured_files, int $user_id): void
    {
        $uploader = new UploadManager();
        foreach ($secured_files as $secured) {
            if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
                @unlink($secured['temp_path']);
            }
        }
        // Clean up empty directories
        $uploader->cleanupEmptyTempDirs($user_id);
    }
    /**
     * Update field value with new attachment IDs
@@ -650,159 +441,6 @@
        $meta->set($data['field_name'], implode(',', $all_ids));
    }
    /**
     * Generic file upload processor - works for images, videos, documents
     */
    protected function processFileUpload(object $operation, array $data, string $file_type): WP_Error|array
    {
        try {
            $uploader = new UploadManager();
            $processed_results = [];
            $config = $this->getFieldConfig($data);
            $args = $data;
            unset($args['secured_files']);
            foreach ($data['secured_files'] as $secured_file) {
                $result = $uploader->processUpload(
                    $secured_file['temp_path'],
                    array_merge(
                        $config,
                        [
                            'file_type' => $file_type,
                            'user_id' => $operation->user_id,
                            'post_id' => (int)($data['post_id'] ?? 0),
                            'term_id' => (int)($data['term_id'] ?? 0),
                            'original_name' => $secured_file['original_name'],
                            'mime_type' => $secured_file['mime_type'],
                            'content' => $data['content'] ?? '',
                        ]
                    )
                );
                if (!is_wp_error($result)) {
                    $standardized = $this->standardizeResult($result);
                    // Use embedded upload_id from the secured file itself
                    $standardized['upload_id'] = $secured_file['upload_id'] ?? null;
                    if ($standardized['upload_id']) {
                        $processed_results[$standardized['upload_id']] = $standardized;
                    } else {
                        $processed_results[] = $standardized;
                    }
                    // Apply frontend metadata if provided
                    if (!empty($secured_file['metadata'])) {
                        $this->applyMeta($standardized['attachment_id'], $secured_file['metadata']);
                    }
                }
            }
            $this->handleUploadDestination($data, $processed_results);
            // Cleanup temporary files
            $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
            return [
                'success' => true,
                'result' => $processed_results
            ];
        } catch (Exception $e) {
            JVB()->error()->log(
                '[UploadRoutes]:processFileUpload',
                $e->getMessage(),
                [
                    'operation_id' => $operation->id,
                    'file_type' => $file_type,
                    'user_id' => $operation->user_id
                ]
            );
            return [
                'success'   => false,
                'result'    => $e->getMessage()
            ];
        }
    }
    /**
     * Process image uploads
     */
    protected function processImageUpload(object $operation, array $data): WP_Error|array
    {
        return $this->processFileUpload($operation, $data, 'image');
    }
    /**
     * Process video uploads
     */
    protected function processVideoUpload(object $operation, array $data): WP_Error|array
    {
        return $this->processFileUpload($operation, $data, 'video');
    }
    /**
     * Process document uploads
     */
    protected function processDocumentUpload(object $operation, array $data): WP_Error|array
    {
        return $this->processFileUpload($operation, $data, 'document');
    }
    /**
     * Process temporary cleanup operation
     * Called by OperationQueue for 'temporary_cleanup' operations
     *
     * @param array $result
     * @param object $operation
     * @param array $data
     * @return array
     */
    protected function processTemporaryCleanup(array $result, object $operation, array $data): array
    {
        try {
            // Cleanup temporary files if they exist
            if (!empty($data['secured_files'])) {
                $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
            }
            // If specific temp paths provided
            if (!empty($data['temp_paths']) && is_array($data['temp_paths'])) {
                foreach ($data['temp_paths'] as $temp_path) {
                    if (file_exists($temp_path)) {
                        @unlink($temp_path);
                    }
                }
            }
            // Cleanup empty temp directories for this user
            if (!empty($operation->user_id)) {
                $uploader = new UploadManager();
                $uploader->cleanupEmptyTempDirs($operation->user_id);
            }
            return [
                'success' => true,
                'result' => 'Temporary files cleaned up successfully'
            ];
        } catch (Exception $e) {
            JVB()->error()->log(
                '[UploadRoutes]:processTemporaryCleanup',
                $e->getMessage(),
                [
                    'operation_id' => $operation->id,
                    'user_id' => $operation->user_id
                ]
            );
            return [
                'success' => false,
                'result' => $e->getMessage()
            ];
        }
    }
    /**
     * Handle metadata update requests
@@ -836,6 +474,8 @@
            }
            return $this->sendResponse(
                false,
                ['error_code' => 'missing_identifiers'],
@@ -843,7 +483,7 @@
            );
        } catch (Exception $e) {
            JVB()->error()->log(
            Errors::getInstance()->log(
                '[UploadRoutes]:handleMetadataUpdate',
                $e->getMessage(),
                [
@@ -904,7 +544,7 @@
     */
    protected function queueMetaUpdate(array $data, int $user, ?string $operationId = null): WP_REST_Response
    {
        $queue = JVB()->queue();
        $queue = Queue::getInstance();
        $depends_on = [];
        $errors = [];
        $original = count($data);
@@ -936,76 +576,7 @@
        );
    }
    /**
     * Process metadata update operation (called by queue processor)
     */
    public function processUploadMeta(WP_Error|array $result, object $operation, array $data): WP_Error|array
    {
        try {
            if (!is_array($operation->depends_on)) {
                $operation->depends_on = [$operation->depends_on];
            }
            $updated_count = 0;
            $errors = [];
            foreach ($operation->depends_on as $dependency) {
                $operationData = JVB()->queue()->getOperation($dependency);
                if (!$operationData || $operationData->state !== 'completed') {
                    throw new Exception('Original upload operation not found or not completed');
                }
                $uploadResults = json_decode($operationData->result, true);
                if (!$uploadResults || !$uploadResults['success']) {
                    throw new Exception('Original upload operation failed');
                }
                $attachmentsToUpdate = array_filter($data, function ($item) use ($dependency) {
                    return $item['depends_on'] === $dependency;
                });
                $uploadIDToAttachment = $this->buildUploadToAttachmentMapping(
                    array_keys($attachmentsToUpdate),
                    $uploadResults['data']
                );
                if (empty($uploadIDToAttachment)) {
                    throw new Exception('No valid upload ID to attachment ID mappings found');
                }
                foreach ($uploadIDToAttachment as $uploadId => $attachmentId) {
                    try {
                        $this->applyMeta($attachmentId, $attachmentsToUpdate[$uploadId]);
                        $updated_count++;
                    } catch (Exception $e) {
                        $errors[] = "Upload {$uploadId} (Attachment {$attachmentId}): " . $e->getMessage();
                    }
                }
            }
            return [
                'success' => $updated_count > 0,
                'result'    => [
                    'updated_count' => $updated_count,
                    'mappings' => $uploadIDToAttachment,
                    'errors' => $errors,
                    'message' => "Applied metadata to {$updated_count} attachment(s)"
                ]
            ];
        } catch (Exception $e) {
            JVB()->error()->log(
                '[UploadRoutes]:processUploadMeta',
                $e->getMessage(),
                [
                    'operation_id' => $operation->id,
                    'original_operation_id' => $data['original_operation_id'] ?? 'unknown'
                ]
            );
            return [
                'success'   => false,
                'result'    => $e->getMessage()
            ];
        }
    }
    protected function applyMeta(int $attachment_id, array $metadata): void
    {
@@ -1031,31 +602,9 @@
    }
    /**
     * Build mapping from frontend upload IDs to WordPress attachment IDs
     */
    protected function buildUploadToAttachmentMapping(array $upload_ids, array $results): array
    {
        $mapping = [];
        foreach ($results as $result) {
            if (!isset($result['upload_id']) || !isset($result['attachment_id'])) {
                continue;
            }
            $upload_id = $result['upload_id'];
            $attachment_id = $result['attachment_id'];
            // Validate that this upload_id was requested
            if (in_array($upload_id, $upload_ids)) {
                $mapping[$upload_id] = $attachment_id;
            }
        }
        return $mapping;
    }
    /**
     * Verify user has access to attachment
     * @param int $attachment_id
     * @param int $user_id
     * @return bool
     */
    protected function verifyAttachmentAccess(int $attachment_id, int $user_id): bool
    {
@@ -1070,59 +619,9 @@
    }
    /**
     * Get field configuration for upload processing
     */
    protected function getFieldConfig(array $data): array
    {
        static $config_cache = [];
        $cache_key = md5(json_encode([
            $args['content'] ?? '',
            $args['field_name'] ?? ''
        ]));
        if (isset($config_cache[$cache_key])) {
            return $config_cache[$cache_key];
        }
        $config = [
            'allowed_types' => null,
            'max_size' => null,
            'convert' => 'webp',
            'quality' => 80,
            'create_thumbnails' => true,
        ];
        // Get field definition from registry if available
        if (!empty($args['content']) && !empty($args['field_name'])) {
            $content_type = $args['content'];
            $field_name = $args['field_name'];
            $registrar = Registrar::getInstance($content_type);
            if ($registrar) {
                $content_fields = $registrar->getFields();
                if (array_key_exists($field_name, $content_fields)) {
                    $field_def = $content_fields[$field_name];
                    // Extract relevant config from field definition
                    $config = array_merge($config, [
                        'allowed_types' => $field_def['accepted_types'] ?? null,
                        'max_size' => $field_def['max_size'] ?? null,
                        'convert' => $field_def['convert'] ?? 'webp',
                        'quality' => $field_def['quality'] ?? 80,
                        'create_thumbnails' => $field_def['create_thumbnails'] ?? true,
                    ]);
                }
            }
        }
        $config_cache[$cache_key] = $config;
        return $config;
    }
    /**
     * Get files info for error logging
     * @param array $files
     * @return array
     */
    protected function getFilesInfo(array $files): array
    {
@@ -1181,7 +680,7 @@
                $chunkSize = 10;
            }
            JVB()->queue()->queueOperation(
            Queue::getInstance()->queueOperation(
                $operation_type,
                $args['user'],
                array_merge(
@@ -1195,7 +694,7 @@
                ]
            );
            $ID = JVB()->queue()->queueOperation(
            $ID = Queue::getInstance()->queueOperation(
                'process_upload_groups',
                $args['user'],
                $args,
inc/rest/routes/UserRoutes.php
@@ -1,5 +1,7 @@
<?php
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
@@ -16,12 +18,14 @@
 */
class UserRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
        $this->cacheName = 'user';
        $this->cacheTtl = WEEK_IN_SECONDS;
        parent::__construct();
        $this->cache = Cache::for($this->cacheName, 'user', $this->cacheTtl);
    }
@@ -50,6 +54,7 @@
        try {
            $meta = Meta::forUser($user_id);
            $meta->setAll($data['changes']);
            Cache::clearUser($user_id);
            return $this->success();
        } catch (Exception $e) {
            return $this->error($e->getMessage());
inc/rest/routes/VoteRoutes.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\managers\CustomTable;
use JVBase\Instance;
use JVBase\managers\KarmaManager;
use JVBase\registrar\Registrar;
use JVBase\rest\Response;
@@ -9,14 +9,13 @@
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Exception;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class VoteRoutes extends Rest
{
    use Instance;
    public function __construct()
    {
        $this->cacheName = 'karma';
@@ -77,7 +76,7 @@
        }
        $man = KarmaManager::getInstance($type);
        $man = $registrar->getKarmaManager();
        if (!$man) {
            return Response::validationError(['message' => __('Karma not set up', 'jvb')]);
        }
@@ -104,15 +103,6 @@
            return $this->unauthorized();
        }
        $cache = $this->cache->get($user);
        if ($cache) {
            return Response::success($cache);
        }
        $votes = KarmaManager::getUserVotes($user);
        $this->cache->set($user, $votes);
        return Response::success($votes);
        return Response::success(KarmaManager::getUserVotes($user));
    }
}
inc/rest/routes/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
inc/ui/Checkout.php
@@ -3,6 +3,7 @@
use JVBase\base\Site;
use JVBase\integrations\Integrations;
use JVBase\integrations\ServiceMap;
use JVBase\meta\Form;
use JVBase\meta\Meta;
@@ -29,13 +30,14 @@
 */
class Checkout
{
    use ServiceMap;
    public static function getProvider():Integrations|false
    {
        $int = ['square','helcim'];
        foreach ($int as $i) {
            if (Site::hasIntegration($i)) {
                error_log('Site has '.$i);
                return JVB()->connect($i);
                $self = new self();
                return $self->getService($i);
            }
        }
        return false;
@@ -70,7 +72,7 @@
            $tabs->addTab('thelogin')
                ->title('Login')
                ->description('Login to see past orders and access your saved cards')
                ->content(JVB()->magicLink()->renderForm());
                ->content(MagicLinks::getInstance()->renderForm());
        } else {
            $tabs->addTab('account')
                ->title('Account')
inc/ui/_setup.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
require(JVB_DIR.'/inc/ui/Modal.php');
require(JVB_DIR.'/inc/ui/Navigation.php');
require(JVB_DIR.'/inc/ui/Tabs.php');
inc/ui/index.php
@@ -1,3 +1,2 @@
<?php
//Nothing to see here
// Silence is golden.
inc/utility/helpers.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
inc/utility/setup.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
require(JVB_DIR.'/inc/utility/helpers.php');
require(JVB_DIR.'/inc/utility/Image.php');
require(JVB_DIR.'/inc/utility/Video.php');
jvb.php
@@ -9,17 +9,36 @@
Textdomain: jvb
*/
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
use JVBase\admin\SEOAdmin;
use JVBase\base\SchemaHelper;
use JVBase\blocks\CustomBlocks;
use JVBase\JVB;
use JVBase\managers\AdminPages;
use JVBase\managers\Approvals;
use JVBase\managers\Dashboard\Dashboard;
use JVBase\managers\DirectoryManager;
use JVBase\managers\Emails;
use JVBase\managers\Favourites;
use JVBase\managers\IconsManager;
use JVBase\managers\InvitationsManager;
use JVBase\managers\Login;
use JVBase\managers\MagicLinks;
use JVBase\managers\NotificationManager;
use JVBase\managers\Notifications\Notifications;
use JVBase\managers\queue\Queue;
use JVBase\managers\Referrals;
use JVBase\managers\Responses;
use JVBase\managers\Roles;
use JVBase\managers\SEO\render\SchemaOutput;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
//security
if (!defined('ABSPATH')) {
    exit;
}
//add_action('admin_enqueue_scripts', function() {
//  global $wp_scripts;
//  add_action('admin_print_scripts', function() use ($wp_scripts) {
@@ -119,11 +138,13 @@
define('JVB_LOCKOUT_DURATION', 15 * MINUTE_IN_SECONDS);
require(JVB_DIR.'/inc/Instance.php');
require(JVB_DIR.'/base/_setup.php');
require(JVB_DIR.'/inc/utility/setup.php');
require(JVB_DIR.'/checks.php');
require(JVB_DIR . '/inc/integrations/ServiceMap.php');
require(JVB_DIR . '/inc/registrar/_setup.php');
require(JVB_DIR . '/activate.php');
@@ -212,7 +233,7 @@
        'display' => __('Every minute', 'jvb')
    ];
    $schedules[ 'monthly' ] = [
        'interval' => 604800 * 4,
        'interval' => MONTH_IN_SECONDS,
        'display' => __('Once Monthly', 'jvb')
    ];
@@ -223,20 +244,6 @@
register_deactivation_hook(JVB_DIR.'/jvb.php', 'jvbDeactivatePlugin');
add_action('init', 'jvbUserCheck');
function jvbUserCheck():void
{
    if (is_admin() && isOurPeople()) {
        wp_redirect(get_home_url(null, '/dash'));
        exit;
    }
}
//require(JVB_DIR . '/inc/users/UserSettings.php');
@@ -287,7 +294,61 @@
function jvbLoadBase():void
{
    JVB::getInstance();
    do_action('jvbloaded');
    Emails::getInstance();
    Queue::getInstance();
    Roles::getInstance();
    Login::getInstance();
    SEOAdmin::getInstance();
    SchemaOutput::getInstance();
    SchemaHelper::getInstance();
    CustomBlocks::getInstance();
    AdminPages::getInstance();
    if (Site::has('magic_link')) {
        MagicLinks::getInstance();
    }
    if (Site::has('dashboard')) {
        Dashboard::getInstance();
    }
    if (Site::has('referrals')) {
        Referrals::getInstance();
    }
    if (Site::has('is_directory')) {
        DirectoryManager::getInstance();
    }
    if (Site::has('feed_block')) {
        TaxonomyRelationships::getInstance();
    }
    if (Site::hasAll(['dashboard', 'membership', 'feed_block'])) {
        UserTermsManager::getInstance();
    }
    if (Site::has('favourites')) {
        Favourites::getInstance();
    }
    if (!empty(Registrar::withFeature('has_responses'))) {
        Responses::getInstance();
    }
    $membership = Site::membership();
    if ($membership && $membership->has('notifications')) {
        NotificationManager::getInstance();
    }
    if ($membership && $membership->has('invitable')) {
        InvitationsManager::getInstance();
    }
    if (!empty(Registrar::withFeature('approve_new'))) {
        Approvals::getInstance();
    }
    jvbLoadRoutes();
}
function jvb_integration_definitions():void
@@ -301,12 +362,92 @@
    Registrar::maybeBuildSections();
}
add_action('wp_footer', 'jvbAdditionalActions');
function jvbAdditionalActions():void
{
    if (Login::isLogin()) {
        return;
    }
    $extras = apply_filters('jvbAdditionalActions', []);
    $extras = array_filter($extras, function ($extra) {
        return is_array($extra) && array_key_exists('button', $extra) && array_key_exists('content', $extra);
    });
    if (empty ($extras)) {
        return;
    }
    $buttons = array_map(function ($extra) {
        return $extra['button'];
    }, $extras);
    $contents = array_map(function($extra) {
        return $extra['content'];
    }, $extras);
    if (!empty ($buttons)) {
        ?>
        <section class="main-actions">
            <div class="buttons col">
                <?= implode('', $buttons); ?>
            </div>
            <div class="actions">
                <?= implode('', $contents); ?>
            </div>
        </section>
        <?php
    }
}
function JVB(): JVB
{
    return JVB::getInstance();
}
use JVBase\rest\routes\AdminRoutes;
use JVBase\rest\routes\ContentRoutes;
use JVBase\rest\routes\ContentTermsRoutes;
use JVBase\rest\routes\ErrorRoutes;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FormRoutes;
use JVBase\rest\routes\NewsRoutes;
use JVBase\rest\routes\OptionsRoutes;
use JVBase\rest\routes\SEORoutes;
use JVBase\rest\routes\SettingsRoutes;
use JVBase\rest\routes\TermRoutes;
use JVBase\rest\routes\UploadRoutes;
function jvbLoadRoutes():void
{
    SEORoutes::getInstance();
    SettingsRoutes::getInstance();
    UploadRoutes::getInstance();
    if (!empty(apply_filters('jvb_register_forms', []))) {
        FormRoutes::getInstance();
    }
    UserRoutes::getInstance();
    if (Site::has('feed_block')) {
        FeedRoutes::getInstance();
    }
    if (Site::has('feed_block') || Site::has('dashboard')) {
        TermRoutes::getInstance();
    }
    if (Site::has('dashboard')) {
        ErrorRoutes::getInstance();
        AdminRoutes::getInstance();
        ContentRoutes::getInstance();
        ContentTermsRoutes::getInstance();
        OptionsRoutes::getInstance();
    }
    $membership = Site::membership();
    if ($membership && $membership->has('forum'))
    {
        NewsRoutes::getInstance();
    }
}
@@ -340,192 +481,7 @@
}
/**
 * Scripts
 */
add_action('wp_enqueue_scripts', 'jvbScripts', 10);
function jvbScripts():void
{
    add_action('wp_head', 'jvbInlineNavStyles');
    if (Site::has('dashboard')) {
        wp_enqueue_script('jvb-queue');
    }
    wp_enqueue_script('jvb-auth');
    wp_enqueue_script('jvb-settings');
    wp_enqueue_script('jvb-navigation');
//    wp_enqueue_script('jvb-ui');
//    wp_enqueue_script('jvb-media');
    wp_enqueue_script('jvb-gallery');
    wp_enqueue_script('jvb-cache');
    $interactions = [];
    if (Site::has('favourites')) {
        $interactions[] = 'favourites';
    }
    if (!empty(Registrar::withFeature('karma'))) {
        $interactions[] = 'karma';
    }
    if (Site::has('notifications')) {
        $interactions[] = 'notifications';
    }
    if (!empty($interactions)) {
        foreach($interactions as $interaction) {
            wp_enqueue_script('jvb-'.$interaction);
        }
    }
    $queue = [
        'api' => rest_url('jvb/v1/'),
        'redirect' => get_home_url(null, '/login/'),
        'labels' => Registrar::getLabels(),
    ];
    wp_localize_script('jvb-auth', 'jvbSettings', $queue);
    $initUserSettings = 'async function initUserItems() {
    if (!jvbSettings.currentUser) return;
    window.userFavourites = {};
    window.userVotes = {};
    ';
    if (Site::has('favourites')) {
        wp_enqueue_script('jvb-favourites');
        $initUserSettings .= '
        //Fetch user favourites
        try {
            const result = await window.jvbCache.fetchWithCache(
                `${jvbSettings.api}favourites?all=true`,
                {
                    method: \'GET\',
                    headers: {\'X-WP-Nonce\': jvbSettings.nonce}
                }
            );
            if (result && result.favourites) {
                for (const key in result.favourites) {
                    window.userFavourites[key] = new Set();
                    result.favourites[key].forEach(id => {
                        window.userFavourites[key].add(parseInt(id));
                    });
                }
            }
        } catch (error) {
            console.error(\'Failed to fetch user favourites:\', error);
        }';
    }
    if (!empty(Registrar::withFeature('karma'))) {
        wp_enqueue_script('jvb-votes');
        $initUserSettings .= '// Fetch user votes
        try {
            const result = await window.jvbCache.fetchWithCache(
                `${jvbSettings.api}vote?user=${jvbSettings.currentUser}`,
                {
                    method: \'GET\',
                    headers: {\'X-WP-Nonce\': jvbSettings.nonce}
                },
                {forceRefresh: true}
            );
            window.userVotes = {};
            if (result) {
                for (const key in result) {
                    window.userVotes[key] = new Map();
                    for (const [id, vote] of Object.entries(result[key])) {
                        window.userVotes[key].set(parseInt(id), vote);
                    }
                }
            }
        } catch (error) {
            console.error(\'Failed to fetch user votes:\', error);
        }';
    }
    $initUserSettings .= '} initUserItems();';
//  wp_add_inline_script('jvb-queue', $initUserSettings, 'after');
    if (is_search() ||
        is_tax(BASE.'shop') ||
        is_singular(BASE.'artist') ||
        jvbIsDirectory()
    ) {
        wp_enqueue_script('jvb-page-nav');
    }
    if (has_block('jvb/summaryBlock')) {
        wp_enqueue_script('jvb-page-nav');
    }
        // Only load on single shop pages or other relevant pages
    if (is_tax(BASE.'shop') ||
        is_singular(BASE.'partner')) {
            $ID = get_queried_object_id();
        if (is_tax()) {
            $check = get_term_meta($ID, BASE.'has_map', true);
        } else {
            $check = get_post_meta($ID, BASE.'has_map', true);
        }
//        if ($check && $check !== '') {
////            wp_enqueue_script(
////                'google-maps-frontend',
////                'https://maps.googleapis.com/maps/api/js?key=' . GOOGLE_API . '&callback=googleMapsLoaded',
////                [],
////                null,
////                ['strategy' => 'async',
////                 'in_footer' => true]
////            );
//
//            // Add the callback function
//            wp_add_inline_script('google-maps-frontend', '
//            function googleMapsLoaded() {
//                if (window.googleMapsCallbacks && window.googleMapsCallbacks.length) {
//                    window.googleMapsCallbacks.forEach(callback => callback());
//                }
//            }
//        ');
//        }
    }
    if (is_user_logged_in() && Site::has('notifications')) {
        wp_enqueue_script('jvb-notifications');
        wp_localize_script('jvb-notifications', 'notificationSettings', array(
            'homeUrl' => home_url(),
            'strings' => array(
                'loading' => 'Loading...',
                'error' => 'Failed to load notifications',
                'noNotifications' => 'No new notifications',
                'viewAll' => 'View All Notifications',
                'markRead' => 'Mark as read',
                'review' => 'Review'
            )
        ));
    }
//    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-queue');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-selector');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-data-store');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-cache');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-a11y');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-utility');
    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-gallery');
//    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-loading');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-queue');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-quill');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-form');
    jvbAddScriptDependency('jvb-forms-view-script', 'jvb-tabs');
    jvbAddScriptDependency('jvb-timeline-view-script', 'jvb-gallery');
}
function jvbAdminMap():void
{
@@ -550,21 +506,7 @@
//    ');
}
function jvbAddScriptDependency(string $handle, string $dep):bool
{
    global $wp_scripts;
    $script = $wp_scripts->query($handle);
    if (!$script) {
        return false;
    }
    if (!in_array($dep, $script->deps)) {
        $script->deps[] = $dep;
    }
    return true;
}
add_action('wp_body_open', 'jvbAccessibility');
@@ -573,19 +515,15 @@
    echo '<div class="screen-reader-text live-region" aria-live="polite" role="status"></div>';
}
add_action('wp_head', 'jvbFrontendBase',1);
function jvbFrontendBase():void
{
    ?>
    <script type="text/javascript">window.jvbBase = '<?= BASE ?>';</script>
    <?php
}
//add_action('wp_head', 'jvbFrontendBase',1);
//function jvbFrontendBase():void
//{
//  ?>
<!--    <script type="text/javascript">window.jvbBase = '--><?php //= BASE ?>//';</script>
//  <?php
//}
//add_action('wp_head', 'jvbDumpIt');
function jvbDumpIt()
{
//  jvbDump(Registrar::getInstance('foodie')->getFields());
}
add_action('after_setup_theme', 'jvbImageSize');
function jvbImageSize():void
src/drawer-menu/render.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
src/faq/render.php
@@ -0,0 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
src/glossary/render.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
src/gmbreviews/render.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
src/menu/render.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
src/simple-calendar/render.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
src/timeline/render.php
@@ -1,4 +1,8 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
src/video/render.php
@@ -1 +1,5 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
templates/dashboard/sections/notifications.php
@@ -1,4 +1,7 @@
<?php
use JVBase\managers\NotificationManager;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
@@ -11,7 +14,7 @@
    true
);
$handler = JVB()->notification();
$handler = NotificationManager::getInstance();
$user = wp_get_current_user();
$role = array_values($user->roles)[0];