Jake Vanderwerf
2026-03-08 c19264ac916707096fe294d996a1b7fb85206b34
=Furthering along the complete refactor. Added some basic setup for is_content taxonomies, including a page for an archive of the terms.
1 files deleted
45 files modified
15 files added
1 files renamed
2536 ■■■■ changed files
assets/css/dash.min.css 2 ●●● patch | view | raw | blame | history
assets/js/concise/AuthManager.js 4 ●●●● patch | view | raw | blame | history
assets/js/concise/DataStore.js 2 ●●● patch | view | raw | blame | history
assets/js/concise/SimpleCache.js 2 ●●● patch | view | raw | blame | history
assets/js/min/auth.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/cache.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/dataStore.min.js 2 ●●● patch | view | raw | blame | history
cleanup.php 45 ●●●●● patch | view | raw | blame | history
inc/admin/SEOAdmin.php 1 ●●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 8 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 3 ●●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 2 ●●● patch | view | raw | blame | history
inc/managers/DirectoryManager.php 3 ●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 4 ●●●● patch | view | raw | blame | history
inc/managers/SEO/_edmonotonink.php 980 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/SchemaOutput.php 31 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/VisualArtwork.php 28 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/AdministrativeArea/City.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Place/AdministrativeArea/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/ThingSchema.php 12 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/_setup.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/aboutTrait.php 29 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/artEditionTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/artMediumTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/artformTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/artistTrait.php 23 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/artworkSurfaceTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/coloristTrait.php 23 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/creatorTrait.php 11 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/inkerTrait.php 23 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/lettererTrait.php 23 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/pencilerTrait.php 23 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/sourceOrganizationTrait.php 9 ●●●● patch | view | raw | blame | history
inc/managers/ScriptLoader.php 2 ●●●●● patch | view | raw | blame | history
inc/meta/Form.php 6 ●●●● patch | view | raw | blame | history
inc/meta/Item.php 2 ●●● patch | view | raw | blame | history
inc/meta/MetaFormOld.php 2 ●●● patch | view | raw | blame | history
inc/meta/MetaTypeManager.php 14 ●●●● patch | view | raw | blame | history
inc/meta/Repeater.php 2 ●●● patch | view | raw | blame | history
inc/meta/Sanitizer.php 30 ●●●● patch | view | raw | blame | history
inc/meta/Storage.php 2 ●●● patch | view | raw | blame | history
inc/registrar/Fields.php 198 ●●●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 117 ●●●●● patch | view | raw | blame | history
inc/registrar/Terms.php 141 ●●●● patch | view | raw | blame | history
inc/registrar/_setup.php 6 ●●●●● patch | view | raw | blame | history
inc/registrar/config/SEO.php 19 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Helpers.php 5 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Resolver.php 75 ●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Schema.php 207 ●●●● patch | view | raw | blame | history
inc/registrar/fields/Field.php 17 ●●●● patch | view | raw | blame | history
inc/registrar/fields/GroupedField.php 12 ●●●● patch | view | raw | blame | history
inc/registrar/fields/RepeaterField.php 50 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/SelectorField.php 125 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/TagListField.php 33 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/TaxonomyField.php 19 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/UploadField.php 2 ●●● patch | view | raw | blame | history
inc/rest/routes/ContentRoutes.php 5 ●●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 22 ●●●●● patch | view | raw | blame | history
inc/users/UserSettings.php 10 ●●●●● patch | view | raw | blame | history
jvb.php 6 ●●●● patch | view | raw | blame | history
assets/css/dash.min.css
@@ -1 +1 @@
:target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end;position:fixed}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace.replace{grid-column:full;padding:0 var(--btn_);max-width:none!important;margin:0!important}.replace .dashboard-page{max-width:var(--wide)}.group-display .item-grid{grid-template-columns:repeat(2,1fr)}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}.list-view h3{font-size:var(--txt-medium)}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%;display:flex;justify-content:space-between}.all-filters summary [data-action=clear-filters]{--w:1em!important;width:max-content;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]{margin-left:auto;--w:1em!important;flex-wrap:nowrap;justify-content:flex-start;transition:var(--trans-size);display:flex;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]:focus,.all-filters [data-action=refresh]:hover{width:max-content}.all-filters [data-action=refresh] span{display:none;white-space:nowrap}.all-filters [data-action=refresh]:focus span,.all-filters [data-action=refresh]:hover span{display:block}.all-filters .btn+label{box-shadow:var(--shdw-none);color:var(--base-200)}.all-filters .radio-options input:not(.ch):checked+label{box-shadow:rgba(var(--base-rgb),var(--op-6)) var(--shdw-inset);color:var(--contrast-200);border-color:var(--contrast-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chip_);padding:.125rem!important;min-width:0;min-height:var(--chip_);width:var(--chip_)}.all-filters>.row{padding:.25rem 0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.crud form.table td .label,.crud form.table td label:not(.select-item-label):not(.radio-option){display:none}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}.dashboard #queue{bottom:0}
:target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end;position:fixed}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace.replace{grid-column:full;padding:0 var(--btn_);max-width:none!important;margin:0!important}.replace .dashboard-page{max-width:var(--wide)}.group-display .item-grid{grid-template-columns:repeat(2,1fr)}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}.list-view h3{font-size:var(--txt-medium)}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%;display:flex;justify-content:space-between}.all-filters summary [data-action=clear-filters]{--w:1em!important;width:max-content;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]{margin-left:auto;--w:1em!important;flex-wrap:nowrap;justify-content:flex-start;transition:var(--trans-size);display:flex;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]:focus,.all-filters [data-action=refresh]:hover{width:max-content}.all-filters [data-action=refresh] span{display:none;white-space:nowrap}.all-filters [data-action=refresh]:focus span,.all-filters [data-action=refresh]:hover span{display:block}.all-filters .btn+label{box-shadow:var(--shdw-none);color:var(--base-200)}.all-filters .radio-options input:not(.ch):checked+label{box-shadow:rgba(var(--base-rgb),var(--op-6)) var(--shdw-inset);color:var(--contrast-200);border-color:var(--contrast-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chip_);padding:.125rem!important;min-width:0;min-height:var(--chip_);width:var(--chip_)}.all-filters>.row{padding:.25rem 0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.crud form.table td .label,.crud form.table td label:not(.select-item-label):not(.radio-option){display:none}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:98vh;width:98vw;max-width:none;max-height:none;inset:0;margin:auto}dialog>.wrap{min-height:100%}dialog .item.upload.upload{display:flex;gap:1rem}dialog .item.upload .preview{width:40%}dialog .item.upload .group{width:60%}.upload details{width:100%}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}.dashboard #queue{bottom:0}
assets/js/concise/AuthManager.js
@@ -17,8 +17,8 @@
        this.nonces = {};
        this.subscribers = new Set();
        this.storageKey = 'jvb_auth_state';
        this.cacheMetaKey = 'jvb_auth_meta';
        this.storageKey = `${jvbBase.base}auth_state`;
        this.cacheMetaKey = `${jvbBase.base}auth_meta`;
        this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
        this.init();
assets/js/concise/DataStore.js
@@ -51,7 +51,7 @@
        if (!this.dbConfig.has(name)) {
            this.dbConfig.set(name, {
                dbName: `jvb_${name}`,
                dbName: `${jvbBase.base}${name}`,
                version: version,
                stores: {},
                _initialized: false
assets/js/concise/SimpleCache.js
@@ -18,7 +18,7 @@
    constructor(base, config = {}) {
        this.base = base;
        this.config = {
            namespace: 'jvb_cache',
            namespace: `${jvbBase.base}cache`,
            TTL: 3600000,
            maxSize: 100,
            ...config
assets/js/min/auth.min.js
@@ -1 +1 @@
window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.storageKey="jvb_auth_state",this.cacheMetaKey="jvb_auth_meta",this.cacheExpiry=3e5,this.init()}async init(){if(this.isAuthenticating)return this.ready();this.isAuthenticating=!0;try{const t=this.getCachedAuth();if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!0});await this.fetchAuth()}catch(t){console.error("Failed to initialize auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,e={}){const i=async(s=0)=>{const a={"Content-Type":"application/json",...e.headers,"X-WP-Nonce":this.getNonce()},h=await fetch(t,{...e,credentials:"same-origin",headers:a});if((403===h.status||401===h.status)&&0===s){const t=await h.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),i(1)}return h};return i()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});if(!t.ok)throw new Error("Auth check failed");const e=await t.json(),i=sessionStorage.getItem(this.cacheMetaKey);if(i){const t=JSON.parse(i);t.session_id&&t.session_id!==e.session_id&&(this.clearCachedAuth(),this.notify("session-changed",{}))}this.cacheAuth(e),this.setAuthData(e),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{}}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={},sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}getCachedAuth(){try{const t=sessionStorage.getItem(this.storageKey),e=sessionStorage.getItem(this.cacheMetaKey);if(!t||!e)return null;const i=JSON.parse(e),s=JSON.parse(t);return Date.now()-i.timestamp>this.cacheExpiry?(this.clearCachedAuth(),null):s}catch(t){return console.error("Error reading cached auth:",t),null}}cacheAuth(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(t)),sessionStorage.setItem(this.cacheMetaKey,JSON.stringify({session_id:t.session_id||null,timestamp:Date.now()}))}catch(t){console.error("Error caching auth:",t)}}clearCachedAuth(){sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey),t)return this.cacheAuth(t),this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((i=>{try{i(t,e)}catch(t){console.error("Subscriber error:",t)}}))}ready(){return this.initialized?Promise.resolve():new Promise((t=>{const e=this.subscribe((i=>{"auth-loaded"!==i&&"auth-error"!==i||(e(),t())}))}))}};
window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.storageKey=`${jvbBase.base}auth_state`,this.cacheMetaKey=`${jvbBase.base}auth_meta`,this.cacheExpiry=3e5,this.init()}async init(){if(this.isAuthenticating)return this.ready();this.isAuthenticating=!0;try{const t=this.getCachedAuth();if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!0});await this.fetchAuth()}catch(t){console.error("Failed to initialize auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,e={}){const i=async(s=0)=>{const a={"Content-Type":"application/json",...e.headers,"X-WP-Nonce":this.getNonce()},h=await fetch(t,{...e,credentials:"same-origin",headers:a});if((403===h.status||401===h.status)&&0===s){const t=await h.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),i(1)}return h};return i()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});if(!t.ok)throw new Error("Auth check failed");const e=await t.json(),i=sessionStorage.getItem(this.cacheMetaKey);if(i){const t=JSON.parse(i);t.session_id&&t.session_id!==e.session_id&&(this.clearCachedAuth(),this.notify("session-changed",{}))}this.cacheAuth(e),this.setAuthData(e),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{}}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={},sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}getCachedAuth(){try{const t=sessionStorage.getItem(this.storageKey),e=sessionStorage.getItem(this.cacheMetaKey);if(!t||!e)return null;const i=JSON.parse(e),s=JSON.parse(t);return Date.now()-i.timestamp>this.cacheExpiry?(this.clearCachedAuth(),null):s}catch(t){return console.error("Error reading cached auth:",t),null}}cacheAuth(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(t)),sessionStorage.setItem(this.cacheMetaKey,JSON.stringify({session_id:t.session_id||null,timestamp:Date.now()}))}catch(t){console.error("Error caching auth:",t)}}clearCachedAuth(){sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey),t)return this.cacheAuth(t),this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((i=>{try{i(t,e)}catch(t){console.error("Subscriber error:",t)}}))}ready(){return this.initialized?Promise.resolve():new Promise((t=>{const e=this.subscribe((i=>{"auth-loaded"!==i&&"auth-error"!==i||(e(),t())}))}))}};
assets/js/min/cache.min.js
@@ -1 +1 @@
window.jvbCache=class{constructor(e,t={}){this.base=e,this.config={namespace:"jvb_cache",TTL:36e5,maxSize:100,...t},this._cache=new Map,this.subscribers=new Set}clearMemoryCache(){const e=this._cache.size;return this._cache.clear(),console.log(`Cleared ${e} items from memory cache`),e}get(e){if(this._cache.has(e))return this._cache.get(e);let t,a=`${this.base}_${e}`;try{if(t=localStorage.getItem(a),!t)return null;t=JSON.parse(t)}catch(e){return console.warn("Error getting from localStorage:",e),null}return t&&this._cache.set(e,t),t}set(e,t){this._cache.set(e,t);let a=`${this.base}_${e}`;try{localStorage.setItem(a,JSON.stringify(t))}catch(t){if(t instanceof DOMException&&22===t.code){this.clearOldestLocalStorageItems();try{localStorage.setItem(e,JSON.stringify(item))}catch(e){console.warn("Still failed to set localStorage item after cleanup:",e)}}else console.warn("Error setting localStorage item:",t)}this.notify("cache-saved",{key:e,value:t})}remove(e){let t=`${this.base}_${e}`;try{localStorage.removeItem(t)}catch(e){console.warn("Error removing localStorage item:",e)}}clearOldestLocalStorageItems(){try{const e=[];for(let t=0;t<localStorage.length;t++){const a=localStorage.key(t);if(a.startsWith(this.config.namespace))try{const t=JSON.parse(localStorage.getItem(a));e.push({key:a,timestamp:t.timestamp||0})}catch(t){e.push({key:a,timestamp:0})}}e.sort(((e,t)=>e.timestamp-t.timestamp));const t=Math.max(1,Math.ceil(.2*e.length));for(let a=0;a<t;a++)e[a]&&localStorage.removeItem(e[a].key)}catch(e){console.warn("Error cleaning up localStorage:",e)}}async loadFromCache(){for(let e=0;e<localStorage.length;e++){const t=localStorage.key(e);if(t.startsWith(`${this.base}_`)){let e=t.replace(`${this.base}_`,"");try{const a=JSON.parse(localStorage.getItem(t));this._cache.set(e,a)}catch(e){console.warn(`Failed to parse cached value for ${t}:`,e)}}}}async clear(){this._cache.clear();try{for(let e=localStorage.length-1;e>=0;e--){const t=localStorage.key(e);t&&t.startsWith(this.config.namespace)&&localStorage.removeItem(t)}}catch(e){console.warn("Error clearing localStorage cache:",e)}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((a=>a(e,t)))}};
window.jvbCache=class{constructor(e,t={}){this.base=e,this.config={namespace:`${jvbBase.base}cache`,TTL:36e5,maxSize:100,...t},this._cache=new Map,this.subscribers=new Set}clearMemoryCache(){const e=this._cache.size;return this._cache.clear(),console.log(`Cleared ${e} items from memory cache`),e}get(e){if(this._cache.has(e))return this._cache.get(e);let t,a=`${this.base}_${e}`;try{if(t=localStorage.getItem(a),!t)return null;t=JSON.parse(t)}catch(e){return console.warn("Error getting from localStorage:",e),null}return t&&this._cache.set(e,t),t}set(e,t){this._cache.set(e,t);let a=`${this.base}_${e}`;try{localStorage.setItem(a,JSON.stringify(t))}catch(t){if(t instanceof DOMException&&22===t.code){this.clearOldestLocalStorageItems();try{localStorage.setItem(e,JSON.stringify(item))}catch(e){console.warn("Still failed to set localStorage item after cleanup:",e)}}else console.warn("Error setting localStorage item:",t)}this.notify("cache-saved",{key:e,value:t})}remove(e){let t=`${this.base}_${e}`;try{localStorage.removeItem(t)}catch(e){console.warn("Error removing localStorage item:",e)}}clearOldestLocalStorageItems(){try{const e=[];for(let t=0;t<localStorage.length;t++){const a=localStorage.key(t);if(a.startsWith(this.config.namespace))try{const t=JSON.parse(localStorage.getItem(a));e.push({key:a,timestamp:t.timestamp||0})}catch(t){e.push({key:a,timestamp:0})}}e.sort(((e,t)=>e.timestamp-t.timestamp));const t=Math.max(1,Math.ceil(.2*e.length));for(let a=0;a<t;a++)e[a]&&localStorage.removeItem(e[a].key)}catch(e){console.warn("Error cleaning up localStorage:",e)}}async loadFromCache(){for(let e=0;e<localStorage.length;e++){const t=localStorage.key(e);if(t.startsWith(`${this.base}_`)){let e=t.replace(`${this.base}_`,"");try{const a=JSON.parse(localStorage.getItem(t));this._cache.set(e,a)}catch(e){console.warn(`Failed to parse cached value for ${t}:`,e)}}}}async clear(){this._cache.clear();try{for(let e=localStorage.length-1;e>=0;e--){const t=localStorage.key(e);t&&t.startsWith(this.config.namespace)&&localStorage.removeItem(t)}}catch(e){console.warn("Error clearing localStorage cache:",e)}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((a=>a(e,t)))}};
assets/js/min/dataStore.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.25){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order","context","source",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL)){let t=r.items.map((t=>this.get(e,t)));return this.notify(e,"data-loaded",{cached:!0,items:t??[]}),r}t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items){const e=r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]);return this.applyOrdering(e,t)}const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];t.filters.taxonomy&&"object"==typeof t.filters.taxonomy&&Object.entries(t.filters.taxonomy).forEach((([e,t])=>{const s=Array.isArray(t)?t:[t];o.push((t=>{if(!t.taxonomies||!t.taxonomies[e])return!1;const r=Object.keys(t.taxonomies[e]).map((e=>parseInt(e)));return s.some((e=>r.includes(parseInt(e))))}))}));for(const[e,s]of Object.entries(t.filters))if("taxonomy"!==e&&!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();return["random","rand"].includes(s)||["random","rand"].includes(r)?this.shuffle(e):(e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.title||e.name||"").toLowerCase(),a=(t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||e.date||0),a=new Date(t.modified||t.date||0);break;default:i=new Date(e.date||e.modified||0),a=new Date(t.date||t.modified||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0})),e)}shuffle(e){const t=e.slice();for(let e=t.length-1;e>0;e--){const s=Math.floor(Math.random()*(e+1));[t[e],t[s]]=[t[s],t[e]]}return t}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t});const a=await this.shouldFetchWithFilters(e,t,i);if(r.config.endpoint&&a)await this.fetch(e);else{const t=this.getFiltered(e);this.notify(e,"data-loaded",{cached:!0,items:t})}}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.25){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`${jvbBase.base}${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order","context","source",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL)){let t=r.items.map((t=>this.get(e,t)));return this.notify(e,"data-loaded",{cached:!0,items:t??[]}),r}t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items){const e=r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]);return this.applyOrdering(e,t)}const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];t.filters.taxonomy&&"object"==typeof t.filters.taxonomy&&Object.entries(t.filters.taxonomy).forEach((([e,t])=>{const s=Array.isArray(t)?t:[t];o.push((t=>{if(!t.taxonomies||!t.taxonomies[e])return!1;const r=Object.keys(t.taxonomies[e]).map((e=>parseInt(e)));return s.some((e=>r.includes(parseInt(e))))}))}));for(const[e,s]of Object.entries(t.filters))if("taxonomy"!==e&&!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();return["random","rand"].includes(s)||["random","rand"].includes(r)?this.shuffle(e):(e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.title||e.name||"").toLowerCase(),a=(t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||e.date||0),a=new Date(t.modified||t.date||0);break;default:i=new Date(e.date||e.modified||0),a=new Date(t.date||t.modified||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0})),e)}shuffle(e){const t=e.slice();for(let e=t.length-1;e>0;e--){const s=Math.floor(Math.random()*(e+1));[t[e],t[s]]=[t[s],t[e]]}return t}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t});const a=await this.shouldFetchWithFilters(e,t,i);if(r.config.endpoint&&a)await this.fetch(e);else{const t=this.getFiltered(e);this.notify(e,"data-loaded",{cached:!0,items:t})}}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
cleanup.php
@@ -264,3 +264,48 @@
    return $clean_classes;
}
add_filter('body_class', 'jvbBodyClasses');
add_action('admin_init', function () {
    // Redirect any user trying to access comments page
    global $pagenow;
    if ($pagenow === 'edit-comments.php') {
        wp_redirect(admin_url());
        exit;
    }
    // Remove comments metabox from dashboard
    remove_meta_box('dashboard_recent_comments', 'dashboard', 'normal');
    // Disable support for comments and trackbacks in post types
    foreach (get_post_types() as $post_type) {
        if (post_type_supports($post_type, 'comments')) {
            remove_post_type_support($post_type, 'comments');
            remove_post_type_support($post_type, 'trackbacks');
        }
    }
});
// Close comments on the front-end
add_filter('comments_open', '__return_false', 20, 2);
add_filter('pings_open', '__return_false', 20, 2);
// Hide existing comments
add_filter('comments_array', '__return_empty_array', 10, 2);
// Remove comments page in menu
add_action('admin_menu', function () {
    remove_menu_page('edit-comments.php');
});
// Remove comments links from admin bar
add_action('init', function () {
    if (is_admin_bar_showing()) {
        remove_action('admin_bar_menu', 'wp_admin_bar_comments_menu', 60);
    }
});
// Remove comments links from admin bar
add_action('add_admin_bar_menus', function () {
    remove_action('admin_bar_menu', 'wp_admin_bar_comments_menu', 60);
});
inc/admin/SEOAdmin.php
@@ -25,6 +25,7 @@
        'JVBase\managers\SEO\render\Thing\CreativeWork\MusicRecording'      => ' - - Music Recording',
        'JVBase\managers\SEO\render\Thing\CreativeWork\Photograph'          => ' - - Photograph',
        'JVBase\managers\SEO\render\Thing\CreativeWork\Review'              => ' - - Review',
        'JVBase\managers\SEO\render\Thing\CreativeWork\VisualArtwork\VisualArtwork'     => ' - - Visual Artwork',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\Webpage'     => ' - - WebPage',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\AboutPage'   => ' - - - About Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CheckoutPage'    => ' - - - Checkout Page',
inc/blocks/CustomBlocks.php
@@ -18,7 +18,7 @@
    {
        $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
        $this->cache->connect('post')->connect('taxonomy');
        add_filter('render_block', [$this, 'render'], 999, 3);
        add_filter('render_block', [$this, 'render'], 990, 3);
        add_action('init', [$this, 'registerBlockStyles']);
    }
@@ -616,10 +616,12 @@
            global $post;
            $block['innerBlocks'] = parse_blocks($post->post_content);
            return $this->innerBlocks($block);
            $result = $this->innerBlocks($block);
        } else {
            return $this->inside($block, $tag, $content);
            $result = $this->inside($block, $tag, $content);
        }
        return apply_filters('jvb_post_content_output', $result, $block);
    }
    //core_post_date
    public function render_core_post_date(array $block):string
inc/integrations/Integrations.php
@@ -319,7 +319,8 @@
        if (!$taxonomies) {
            // Combine both content and taxonomy filtering
            $taxonomies = [];
            foreach (Registrar::getFeatured('is_content', 'term') as $registrar) {
            foreach (Registrar::getFeatured('is_content', 'term') as $type) {
                $registrar = Registrar::getInstance($type);
                if ($registrar->hasIntegration($this->service_name)) {
                    $taxonomies[] = $registrar->getSlug();
                }
inc/managers/CRUDManager.php
@@ -87,7 +87,7 @@
        $this->skeleton->addDateFilter();
        $this->skeleton->addCustomDateRange($this->addDateRanges());
        if (!empty($this->taxonomies)) {
            $this->skeleton->addTaxonomyFilter(array_keys($this->taxonomies), 'user');
            $this->skeleton->addTaxonomyFilter($this->taxonomies, 'user');
        }
        // Capabilities
inc/managers/DirectoryManager.php
@@ -37,7 +37,7 @@
        jvb_register_do_once('buildDirectories', [$this, 'activate']);
        add_action('init', [$this, 'registerDirectories']);
        add_action('render_block', [$this, 'renderBlock'], 99999, 3);
        add_action('render_block', [$this, 'renderBlock'], 998, 3);
    }
    public function registerDirectories():void
@@ -684,6 +684,7 @@
            return $content;
        }
        error_log('Still working on directory manager...');
        // For archive page
        if (is_post_type_archive(BASE.'directory') && $block['blockName'] === 'core/group') {
            return ($block['attrs']['tagName']??'' === 'main') ? '<main>'.$this->renderArchive().'</main>' : $content;
inc/managers/ReferralManager.php
@@ -2491,11 +2491,11 @@
            <p><small>(No data is stored. Your friends will get an email from our email.)</small></p>
            <?php
            $invite = [
                'type' => 'tag_list',
                'type' => 'taglist',
                'label' => 'Invite Your Friends',
                'hint' => 'Add friends to send them a referral link',
                'add_label' => 'Add Invite',
                'tag_format' => '{name} ({email})', // or 'first_field', 'all_fields', 'email', etc.
                'tag_format' => '{{name}} ({{email}})', // or 'first_field', 'all_fields', 'email', etc.
                'fields' => [
                    'name' => [
                        'type' => 'text',
inc/managers/SEO/_edmonotonink.php
@@ -42,498 +42,498 @@
// CONTENT TYPES CONFIGURATION
// ==================================================
add_filter('the_content', function ($content) {
    // ARTIST
    $content['artist'] = array_merge($content['artist'] ?? [], [
        'seo' => [
            'title_template' => '{{name}} | {{primary_style}} Tattoo Artist in {{city}}',
            'description_template' => '{{name}} is a {{primary_style}} tattoo artist {{location_text}}. {{bio}} Browse portfolio and book appointments.',
            'variables' => [
                'name' => 'post_title',
                'primary_style' => ['taxonomy' => BASE . 'style', 'primary' => true],
                'styles' => ['taxonomy' => BASE . 'style'],
                'city' => ['taxonomy' => BASE . 'city', 'primary' => true],
                'primary_shop' => ['taxonomy' => BASE . 'shop', 'primary' => true],
                'bio' => ['meta' => 'bio', 'truncate' => 100],
                'location_text' => ['callback' => function ($post_id, $context) {
                    $city_terms = get_the_terms($post_id, BASE . 'city');
                    $shop_terms = get_the_terms($post_id, BASE . 'shop');
                    if ($shop_terms && !is_wp_error($shop_terms)) {
                        $shop = $shop_terms[0];
                        return "working at {$shop->name}";
                    } elseif ($city_terms && !is_wp_error($city_terms)) {
                        $city = $city_terms[0];
                        return "in {$city->name}";
                    }
                    return "in Edmonton";
                }],
            ],
            'archive_title' => 'Edmonton\'s Best Tattoo Artists | Browse by Style & Shop',
            'archive_description' => 'Explore Edmonton\'s top tattoo artists. Filter by style, shop, or location. View portfolios and book your next tattoo today.'
        ],
        'schema' => [
            'type' => 'Person',
            'additional_types' => ['Artist'],
            'properties' => [
                'jobTitle' => ['callback' => function ($id, $context, $meta) {
                    $job = $meta['job_title'] ?? null;
                    if ($job) return $job;
                    // Try to build from specialties
                    $styles = get_the_terms($id, BASE . 'style');
                    if ($styles && !is_wp_error($styles) && count($styles) > 0) {
                        $primary = $styles[0]->name;
                        return "{$primary} Tattoo Artist";
                    }
                    return "Tattoo Artist";
                }],
                'telephone' => 'phone',
                'email' => 'email',
                'url' => ['callback' => function ($id) {
                    return get_permalink($id);
                }],
                'image' => ['callback' => function ($id) {
                    return get_the_post_thumbnail_url($id, 'full');
                }],
                'knowsAbout' => ['taxonomy' => BASE . 'style'],
                'worksFor' => ['callback' => function ($id, $context, $meta) {
                    $shops = get_the_terms($id, BASE . 'shop');
                    if (!$shops || is_wp_error($shops)) {
                        return null;
                    }
                    return array_map(function ($shop) {
                        return [
                            '@type' => 'LocalBusiness',
                            '@id' => get_term_link($shop) . '#business',
                            'name' => $shop->name,
                            'url' => get_term_link($shop)
                        ];
                    }, $shops);
                }],
                'memberOf' => [
                    '@id' => 'https://edmonton.ink/#organization'
                ],
            ]
        ]
    ]);
    // PARTNER (Shops/Organizations)
    $content['partner'] = array_merge($content['partner'] ?? [], [
        'seo' => [
            'title_template' => '{{name}} | Community Partner',
            'description_template' => '{{name}} - {{excerpt}}',
            'variables' => [
                'name' => 'post_title',
                'excerpt' => ['callback' => function ($id) {
                    return get_the_excerpt($id);
                }],
            ],
            'archive_title' => 'Our Community Partners | Supporting Edmonton\'s Tattoo Scene',
            'archive_description' => 'Meet the businesses and organizations supporting Edmonton\'s tattoo community. Our partners help make edmonton.ink possible.'
        ],
        'schema' => [
            'type' => 'Organization',
            'properties' => [
                'name' => ['callback' => function ($id) {
                    return get_the_title($id);
                }],
                'description' => ['callback' => function ($id) {
                    return get_the_excerpt($id);
                }],
                'url' => ['callback' => function ($id, $context, $meta) {
                    $website = $meta['website'] ?? null;
                    return $website ?: get_permalink($id);
                }],
                'logo' => ['callback' => function ($id, $context, $meta) {
                    $image_id = $meta['image'] ?? null;
                    if ($image_id) {
                        return wp_get_attachment_image_url($image_id, 'full');
                    }
                    return get_the_post_thumbnail_url($id, 'full');
                }],
                'telephone' => 'phone',
                'email' => 'email',
                'address' => 'address',
                'sameAs' => ['callback' => function ($id, $context, $meta) {
                    $links = [];
                    if (!empty($meta['instagram'])) $links[] = $meta['instagram'];
                    if (!empty($meta['facebook'])) $links[] = $meta['facebook'];
                    if (!empty($meta['twitter'])) $links[] = $meta['twitter'];
                    return !empty($links) ? $links : null;
                }],
                'memberOf' => [
                    '@id' => 'https://edmonton.ink/#organization'
                ],
            ]
        ]
    ]);
    // TATTOO
    $content['tattoo'] = array_merge($content['tattoo'] ?? [], [
        'seo' => [
            'title_template' => '{{style}} Tattoo by {{artist}} | Edmonton',
            'description_template' => 'Beautiful {{style}} tattoo by {{artist}}{{location}}. View more work from Edmonton\'s talented artists.',
            'variables' => [
                'style' => ['taxonomy' => BASE . 'style', 'primary' => true],
                'artist' => ['callback' => function ($id) {
                    $artist_id = get_post_meta($id, BASE . 'link', true);
                    if ($artist_id) {
                        return get_the_title($artist_id);
                    }
                    return 'Edmonton Artist';
                }],
                'location' => ['callback' => function ($id) {
                    $artist_id = get_post_meta($id, BASE . 'link', true);
                    if (!$artist_id) return '';
                    $shops = get_the_terms($artist_id, BASE . 'shop');
                    if ($shops && !is_wp_error($shops)) {
                        return ' at ' . $shops[0]->name;
                    }
                    return '';
                }],
            ],
            'archive_title' => 'Edmonton Tattoos | Browse by Style, Artist & Shop',
            'archive_description' => 'Browse tattoos from Edmonton\'s best artists. Filter by style, theme, or artist to find inspiration for your next piece.'
        ],
        'schema' => [
            'type' => 'CreativeWork',
            'additional_types' => ['VisualArtwork'],
            'properties' => [
                'creator' => ['callback' => function ($id) {
                    $artist_id = get_post_meta($id, BASE . 'link', true);
                    if ($artist_id) {
                        return [
                            '@type' => 'Person',
                            '@id' => get_permalink($artist_id) . '#person',
                            'name' => get_the_title($artist_id),
                            'url' => get_permalink($artist_id)
                        ];
                    }
                    return null;
                }],
                'image' => ['callback' => function ($id) {
                    return get_the_post_thumbnail_url($id, 'full');
                }],
                'about' => ['taxonomy' => BASE . 'theme'],
                'artform' => 'Tattoo',
            ]
        ]
    ]);
    // PIERCING
    $content['piercing'] = array_merge($content['piercing'] ?? [], [
        'seo' => [
            'title_template' => '{{type}} Piercing by {{artist}} | Edmonton',
            'description_template' => 'Professional {{type}} piercing by {{artist}}. View more piercing work from Edmonton\'s skilled professionals.',
            'variables' => [
                'type' => ['taxonomy' => BASE . 'pstyle', 'primary' => true],
                'artist' => ['callback' => function ($id) {
                    $artist_id = get_post_meta($id, BASE . 'link', true);
                    return $artist_id ? get_the_title($artist_id) : 'Edmonton Professional';
                }],
            ]
        ],
        'schema' => [
            'type' => 'CreativeWork',
            'properties' => [
                'creator' => ['callback' => function ($id) {
                    $artist_id = get_post_meta($id, BASE . 'link', true);
                    if ($artist_id) {
                        return [
                            '@type' => 'Person',
                            '@id' => get_permalink($artist_id) . '#person',
                            'name' => get_the_title($artist_id),
                            'url' => get_permalink($artist_id)
                        ];
                    }
                    return null;
                }],
                'image' => ['callback' => function ($id) {
                    return get_the_post_thumbnail_url($id, 'full');
                }],
            ]
        ]
    ]);
    // EVENT
    $content['event'] = array_merge($content['event'] ?? [], [
        'seo' => [
            'title_builder' => function ($post_id, $meta) {
                $title = get_the_title($post_id);
                $date = $meta->getValue('event_date');
                if ($date) {
                    $formatted_date = date('F j, Y', strtotime($date));
                    return "{$title} | {$formatted_date}";
                }
                return "{$title} | Edmonton Tattoo Event";
            },
            'description_builder' => function ($post_id, $meta) {
                $excerpt = get_the_excerpt($post_id);
                $venue = $meta->getValue('venue');
                $date = $meta->getValue('event_date');
                $desc = $excerpt;
                if ($venue) $desc .= " Location: {$venue}.";
                if ($date) {
                    $formatted = date('F j, Y', strtotime($date));
                    $desc .= " Date: {$formatted}.";
                }
                return $desc;
            }
        ],
        'schema' => [
            'custom_builder' => function ($post_id) {
                $meta = Meta::forPost($post_id);
                $schema = [
                    '@type' => 'Event',
                    'name' => get_the_title($post_id),
                    'url' => get_permalink($post_id),
                ];
                $date = $meta->get('event_date');
                if ($date) {
                    $schema['startDate'] = date('c', strtotime($date));
                }
                $venue = $meta->get('venue');
                $venue_address = $meta->get('venue_address');
                if ($venue) {
                    $schema['location'] = [
                        '@type' => 'Place',
                        'name' => $venue,
                    ];
                    if ($venue_address) {
                        $schema['location']['address'] = $venue_address;
                    }
                }
                $image_id = get_post_thumbnail_id($post_id);
                if ($image_id) {
                    $schema['image'] = wp_get_attachment_image_url($image_id, 'full');
                }
                return $schema;
            }
        ]
    ]);
    return $content;
});
//add_filter('the_content', function ($content) {
//
//  // ARTIST
//  $content['artist'] = array_merge($content['artist'] ?? [], [
//      'seo' => [
//          'title_template' => '{{name}} | {{primary_style}} Tattoo Artist in {{city}}',
//          'description_template' => '{{name}} is a {{primary_style}} tattoo artist {{location_text}}. {{bio}} Browse portfolio and book appointments.',
//          'variables' => [
//              'name' => 'post_title',
//              'primary_style' => ['taxonomy' => BASE . 'style', 'primary' => true],
//              'styles' => ['taxonomy' => BASE . 'style'],
//              'city' => ['taxonomy' => BASE . 'city', 'primary' => true],
//              'primary_shop' => ['taxonomy' => BASE . 'shop', 'primary' => true],
//              'bio' => ['meta' => 'bio', 'truncate' => 100],
//              'location_text' => ['callback' => function ($post_id, $context) {
//                  $city_terms = get_the_terms($post_id, BASE . 'city');
//                  $shop_terms = get_the_terms($post_id, BASE . 'shop');
//
//                  if ($shop_terms && !is_wp_error($shop_terms)) {
//                      $shop = $shop_terms[0];
//                      return "working at {$shop->name}";
//                  } elseif ($city_terms && !is_wp_error($city_terms)) {
//                      $city = $city_terms[0];
//                      return "in {$city->name}";
//                  }
//                  return "in Edmonton";
//              }],
//          ],
//          'archive_title' => 'Edmonton\'s Best Tattoo Artists | Browse by Style & Shop',
//          'archive_description' => 'Explore Edmonton\'s top tattoo artists. Filter by style, shop, or location. View portfolios and book your next tattoo today.'
//      ],
//
//      'schema' => [
//          'type' => 'Person',
//          'additional_types' => ['Artist'],
//          'properties' => [
//              'jobTitle' => ['callback' => function ($id, $context, $meta) {
//                  $job = $meta['job_title'] ?? null;
//                  if ($job) return $job;
//
//                  // Try to build from specialties
//                  $styles = get_the_terms($id, BASE . 'style');
//                  if ($styles && !is_wp_error($styles) && count($styles) > 0) {
//                      $primary = $styles[0]->name;
//                      return "{$primary} Tattoo Artist";
//                  }
//                  return "Tattoo Artist";
//              }],
//              'telephone' => 'phone',
//              'email' => 'email',
//              'url' => ['callback' => function ($id) {
//                  return get_permalink($id);
//              }],
//              'image' => ['callback' => function ($id) {
//                  return get_the_post_thumbnail_url($id, 'full');
//              }],
//              'knowsAbout' => ['taxonomy' => BASE . 'style'],
//              'worksFor' => ['callback' => function ($id, $context, $meta) {
//                  $shops = get_the_terms($id, BASE . 'shop');
//                  if (!$shops || is_wp_error($shops)) {
//                      return null;
//                  }
//
//                  return array_map(function ($shop) {
//                      return [
//                          '@type' => 'LocalBusiness',
//                          '@id' => get_term_link($shop) . '#business',
//                          'name' => $shop->name,
//                          'url' => get_term_link($shop)
//                      ];
//                  }, $shops);
//              }],
//              'memberOf' => [
//                  '@id' => 'https://edmonton.ink/#organization'
//              ],
//          ]
//      ]
//  ]);
//
//  // PARTNER (Shops/Organizations)
//  $content['partner'] = array_merge($content['partner'] ?? [], [
//      'seo' => [
//          'title_template' => '{{name}} | Community Partner',
//          'description_template' => '{{name}} - {{excerpt}}',
//          'variables' => [
//              'name' => 'post_title',
//              'excerpt' => ['callback' => function ($id) {
//                  return get_the_excerpt($id);
//              }],
//          ],
//          'archive_title' => 'Our Community Partners | Supporting Edmonton\'s Tattoo Scene',
//          'archive_description' => 'Meet the businesses and organizations supporting Edmonton\'s tattoo community. Our partners help make edmonton.ink possible.'
//      ],
//
//      'schema' => [
//          'type' => 'Organization',
//          'properties' => [
//              'name' => ['callback' => function ($id) {
//                  return get_the_title($id);
//              }],
//              'description' => ['callback' => function ($id) {
//                  return get_the_excerpt($id);
//              }],
//              'url' => ['callback' => function ($id, $context, $meta) {
//                  $website = $meta['website'] ?? null;
//                  return $website ?: get_permalink($id);
//              }],
//              'logo' => ['callback' => function ($id, $context, $meta) {
//                  $image_id = $meta['image'] ?? null;
//                  if ($image_id) {
//                      return wp_get_attachment_image_url($image_id, 'full');
//                  }
//                  return get_the_post_thumbnail_url($id, 'full');
//              }],
//              'telephone' => 'phone',
//              'email' => 'email',
//              'address' => 'address',
//              'sameAs' => ['callback' => function ($id, $context, $meta) {
//                  $links = [];
//                  if (!empty($meta['instagram'])) $links[] = $meta['instagram'];
//                  if (!empty($meta['facebook'])) $links[] = $meta['facebook'];
//                  if (!empty($meta['twitter'])) $links[] = $meta['twitter'];
//                  return !empty($links) ? $links : null;
//              }],
//              'memberOf' => [
//                  '@id' => 'https://edmonton.ink/#organization'
//              ],
//          ]
//      ]
//  ]);
//
//  // TATTOO
//  $content['tattoo'] = array_merge($content['tattoo'] ?? [], [
//      'seo' => [
//          'title_template' => '{{style}} Tattoo by {{artist}} | Edmonton',
//          'description_template' => 'Beautiful {{style}} tattoo by {{artist}}{{location}}. View more work from Edmonton\'s talented artists.',
//          'variables' => [
//              'style' => ['taxonomy' => BASE . 'style', 'primary' => true],
//              'artist' => ['callback' => function ($id) {
//                  $artist_id = get_post_meta($id, BASE . 'link', true);
//                  if ($artist_id) {
//                      return get_the_title($artist_id);
//                  }
//                  return 'Edmonton Artist';
//              }],
//              'location' => ['callback' => function ($id) {
//                  $artist_id = get_post_meta($id, BASE . 'link', true);
//                  if (!$artist_id) return '';
//
//                  $shops = get_the_terms($artist_id, BASE . 'shop');
//                  if ($shops && !is_wp_error($shops)) {
//                      return ' at ' . $shops[0]->name;
//                  }
//                  return '';
//              }],
//          ],
//          'archive_title' => 'Edmonton Tattoos | Browse by Style, Artist & Shop',
//          'archive_description' => 'Browse tattoos from Edmonton\'s best artists. Filter by style, theme, or artist to find inspiration for your next piece.'
//      ],
//
//      'schema' => [
//          'type' => 'CreativeWork',
//          'additional_types' => ['VisualArtwork'],
//          'properties' => [
//              'creator' => ['callback' => function ($id) {
//                  $artist_id = get_post_meta($id, BASE . 'link', true);
//                  if ($artist_id) {
//                      return [
//                          '@type' => 'Person',
//                          '@id' => get_permalink($artist_id) . '#person',
//                          'name' => get_the_title($artist_id),
//                          'url' => get_permalink($artist_id)
//                      ];
//                  }
//                  return null;
//              }],
//              'image' => ['callback' => function ($id) {
//                  return get_the_post_thumbnail_url($id, 'full');
//              }],
//              'about' => ['taxonomy' => BASE . 'theme'],
//              'artform' => 'Tattoo',
//          ]
//      ]
//  ]);
//
//  // PIERCING
//  $content['piercing'] = array_merge($content['piercing'] ?? [], [
//      'seo' => [
//          'title_template' => '{{type}} Piercing by {{artist}} | Edmonton',
//          'description_template' => 'Professional {{type}} piercing by {{artist}}. View more piercing work from Edmonton\'s skilled professionals.',
//          'variables' => [
//              'type' => ['taxonomy' => BASE . 'pstyle', 'primary' => true],
//              'artist' => ['callback' => function ($id) {
//                  $artist_id = get_post_meta($id, BASE . 'link', true);
//                  return $artist_id ? get_the_title($artist_id) : 'Edmonton Professional';
//              }],
//          ]
//      ],
//
//      'schema' => [
//          'type' => 'CreativeWork',
//          'properties' => [
//              'creator' => ['callback' => function ($id) {
//                  $artist_id = get_post_meta($id, BASE . 'link', true);
//                  if ($artist_id) {
//                      return [
//                          '@type' => 'Person',
//                          '@id' => get_permalink($artist_id) . '#person',
//                          'name' => get_the_title($artist_id),
//                          'url' => get_permalink($artist_id)
//                      ];
//                  }
//                  return null;
//              }],
//              'image' => ['callback' => function ($id) {
//                  return get_the_post_thumbnail_url($id, 'full');
//              }],
//          ]
//      ]
//  ]);
//
//  // EVENT
//  $content['event'] = array_merge($content['event'] ?? [], [
//      'seo' => [
//          'title_builder' => function ($post_id, $meta) {
//              $title = get_the_title($post_id);
//              $date = $meta->getValue('event_date');
//              if ($date) {
//                  $formatted_date = date('F j, Y', strtotime($date));
//                  return "{$title} | {$formatted_date}";
//              }
//              return "{$title} | Edmonton Tattoo Event";
//          },
//          'description_builder' => function ($post_id, $meta) {
//              $excerpt = get_the_excerpt($post_id);
//              $venue = $meta->getValue('venue');
//              $date = $meta->getValue('event_date');
//
//              $desc = $excerpt;
//              if ($venue) $desc .= " Location: {$venue}.";
//              if ($date) {
//                  $formatted = date('F j, Y', strtotime($date));
//                  $desc .= " Date: {$formatted}.";
//              }
//              return $desc;
//          }
//      ],
//
//      'schema' => [
//          'custom_builder' => function ($post_id) {
//              $meta = Meta::forPost($post_id);
//
//              $schema = [
//                  '@type' => 'Event',
//                  'name' => get_the_title($post_id),
//                  'url' => get_permalink($post_id),
//              ];
//
//              $date = $meta->get('event_date');
//              if ($date) {
//                  $schema['startDate'] = date('c', strtotime($date));
//              }
//
//              $venue = $meta->get('venue');
//              $venue_address = $meta->get('venue_address');
//              if ($venue) {
//                  $schema['location'] = [
//                      '@type' => 'Place',
//                      'name' => $venue,
//                  ];
//                  if ($venue_address) {
//                      $schema['location']['address'] = $venue_address;
//                  }
//              }
//
//              $image_id = get_post_thumbnail_id($post_id);
//              if ($image_id) {
//                  $schema['image'] = wp_get_attachment_image_url($image_id, 'full');
//              }
//
//              return $schema;
//          }
//      ]
//  ]);
//
//  return $content;
//});
// ==================================================
// TAXONOMY CONFIGURATION
// ==================================================
add_filter('jvb_taxonomy', function ($taxonomies) {
    // SHOP
    $taxonomies['shop'] = array_merge($taxonomies['shop'] ?? [], [
        'seo' => [
            'title_template' => '{{name}} | Tattoo Shop in {{city}}',
            'description_template' => '{{name}}{{tagline_text}}{{established_text}} in {{city}}. Featuring {{artist_count}} talented artists. Book your appointment today.',
            'variables' => [
                'name' => 'term_name',
                'city' => ['callback' => function ($term_id, $context) {
                    $meta = Meta::forTerm($term_id);
                    $city_id = $meta->get('city');
                    if ($city_id && term_exists((int)$city_id, BASE . 'city')) {
                        $city_term = get_term($city_id, BASE . 'city');
                        if ($city_term && !is_wp_error($city_term)) {
                            return $city_term->name;
                        }
                    }
                    return 'Edmonton';
                }],
                'tagline_text' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $tagline = $meta->get('tagline');
                    return $tagline ? " - {$tagline}" : '';
                }],
                'established_text' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $established = $meta->get('established');
                    return $established ? " Established in {$established}" : '';
                }],
                'artist_count' => ['callback' => function ($term_id) {
                    $artists = get_posts([
                        'post_type' => BASE . 'artist',
                        'tax_query' => [[
                            'taxonomy' => BASE . 'shop',
                            'terms' => $term_id
                        ]],
                        'posts_per_page' => -1,
                        'fields' => 'ids'
                    ]);
                    return count($artists);
                }],
            ]
        ],
        'schema' => [
            'type' => 'LocalBusiness',
            'additional_types' => ['TattooParlor'],
            'properties' => [
                'address' => 'address',
                'telephone' => 'phone',
                'email' => 'email',
                'openingHours' => 'hours',
                'priceRange' => 'price_range',
                'image' => 'logo',
                'url' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $website = $meta->get('website');
                    return $website ?: get_term_link($term_id);
                }],
                'sameAs' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $links = [];
                    if ($ig = $meta->get('instagram')) $links[] = $ig;
                    if ($fb = $meta->get('facebook')) $links[] = $fb;
                    return !empty($links) ? $links : null;
                }],
                'memberOf' => [
                    '@id' => 'https://edmonton.ink/#organization'
                ],
            ]
        ]
    ]);
    // STYLE
    $taxonomies['style'] = array_merge($taxonomies['style'] ?? [], [
        'seo' => [
            'title_template' => 'Edmonton {{name}} Tattoo Artists | Specialists in {{name}}',
            'description_template' => '{{name}}{{alt_names}} is a distinctive tattoo style. {{characteristics}} Find Edmonton artists specializing in {{name}} tattoos.',
            'variables' => [
                'name' => 'term_name',
                'alt_names' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $alts = $meta->get('alternate_name');
                    if (!empty($alts) && is_array($alts)) {
                        $names = array_filter(array_column($alts, 'name'));
                        if (!empty($names)) {
                            return ' (also known as ' . implode(', ', array_slice($names, 0, 2)) . ')';
                        }
                    }
                    return '';
                }],
                'characteristics' => ['meta' => 'characteristics', 'truncate' => 100],
            ]
        ],
        'schema' => [
            'type' => 'CreativeWork',
            'properties' => [
                'name' => ['callback' => function ($term_id) {
                    return get_term($term_id)->name . ' Tattoo Style';
                }],
                'description' => 'characteristics',
                'about' => ['meta' => 'description'],
                'alternateName' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $alts = $meta->get('alternate_name');
                    if (!empty($alts) && is_array($alts)) {
                        return array_filter(array_column($alts, 'name'));
                    }
                    return null;
                }],
            ]
        ]
    ]);
    // THEME
    $taxonomies['theme'] = array_merge($taxonomies['theme'] ?? [], [
        'seo' => [
            'title_template' => 'Edmonton {{name}} Tattoos | Find {{name}} Tattoo Designs',
            'description_template' => 'Explore {{name}} tattoos, a popular motif in Edmonton\'s tattoo scene. {{similar}}Find artists specializing in {{name}} designs.',
            'variables' => [
                'name' => 'term_name',
                'similar' => ['callback' => function ($term_id) {
                    $meta = Meta::forTerm($term_id);
                    $similar = $meta->get('similar');
                    if (!empty($similar)) {
                        $similar_names = [];
                        foreach ((array)$similar as $similar_id) {
                            $term = get_term($similar_id, BASE . 'theme');
                            if ($term && !is_wp_error($term)) {
                                $similar_names[] = html_entity_decode($term->name);
                            }
                        }
                        if (!empty($similar_names)) {
                            return 'Similar themes include ' . implode(', ', array_slice($similar_names, 0, 2)) . '. ';
                        }
                    }
                    return '';
                }],
            ]
        ],
        'schema' => [
            'type' => 'CreativeWork',
            'properties' => [
                'name' => ['callback' => function ($term_id) {
                    return get_term($term_id)->name . ' Tattoo Theme';
                }],
                'description' => ['meta' => 'description'],
            ]
        ]
    ]);
    // CITY
    $taxonomies['city'] = array_merge($taxonomies['city'] ?? [], [
        'seo' => [
            'title_template' => '{{name}} Tattoo Artists & Shops | edmonton.ink',
            'description_template' => 'Discover {{name}}\'s vibrant tattoo scene featuring {{shop_count}} local shops and {{artist_count}} talented artists. Find top local talent and book your next tattoo today.',
            'variables' => [
                'name' => 'term_name',
                'shop_count' => ['callback' => function ($term_id) {
                    $shops = get_terms([
                        'taxonomy' => BASE . 'shop',
                        'meta_key' => BASE . 'city',
                        'meta_value' => $term_id,
                        'fields' => 'count'
                    ]);
                    return is_wp_error($shops) ? 0 : $shops;
                }],
                'artist_count' => ['callback' => function ($term_id) {
                    $artists = get_posts([
                        'post_type' => BASE . 'artist',
                        'tax_query' => [[
                            'taxonomy' => BASE . 'city',
                            'terms' => $term_id
                        ]],
                        'posts_per_page' => -1,
                        'fields' => 'ids'
                    ]);
                    return count($artists);
                }],
            ]
        ],
        'schema' => [
            'type' => 'Place',
            'properties' => [
                'address' => ['callback' => function ($term_id) {
                    $term = get_term($term_id);
                    return [
                        '@type' => 'PostalAddress',
                        'addressLocality' => html_entity_decode($term->name),
                        'addressRegion' => 'Alberta',
                        'addressCountry' => 'CA'
                    ];
                }],
            ]
        ]
    ]);
    return $taxonomies;
});
//add_filter('jvb_taxonomy', function ($taxonomies) {
//
//  // SHOP
//  $taxonomies['shop'] = array_merge($taxonomies['shop'] ?? [], [
//      'seo' => [
//          'title_template' => '{{name}} | Tattoo Shop in {{city}}',
//          'description_template' => '{{name}}{{tagline_text}}{{established_text}} in {{city}}. Featuring {{artist_count}} talented artists. Book your appointment today.',
//          'variables' => [
//              'name' => 'term_name',
//              'city' => ['callback' => function ($term_id, $context) {
//                  $meta = Meta::forTerm($term_id);
//                  $city_id = $meta->get('city');
//                  if ($city_id && term_exists((int)$city_id, BASE . 'city')) {
//                      $city_term = get_term($city_id, BASE . 'city');
//                      if ($city_term && !is_wp_error($city_term)) {
//                          return $city_term->name;
//                      }
//                  }
//                  return 'Edmonton';
//              }],
//              'tagline_text' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $tagline = $meta->get('tagline');
//                  return $tagline ? " - {$tagline}" : '';
//              }],
//              'established_text' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $established = $meta->get('established');
//                  return $established ? " Established in {$established}" : '';
//              }],
//              'artist_count' => ['callback' => function ($term_id) {
//                  $artists = get_posts([
//                      'post_type' => BASE . 'artist',
//                      'tax_query' => [[
//                          'taxonomy' => BASE . 'shop',
//                          'terms' => $term_id
//                      ]],
//                      'posts_per_page' => -1,
//                      'fields' => 'ids'
//                  ]);
//                  return count($artists);
//              }],
//          ]
//      ],
//
//      'schema' => [
//          'type' => 'LocalBusiness',
//          'additional_types' => ['TattooParlor'],
//          'properties' => [
//              'address' => 'address',
//              'telephone' => 'phone',
//              'email' => 'email',
//              'openingHours' => 'hours',
//              'priceRange' => 'price_range',
//              'image' => 'logo',
//              'url' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $website = $meta->get('website');
//                  return $website ?: get_term_link($term_id);
//              }],
//              'sameAs' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $links = [];
//                  if ($ig = $meta->get('instagram')) $links[] = $ig;
//                  if ($fb = $meta->get('facebook')) $links[] = $fb;
//                  return !empty($links) ? $links : null;
//              }],
//              'memberOf' => [
//                  '@id' => 'https://edmonton.ink/#organization'
//              ],
//          ]
//      ]
//  ]);
//
//  // STYLE
//  $taxonomies['style'] = array_merge($taxonomies['style'] ?? [], [
//      'seo' => [
//          'title_template' => 'Edmonton {{name}} Tattoo Artists | Specialists in {{name}}',
//          'description_template' => '{{name}}{{alt_names}} is a distinctive tattoo style. {{characteristics}} Find Edmonton artists specializing in {{name}} tattoos.',
//          'variables' => [
//              'name' => 'term_name',
//              'alt_names' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $alts = $meta->get('alternate_name');
//                  if (!empty($alts) && is_array($alts)) {
//                      $names = array_filter(array_column($alts, 'name'));
//                      if (!empty($names)) {
//                          return ' (also known as ' . implode(', ', array_slice($names, 0, 2)) . ')';
//                      }
//                  }
//                  return '';
//              }],
//              'characteristics' => ['meta' => 'characteristics', 'truncate' => 100],
//          ]
//      ],
//
//      'schema' => [
//          'type' => 'CreativeWork',
//          'properties' => [
//              'name' => ['callback' => function ($term_id) {
//                  return get_term($term_id)->name . ' Tattoo Style';
//              }],
//              'description' => 'characteristics',
//              'about' => ['meta' => 'description'],
//              'alternateName' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $alts = $meta->get('alternate_name');
//                  if (!empty($alts) && is_array($alts)) {
//                      return array_filter(array_column($alts, 'name'));
//                  }
//                  return null;
//              }],
//          ]
//      ]
//  ]);
//
//  // THEME
//  $taxonomies['theme'] = array_merge($taxonomies['theme'] ?? [], [
//      'seo' => [
//          'title_template' => 'Edmonton {{name}} Tattoos | Find {{name}} Tattoo Designs',
//          'description_template' => 'Explore {{name}} tattoos, a popular motif in Edmonton\'s tattoo scene. {{similar}}Find artists specializing in {{name}} designs.',
//          'variables' => [
//              'name' => 'term_name',
//              'similar' => ['callback' => function ($term_id) {
//                  $meta = Meta::forTerm($term_id);
//                  $similar = $meta->get('similar');
//                  if (!empty($similar)) {
//                      $similar_names = [];
//                      foreach ((array)$similar as $similar_id) {
//                          $term = get_term($similar_id, BASE . 'theme');
//                          if ($term && !is_wp_error($term)) {
//                              $similar_names[] = html_entity_decode($term->name);
//                          }
//                      }
//                      if (!empty($similar_names)) {
//                          return 'Similar themes include ' . implode(', ', array_slice($similar_names, 0, 2)) . '. ';
//                      }
//                  }
//                  return '';
//              }],
//          ]
//      ],
//
//      'schema' => [
//          'type' => 'CreativeWork',
//          'properties' => [
//              'name' => ['callback' => function ($term_id) {
//                  return get_term($term_id)->name . ' Tattoo Theme';
//              }],
//              'description' => ['meta' => 'description'],
//          ]
//      ]
//  ]);
//
//  // CITY
//  $taxonomies['city'] = array_merge($taxonomies['city'] ?? [], [
//      'seo' => [
//          'title_template' => '{{name}} Tattoo Artists & Shops | edmonton.ink',
//          'description_template' => 'Discover {{name}}\'s vibrant tattoo scene featuring {{shop_count}} local shops and {{artist_count}} talented artists. Find top local talent and book your next tattoo today.',
//          'variables' => [
//              'name' => 'term_name',
//              'shop_count' => ['callback' => function ($term_id) {
//                  $shops = get_terms([
//                      'taxonomy' => BASE . 'shop',
//                      'meta_key' => BASE . 'city',
//                      'meta_value' => $term_id,
//                      'fields' => 'count'
//                  ]);
//                  return is_wp_error($shops) ? 0 : $shops;
//              }],
//              'artist_count' => ['callback' => function ($term_id) {
//                  $artists = get_posts([
//                      'post_type' => BASE . 'artist',
//                      'tax_query' => [[
//                          'taxonomy' => BASE . 'city',
//                          'terms' => $term_id
//                      ]],
//                      'posts_per_page' => -1,
//                      'fields' => 'ids'
//                  ]);
//                  return count($artists);
//              }],
//          ]
//      ],
//
//      'schema' => [
//          'type' => 'Place',
//          'properties' => [
//              'address' => ['callback' => function ($term_id) {
//                  $term = get_term($term_id);
//                  return [
//                      '@type' => 'PostalAddress',
//                      'addressLocality' => html_entity_decode($term->name),
//                      'addressRegion' => 'Alberta',
//                      'addressCountry' => 'CA'
//                  ];
//              }],
//          ]
//      ]
//  ]);
//
//  return $taxonomies;
//});
inc/managers/SEO/render/SchemaOutput.php
@@ -49,9 +49,24 @@
            $registrar = Registrar::getInstance($type);
            if ($registrar && !empty($registrar->getConfig('seo')['schema']??[])) {
                $seo = $registrar->getSEO();
                error_log('SEO: '.print_r($seo->schema(), true));
                $schema[] = $seo->schema()->outputArchiveSchema();
            }
        }
        $isContent = array_values(array_filter(array_map(function($item) {
            return intval(get_option(BASE.$item.'_archive', false));
        },Registrar::getFeatured('is_content', 'term'))));
        if (is_page($isContent)){
            $type = get_post_meta(get_the_id(), BASE.'for_type', true);
            error_log('Type: '.print_r($type, true));
            $registrar = Registrar::getInstance($type);
            if ($registrar) {
                $schema[] = $registrar->getSEO()->schema()->outputContentTaxArchiveSchema();
            }
        }
        $breadcrumbs = $this->buildBreadcrumbs();
        if ($breadcrumbs) {
@@ -61,7 +76,9 @@
        if (!empty($schema)) {
            $website = get_option(BASE.'WebsiteSchema');
            if (!empty($website)) {
                Cache::for('websiteSchema')->flush();
                if (JVB_TESTING) {
                    Cache::for('websiteSchema')->flush();
                }
                $website = Cache::for('websiteSchema')->remember(
                    'schema',
                    function () {
@@ -104,6 +121,7 @@
        echo '<script type="application/ld+json">' . "\n";
        echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
        echo "\n" . '</script>' . "\n";
        echo "\n" . '<!-- / SEO Schema by JakeVan -->'."\n";
    }
    public function buildBreadcrumbs():array
@@ -112,7 +130,11 @@
    }
    public function buildBasicWebsiteSchema():array
    {   Cache::for('websiteSchema')->flush();
    {
        if (JVB_TESTING){
            Cache::for('websiteSchema')->flush();
        }
        return Cache::for('websiteSchema')->remember(
            'reference',
            function () {
@@ -137,7 +159,10 @@
    public function getCreator(bool $reference = false):LocalBusiness
    {
        Cache::for('JakeVanCreator')->flush();
        if (JVB_TESTING){
            Cache::for('JakeVanCreator')->flush();
        }
        if ($reference) {
            return Cache::for('JakeVanCreator')->remember(
                'reference',
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/VisualArtwork.php
New file
@@ -0,0 +1,28 @@
<?php
namespace JVBase\managers\SEO\render\Thing\CreativeWork\VisualArtwork;
use JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork;
use JVBase\managers\SEO\render\Traits\_Properties\artEditionTrait;
use JVBase\managers\SEO\render\Traits\_Properties\artformTrait;
use JVBase\managers\SEO\render\Traits\_Properties\artistTrait;
use JVBase\managers\SEO\render\Traits\_Properties\artMediumTrait;
use JVBase\managers\SEO\render\Traits\_Properties\artworkSurfaceTrait;
use JVBase\managers\SEO\render\Traits\_Properties\coloristTrait;
use JVBase\managers\SEO\render\Traits\_Properties\depthTrait;
use JVBase\managers\SEO\render\Traits\_Properties\heightTrait;
use JVBase\managers\SEO\render\Traits\_Properties\inkerTrait;
use JVBase\managers\SEO\render\Traits\_Properties\lettererTrait;
use JVBase\managers\SEO\render\Traits\_Properties\pencilerTrait;
use JVBase\managers\SEO\render\Traits\_Properties\weightTrait;
use JVBase\managers\SEO\render\Traits\_Properties\widthTrait;
if (!defined('ABSPATH')) {
    exit;
}
class VisualArtwork extends CreativeWork
{
    use artEditionTrait, artMediumTrait, artFormTrait, artMediumTrait, artistTrait,
        artworkSurfaceTrait, coloristTrait, depthTrait, heightTrait, inkerTrait, lettererTrait,
        pencilerTrait, weightTrait, widthTrait;
}
inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php
New file
@@ -0,0 +1,2 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/VisualArtwork.php');
inc/managers/SEO/render/Thing/CreativeWork/_setup.php
@@ -1,5 +1,6 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/CreativeWork.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/VisualArtwork/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/DefinedTermSet.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/CategoryCodeSet.php');
inc/managers/SEO/render/Thing/Place/AdministrativeArea/City.php
New file
@@ -0,0 +1,13 @@
<?php
namespace JVBase\managers\SEO\render\Thing\Place\AdministrativeArea;
use JVBase\managers\SEO\render\Thing\Intangible\ContactPoint\PostalAddress;
use JVBase\managers\SEO\render\Thing\Thing;
if (!defined('ABSPATH')) {
    exit;
}
class City extends AdministrativeArea {
}
inc/managers/SEO/render/Thing/Place/AdministrativeArea/_setup.php
@@ -1,3 +1,4 @@
<?php
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/Traits/ThingSchema.php
@@ -31,13 +31,14 @@
    protected array $ignore = [
        'mappedMethods',
        'ignore'
        'ignore',
        'id'
    ];
    public function outputSchema():array
    {
        global $wp;
        $current = home_url( add_query_arg( $_GET, $wp->request ) );
        $id = (isset($this->id)) ? $this->id : $current.'/#'.strtolower($this->getTypeName());
        $id = (isset($this->id)) ? $this->id : $current.'#'.strtolower($this->getTypeName());
        $elements = array_map(
            function ($value) {
@@ -118,4 +119,11 @@
        }
        $this->id = $id;
    }
    public function delete(string $property):void
    {
        if (property_exists($this, $property)) {
            unset($this->$property);
        }
    }
}
inc/managers/SEO/render/Traits/_Properties/_setup.php
@@ -28,6 +28,11 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/amountOfThisGoodTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/applicableCountryTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/areaServedTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artEditionTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artistTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artFormTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artMediumTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artworkSurfaceTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/associatedMediaTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/attendeeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/audienceTrait.php');
@@ -78,6 +83,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/codeValueTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/colleagueTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/colorTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/coloristTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/companyRegistrationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/composerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/contactOptionTrait.php');
@@ -195,6 +201,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/imageTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/includesObjectTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/inCodeSetTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/inkerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/inDefinedTermSetTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/ineligibleLocationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/ineligibleRegionTrait.php');
@@ -234,6 +241,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/legalAddressTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/legalNameTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/legalRepresentativeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/lettererTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/licenseTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/lifeEventTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/lineTrait.php');
@@ -294,6 +302,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/patternTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/paymentMethodTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/pencilerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/performerInTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/performerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/performTimeTrait.php');
inc/managers/SEO/render/Traits/_Properties/aboutTrait.php
@@ -2,6 +2,7 @@
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Thing;
use JVBase\registrar\config\seo\Resolver;
if (!defined('ABSPATH')) {
    exit;
@@ -17,8 +18,34 @@
    {
        return $this->about??null;
    }
    public function setAbout(Thing $about):void
    public function setAbout(Thing|array $about):void
    {
        if (is_array($about)) {
            if (!array_key_exists('type', $about)) {
                error_log('[aboutTrait] Needs to have a type key'.print_r($about, true));
                return;
            }
            if (!class_exists($about['type'])) {
                error_log('[aboutTrait] Class not found for: '.print_r($about['type'], true));
                return;
            }
            $class = new $about['type']();
            $className = $about['type'];
            unset($about['type']);
            foreach ($about as $key => $value) {
                $method = 'set'.ucfirst($key);
                if (method_exists($class, $method)) {
                    if (is_string($value) && str_contains($value, '{{')) {
                        $value = Resolver::resolveForSchema($key, $value, $class);
                    }
                    error_log('Setting '.$key.' to '.print_r($value, true));
                    $class->$method($value);
                } else {
                    error_log('[aboutTrait] Invalid method: '.$method.', for class: '.$className);
                }
            }
            $about = $class;
        }
        $this->about = $about;
    }
}
inc/managers/SEO/render/Traits/_Properties/artEditionTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait artEditionTrait {
    /**
     * @var int|string The number of copies when multiple copies of a piece of artwork are produced - e.g. for a limited edition of 20 prints, 'artEdition' refers to the total number of copies (in this example "20").
     */
    protected int|string $artEdition;
    public function getArtEdition():int|string|null
    {
        return $this->artEdition??null;
    }
    public function setArtEdition(int|string $artEdition):void
    {
        $this->artEdition = $artEdition;
    }
}
inc/managers/SEO/render/Traits/_Properties/artMediumTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait artMediumTrait {
    /**
     * @var string The material used. (E.g. Oil, Watercolour, Acrylic, Linoprint, Marble, Cyanotype, Digital, Lithograph, DryPoint, Intaglio, Pastel, Woodcut, Pencil, Mixed Media, etc.)
     */
    protected string $artMedium;
    public function getArtMedium():?string
    {
        return $this->artMedium??null;
    }
    public function setArtMedium(string $artMedium):void
    {
        $this->artMedium = $artMedium;
    }
}
inc/managers/SEO/render/Traits/_Properties/artformTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait artformTrait {
    /**
     * @var string e.g. Painting, Drawing, Sculpture, Print, Photograph, Assemblage, Collage, etc.
     */
    protected string $artform;
    public function getArtform():?string
    {
        return $this->artform??null;
    }
    public function setArtform(string $artform):void
    {
        $this->artform = $artform;
    }
}
inc/managers/SEO/render/Traits/_Properties/artistTrait.php
New file
@@ -0,0 +1,23 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Person\Person;
if (!defined('ABSPATH')) {
    exit;
}
trait artistTrait {
    /**
     * @var Person The primary artist for a work in a medium other than pencils or digital line art--for example, if the primary artwork is done in watercolors or digital paints.
     */
    protected Person $artist;
    public function getArtist():?Person
    {
        return $this->artist??null;
    }
    public function setArtist(Person $artist):void
    {
        $this->artist = $artist;
    }
}
inc/managers/SEO/render/Traits/_Properties/artworkSurfaceTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait artworkSurfaceTrait {
    /**
     * @var string The supporting materials for the artwork, e.g. Canvas, Paper, Wood, Board, etc. Supersedes surface.
     */
    protected string $artworkSurface;
    public function getArtworkSurface():?string
    {
        return $this->artworkSurface??null;
    }
    public function setArtworkSurface(string $artworkSurface):void
    {
        $this->artworkSurface = $artworkSurface;
    }
}
inc/managers/SEO/render/Traits/_Properties/coloristTrait.php
New file
@@ -0,0 +1,23 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Person\Person;
if (!defined('ABSPATH')) {
    exit;
}
trait coloristTrait {
    /**
     * @var Person The individual who adds color to inked drawings.
     */
    protected Person $colorist;
    public function getColorist():?Person
    {
        return $this->colorist??null;
    }
    public function setColorist(Person $colorist):void
    {
        $this->colorist = $colorist;
    }
}
inc/managers/SEO/render/Traits/_Properties/creatorTrait.php
@@ -4,6 +4,7 @@
use JVBase\managers\SEO\render\Thing\Organization\Organization;
use JVBase\managers\SEO\render\Thing\Person\Person;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
use JVBase\registrar\config\seo\Resolver;
if (!defined('ABSPATH')) {
    exit;
@@ -19,8 +20,16 @@
    {
        return $this->creator??null;
    }
    public function setCreator(Organization|Person|array $creator):void
    public function setCreator(string|Organization|Person|array $creator):void
    {
        if (is_string($creator)) {
            if (empty($creator)) {
                return;
            }
            error_log('Creator value: '.print_r($creator, true));
            //TODO: Set creator from string
            return;
        }
        if (is_array($creator)) {
            $creator = $this->mixedArray('creator', $creator, [
                'JVBase\managers\SEO\render\Thing\Organization\Organization',
inc/managers/SEO/render/Traits/_Properties/inkerTrait.php
New file
@@ -0,0 +1,23 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Person\Person;
if (!defined('ABSPATH')) {
    exit;
}
trait inkerTrait {
    /**
     * @var Person The individual who traces over the pencil drawings in ink after pencils are complete.
     */
    protected Person $inker;
    public function getInker():?Person
    {
        return $this->inker??null;
    }
    public function setInker(Person $inker):void
    {
        $this->inker = $inker;
    }
}
inc/managers/SEO/render/Traits/_Properties/lettererTrait.php
New file
@@ -0,0 +1,23 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Person\Person;
if (!defined('ABSPATH')) {
    exit;
}
trait lettererTrait {
    /**
     * @var Person The individual who adds lettering, including speech balloons and sound effects, to artwork.
     */
    protected Person $letterer;
    public function getLetterer():?Person
    {
        return $this->letterer??null;
    }
    public function setLetterer(Person $letterer):void
    {
        $this->letterer = $letterer;
    }
}
inc/managers/SEO/render/Traits/_Properties/pencilerTrait.php
New file
@@ -0,0 +1,23 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Person\Person;
if (!defined('ABSPATH')) {
    exit;
}
trait pencilerTrait {
    /**
     * @var Person The individual who draws the primary narrative artwork.
     */
    protected Person $penciler;
    public function getPenciler():?Person
    {
        return $this->penciler??null;
    }
    public function setPenciler(Person $penciler):void
    {
        $this->penciler = $penciler;
    }
}
inc/managers/SEO/render/Traits/_Properties/sourceOrganizationTrait.php
@@ -16,8 +16,15 @@
    {
        return $this->sourceOrganization??null;
    }
    public function setSourceOrganization(Organization $sourceOrganization):void
    public function setSourceOrganization(string|Organization $sourceOrganization):void
    {
        if (is_string($sourceOrganization)) {
            if (empty($sourceOrganization)) {
                return;
            }
            //TODO: create organization from string
            return;
        }
        $this->sourceOrganization = $sourceOrganization;
    }
}
inc/managers/ScriptLoader.php
@@ -44,6 +44,7 @@
        $version,
        $strategy
    );
    wp_localize_script('jvb-auth', 'jvbBase', ['base' => BASE]);
    wp_register_script(
        'jvb-interactions',
        JVB_URL.'assets/js/min/interactions.min.js',
@@ -212,6 +213,7 @@
        $strategy
    );
    //SEO Admin
    wp_register_script(
        'jvb-schema',
inc/meta/Form.php
@@ -1179,7 +1179,7 @@
            'type'      => $config['subtype'],
        ], $config);
        $registrar = Registrar::getInstance($config['subtype']);
        $registrar = Registrar::getInstance($config[$config['subtype']]);
        $icon = jvbDefaultIcon();
        if ($registrar){
            $icon = $registrar->getIcon()??jvbDefaultIcon();
@@ -1597,8 +1597,8 @@
    {
        $fields = $config['fields'] ?? [];
        $rows = is_array($value) ? $value : [];
        if(array_key_exists('row_label', $config)) {
            $config['data']['label'] = esc_attr($config['row_label']);
        if(array_key_exists('add_label', $config)) {
            $config['data']['label'] = esc_attr($config['add_label']);
        }
        $input = sprintf(
inc/meta/Item.php
@@ -47,7 +47,7 @@
            'user_email',
        ],
        'term' => [
            'term_name',
            'name',
            'description'
        ]
    ];
inc/meta/MetaFormOld.php
@@ -642,7 +642,7 @@
        $values = is_array($value) ? $value : array();
        $conditional = $this->handleConditionalField($field);
        $row_label = isset($field['row_label']) ? $field['row_label'] : '';
        $row_label = isset($field['add_label']) ? $field['add_label'] : '';
        $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
inc/meta/MetaTypeManager.php
@@ -85,12 +85,17 @@
            'sanitize'  => 'sanitizeUser',
            'default'   => '',
        ],
        'post'  => [
            'type'      => 'string',
            'sanitize'  => 'sanitizePost',
            'default'   => '',
        ],
        'repeater' => [
            'type' => 'object',
            'sanitize' => 'sanitizeRepeater',
            'default'   => [],
        ],
        'tag_list' => [
        'taglist' => [
            'type' => 'object',
            'sanitize' => 'sanitizeTagList',
            'default' => []
@@ -133,7 +138,12 @@
            'type'  => 'string',
            'sanitize'  => 'sanitize_text_field',
            'default'   => '',
        ]
        ],
        'selector' => [
            'type'  => 'string',
            'sanitize'  => 'sanitizeSelector',
            'default'   => '',
        ]
    ];
    public static function getType(string $field_name):array
    {
inc/meta/Repeater.php
@@ -392,6 +392,6 @@
     */
    public function rowLabelField(): ?string
    {
        return $this->config['row_label'] ?? null;
        return $this->config['add_label'] ?? null;
    }
}
inc/meta/Sanitizer.php
@@ -31,11 +31,9 @@
           MetaTypeManager::getSanitizeCallback($field_config['type']);
    }
    protected static function sanitizeTaxonomy(array|string $values, array $field_config):string
    protected static function sanitizeTaxonomy(string $values, array $field_config):string
    {
        if (!is_array($values)) {
            $values = explode(',', $values);
        }
        $values = array_map('absint', explode(',', $values));
        // Ensure taxonomy starts with BASE
        $taxonomy = (str_starts_with($field_config['taxonomy'], BASE))
@@ -47,17 +45,21 @@
        return implode(',', $values);
    }
    protected static function sanitizeUser(array|string $values, array $field_config):string
    protected static function sanitizeUser(string $values, array $field_config):string
    {
        if (!is_array($values)) {
            $values = explode(',', $values);
        }
        $values = array_map('absint', explode(',', $values));
        $values = array_filter($values, fn($value) => (bool)get_userdata((int)$value));
        return implode(',', $values);
    }
    protected static function sanitizePost(string $values, array $config):string
    {
        $values = array_map('absint', explode(',', $values));
        return implode(',', array_filter($values, fn($value) => (bool)get_post((int)$value)));
    }
    protected static function sanitizeTagList(array $values, array $field_config): array
    {
        if (empty(array_filter($values, fn($value) => !empty($value)))) {
@@ -171,6 +173,18 @@
        return $sanitized;
    }
    protected static function sanitizeSelector(string $value, array $config):string
    {
        if (array_key_exists('type', $config)) {
            return match ($config['type']) {
                'user' => self::sanitizeUser($value, $config),
                'taxonomy'=> self::sanitizeTaxonomy($value, $config),
                'post'  => self::sanitizePost($value, $config),
            };
        }
        return implode(',',array_map('absint', explode(',',$value)));
    }
    protected static function sanitizeUpload(array|string $value):string
    {
        if (empty($value)) {
inc/meta/Storage.php
@@ -440,7 +440,7 @@
    protected function getTermField(Item $item, string $name): mixed
    {
        return match ($name) {
            'term_name' => get_term_field('name', $item->id),
            'name' => get_term_field('name', $item->id),
            'description' => get_term_field('description', $item->id),
            default => ''
        };
inc/registrar/Fields.php
@@ -4,8 +4,10 @@
use JVBase\registrar\fields\Field;
use JVBase\registrar\fields\GroupedField;
use JVBase\registrar\fields\OptionsField;
use JVBase\registrar\fields\TaxonomyField;
use JVBase\registrar\fields\Upload;
use JVBase\registrar\fields\RepeaterField;
use JVBase\registrar\fields\TagListField;
use JVBase\registrar\fields\SelectorField;
use JVBase\registrar\fields\UploadField;
if (!defined('ABSPATH')) {
    exit;
@@ -16,6 +18,7 @@
    protected Registrar $registrar;
    public function __construct(?string $type = null, ?Registrar $registrar = null) {
        $this->registrar = $registrar;
        switch ($type) {
            case 'post':
                $this->addPostFields();
@@ -27,15 +30,16 @@
                $this->addUserFields();
                break;
        }
        $this->registrar = $registrar;
    }
    public function addField(string $name, array $config):self {
        $this->fields[$name] = match ($config['type']) {
            'upload', 'image', 'gallery'    => new Upload($name, $config),
            'upload', 'image', 'gallery'    => new UploadField($name, $config),
            'checkbox', 'radio', 'select', 'set' => new OptionsField($name, $config),
            'repeater', 'group', 'tagList' => new GroupedField($name, $config),
            'selector', 'taxonomy', 'user', 'post' => new TaxonomyField($name, $config),
            'group' => new GroupedField($name, $config),
            'repeater'  => new RepeaterField($name, $config),
            'tagList'   => new TagListField($name, $config),
            'selector', 'taxonomy', 'user', 'post' => new SelectorField($name, $config),
            default => new Field($name, $config),
        };
@@ -117,10 +121,10 @@
                'label'     => 'Description',
            ]
        ];
        if ($this->registrar->registrar->hierarchical){
        if ($this->registrar->args()['hierarchical']??false && $this->registrar->args()['hierarchical'] === true){
            $fields['parent'] = [
                'type'          => 'taxonomy',
                'taxonomy_type' => 'reference',
                'isReference'   => true,
                'autocomplete'  => true,
                'label'         => 'Term Parent'
            ];
@@ -170,4 +174,182 @@
    {
        return $this->fields;
    }
    public function addCommon(string $name):self
    {
        match ($name) {
            'wiki'      => $this->addWikiField(),
            'links'     => $this->addLinksField(),
            'contact'   => $this->addContactField(),
            'reviews', 'review' => $this->addReviewField(),
            'alternate_name'    => $this->addAlternateName(),
            'keywords' => $this->addKeywords(),
            default => error_log('[Field]addCommon: No configuration found for '.$name.'.')
        };
        return $this;
    }
    protected function addWikiField():void
    {
        $this->addField(
            'wiki',
            [
                'type'        => 'url',
                'label'       => 'Wikipedia Page',
                'description' => 'For the schema',
                'quickEdit'   => true,
            ]
        );
    }
    protected function addLinksField():void
    {
        $this->addField(
            'links',
            [
                'type'        => 'repeater',
                'quickEdit'   => true,
                'add_label' => 'title',
                'label'       => 'Online Links',
                'description' => 'These are listed publicly on the website',
                'fields'      => [
                    'url'     => [
                        'type'  => 'url',
                        'label' => 'URL',
                    ],
                    'title'   => [
                        'type'  => 'text',
                        'label' => 'Label',
                    ],
                    'tracker' => [
                        'type'        => 'text',
                        'label'       => 'Tracker',
                        'description' => 'If you are set up to track link referrals, add what comes after the ? here.',
                    ],
                ],
                'section'     => 'contact'
            ]
        );
    }
    protected function addContactField():void
    {
        $this->addField(
            'admin_contact',
            [
                'type'      => 'set',
                'label'     => 'Admin Contact',
                'quickEdit' => true,
                'options'   => [
                    'text'  => 'Text',
                    'call'  => 'Call',
                    'email' => 'Email',
                    'insta' => 'Instagram',
                ],
                'section'   => 'contact'
            ]
        );
        $this->addField(
            'public_contact',
            [
                'type'      => 'set',
                'label'     => 'Public Contact',
                'quickEdit' => true,
                'options'   => [
                    'text'  => 'Text',
                    'call'  => 'Call',
                    'email' => 'Email',
                    'insta' => 'Instagram',
                ],
                'section'   => 'contact'
            ]
        );
    }
    protected function addReviewField():void
    {
        $this->addField(
            'reviews',
            [
                'type'    => 'repeater',
                'add_label' => 'name',
                'label'   => 'Reviews',
                'fields'  => [
                    'name'   => [
                        'type'  => 'text',
                        'label' => 'Reviewer Name',
                    ],
                    'review' => [
                        'type'  => 'textarea',
                        'quill' => false,
                        'label' => 'Review',
                    ],
                    'rating' => [
                        'type'    => 'select',
                        'label'   => 'Rating',
                        'options' => [
                            'none' => 'Not Given',
                            '0.5'  => '0.5',
                            '1'    => '1',
                            '1.5'  => '1.5',
                            '2'    => '2',
                            '2.5'  => '2.5',
                            '3'    => '3',
                            '3.5'  => '3.5',
                            '4'    => '4',
                            '4.5'  => '4.5',
                            '5'    => '5',
                        ],
                        'default' => 'none'
                    ],
                    'date'   => [
                        'type'  => 'date',
                        'label' => 'Date of Review',
                    ],
                    'url'    => [
                        'type'  => 'url',
                        'label' => 'Link to Review (optional)',
                    ],
                ],
                'section' => 'seo'
            ]
        );
    }
    protected function addAlternateName():void
    {
        $this->addField(
            'alternate_name',
            [
                'type'    => 'repeater',
                'label'   => 'Alternate Name',
                'fields'  => [
                    'name' => [
                        'type'  => 'text',
                        'label' => 'Name',
                    ]
                ],
                'section' => 'seo'
            ]
        );
    }
    protected function addKeywords():void
    {
        $this->addField(
            'keywords',
            [
                'type'      => 'repeater',
                'label'     => 'Keywords',
                'fields'    => [
                    'keyword' => [
                        'type'  => 'text',
                        'label' => 'Keyword',
                    ],
                ],
                'default'   => $labels ?? [ 'Edmonton tattoos', 'Edmonton tattoo artist', 'Edmonton tattooist' ],
                'section'   => 'seo',
                'quickEdit' => true,
            ]
        );
    }
}
inc/registrar/Registrar.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\registrar;
use JVBase\managers\Cache;
use JVBase\managers\CRUD;
use JVBase\managers\IconsManager;
use JVBase\meta\Meta;
use JVBase\registrar\config\Breadcrumbs;
use JVBase\registrar\config\Dashboard;
use JVBase\registrar\config\Directory;
@@ -15,6 +17,7 @@
use JVBase\registrar\helpers\MakeTrackChanges;
use JVBase\registrar\helpers\MakeVerification;
use JVBase\utility\Features;
use WP_Query;
if (!defined('ABSPATH')) {
    exit;
@@ -36,6 +39,8 @@
    protected ?string $upload_title = null;
    protected int|false $page = false;
    protected static array $allFlags = [
        //Shared Flags
        'favouritable', 'karma', 'show_feed', 'show_directory', 'approve_new', 'has_responses', 'invitable',
@@ -281,6 +286,11 @@
        return $this->fields;
    }
    public function args():array
    {
        return $this->args;
    }
    public function setFields():void
    {
        $this->fields = new Fields($this->type, $this);
@@ -401,6 +411,11 @@
        });
        foreach ($flags as $flag) {
            $this->$flag = true;
            switch ($flag) {
                case 'is_content':
                    add_action('init', [$this, 'setupContent'], 20);
                    break;
            }
        }
        return $this;
    }
@@ -639,4 +654,106 @@
        return $content;
    }
    public function setupContent():void
    {
        if (!$this->is_content) return;
        //We need a pseudo-archive page for this content taxonomy. We create a post with the plural name
        $this->page = get_option(BASE.$this->slug.'_archive', false);
        if (!$this->page || !(bool)get_post((int)$this->page)) {
            $exists = new WP_Query([
                'post_type' => 'page',
                'title'=> $this->plural,
                'posts_per_page'    => 1,
                'post_status'   => 'publish',
                'fields'    => 'ids',
            ]);
            if ($exists->have_posts()) {
                $page = $exists->posts[0];
            } else {
                $page = wp_insert_post([
                    'post_type'     => 'page',
                    'post_title'    => $this->plural,
                    'post_content'  => '',
                    'post_status'   => 'publish',
                ]);
            }
            if ($page && !is_wp_error($page)) {
                update_post_meta($page, BASE.'for_type', $this->slug);
                update_option(BASE.$this->slug.'_archive', $page);
                $this->page = $page;
            }
            wp_reset_postdata();
        }
        add_filter('jvb_post_content_output', [$this, 'renderContent'], 20, 2);
    }
    public function renderContent(string $content, array $block):string
    {
        if (!is_page($this->page)) {
            return $content;
        }
        if ($block['blockName'] !== 'core/post-content') {
            return $content;
        }
        if (JVB_TESTING) {
            Cache::for($this->slug)->flush();
        }
        $out = Cache::for($this->slug)->remember(
            get_the_ID(),
            function() {
                $items = get_terms([
                    'taxonomy'  => jvbCheckBase($this->slug),
//                      'hide_empty' => true,
                    'fields'    => 'ids',
                ]);
                $out = [];
                if ($items && !is_wp_error($items)) {
                    foreach ($items as $item) {
                        $meta = Meta::forTerm($item);
                        $slug = sanitize_title($meta->get('name'));
                        $item = sprintf(
                            '<li id="%s"><h3><a href="%s">%s</a></h3><p>%s</p><ul>',
                            $slug,
                            get_term_link($item, jvbCheckBase($this->slug))??'',
                            $meta->get('name'),
                            $meta->get('description')
                        );
                            $postTypes = array_map(function($type) { return jvbCheckBase($type);}, $this->registrar->for);
                            $posts = new WP_Query([
                                'post_type'     => $postTypes,
                                'post_status'   => 'publish',
                                'posts_per_page'    => 3,
                                'fields'    => 'ids',
                            ]);
                            if ($posts->have_posts()) {
                                while($posts->have_posts()) {
                                    $posts->the_post();
                                    $ID = get_the_id();
                                    $postMeta = Meta::forPost($ID);
                                    $img = $postMeta->get('post_thumbnail');
                                    $img = !empty($img) ? jvbFormatImage((int)$img, 'tiny', 'medium') : '';
                                    $item .= sprintf(
                                        '<li id="%s"><h4><a href="%s">%s</a></h4>%s</li>',
                                        $slug.'-'.sanitize_title(get_the_title($ID)),
                                        get_the_permalink($ID),
                                        $postMeta->get('post_title'),
                                        $img
                                    );
                                }
                            }
                            wp_reset_postdata();
                        $item .= '</ul></li>';
                        $out[] = $item;
                    }
                }
                return empty($out) ? '' : '<ul class="content-term-list">'.implode('',$out).'</ul>';
            }
        );
        error_log('Built the '.$this->slug.' page content.');
        return $content . $out;
    }
}
inc/registrar/Terms.php
@@ -179,75 +179,78 @@
    public function register():void
    {
        $args = [
            'labels'        => $this->labels,
            'public'        => $this->public,
            'hierarchical'  => $this->hierarchical,
        ];
            if (isset($this->description)) {
                $args['description'] = $this->description;
            }
            if (isset($this->publicly_queryable)) {
                $args['publicly_queryable'] = $this->publicly_queryable;
            }
            if (isset($this->show_ui)) {
                $args['show_ui'] = $this->show_ui;
            }
            if (isset($this->show_in_menu)) {
                $args['show_in_menu'] = $this->show_in_menu;
            }
            if (isset($this->show_in_nav_menus)) {
                $args['show_in_nav_menus'] = $this->show_in_nav_menus;
            }
            if (isset($this->show_in_rest)) {
                $args['show_in_rest'] = $this->show_in_rest;
            }
            if (isset($this->rest_base)) {
                $args['rest_base'] = $this->rest_base;
            }
            if (isset($this->rest_namespace)) {
                $args['rest_namespace'] = $this->rest_namespace;
            }
            if (isset($this->rest_controller_class)) {
                $args['rest_controller_class'] = $this->rest_controller_class;
            }
            if (isset($this->show_tag_cloud)) {
                $args['show_tag_cloud'] = $this->show_tag_cloud;
            }
            if (isset($this->show_quick_edit)) {
                $args['show_quick_edit'] = $this->show_quick_edit;
            }
            if (isset($this->show_admin_column)) {
                $args['show_admin_column'] = $this->show_admin_column;
            }
            if (isset($this->meta_box_cb) && is_callable($this->meta_box_cb)) {
                $args['meta_box_cb'] = $this->meta_box_cb;
            }
            if (isset($this->meta_box_sanitize_cb) && is_callable($this->meta_box_sanitize_cb)) {
                $args['meta_box_sanitize_cb'] = $this->meta_box_sanitize_cb;
            }
            if (isset($this->capabilities)) {
                $allowed = ['manage_terms', 'edit_terms', 'delete_terms', 'assign_terms'];
                $caps = array_filter($this->capabilities, function ($cap) use ($allowed) {
                    return in_array($cap, $allowed);
                }, ARRAY_FILTER_USE_KEY);
                $args['capabilities'] = $caps;
            }
            if (isset($this->query_var)) {
                $args['query_var'] = $this->query_var;
            }
            if (isset($this->update_count_callback) && is_callable($this->update_count_callback)) {
                $args['update_count_callback'] = $this->update_count_callback;
            }
            if (isset($this->default_term)) {
                $args['default_term'] = $this->default_term;
            }
            if (isset($this->sort)) {
                $args['sort'] = $this->sort;
            }
            if (isset($this->args)) {
                $args['args'] = $this->args;
            }
        $args = array_filter(get_object_vars($this));
//      $args = [
//          'labels'        => $this->labels,
//          'public'        => $this->public,
//          'hierarchical'  => $this->hierarchical,
//      ];
//          if (isset($this->description)) {
//              $args['description'] = $this->description;
//          }
//          if (isset($this->publicly_queryable)) {
//              $args['publicly_queryable'] = $this->publicly_queryable;
//          }
//          if (isset($this->show_ui)) {
//              $args['show_ui'] = $this->show_ui;
//          }
//          if (isset($this->show_in_menu)) {
//              $args['show_in_menu'] = $this->show_in_menu;
//          }
//          if (isset($this->show_in_nav_menus)) {
//              $args['show_in_nav_menus'] = $this->show_in_nav_menus;
//          }
//          if (isset($this->show_in_rest)) {
//              $args['show_in_rest'] = $this->show_in_rest;
//          }
//          if (isset($this->rest_base)) {
//              $args['rest_base'] = $this->rest_base;
//          }
//          if (isset($this->rest_namespace)) {
//              $args['rest_namespace'] = $this->rest_namespace;
//          }
//          if (isset($this->rest_controller_class)) {
//              $args['rest_controller_class'] = $this->rest_controller_class;
//          }
//          if (isset($this->show_tag_cloud)) {
//              $args['show_tag_cloud'] = $this->show_tag_cloud;
//          }
//          if (isset($this->show_quick_edit)) {
//              $args['show_quick_edit'] = $this->show_quick_edit;
//          }
//          if (isset($this->show_admin_column)) {
//              $args['show_admin_column'] = $this->show_admin_column;
//          }
//          if (isset($this->meta_box_cb) && is_callable($this->meta_box_cb)) {
//              $args['meta_box_cb'] = $this->meta_box_cb;
//          }
//          if (isset($this->meta_box_sanitize_cb) && is_callable($this->meta_box_sanitize_cb)) {
//              $args['meta_box_sanitize_cb'] = $this->meta_box_sanitize_cb;
//          }
//          if (isset($this->capabilities)) {
//              $allowed = ['manage_terms', 'edit_terms', 'delete_terms', 'assign_terms'];
//              $caps = array_filter($this->capabilities, function ($cap) use ($allowed) {
//                  return in_array($cap, $allowed);
//              }, ARRAY_FILTER_USE_KEY);
//              $args['capabilities'] = $caps;
//          }
//          if (isset($this->query_var)) {
//              $args['query_var'] = $this->query_var;
//          }
//          if (isset($this->update_count_callback) && is_callable($this->update_count_callback)) {
//              $args['update_count_callback'] = $this->update_count_callback;
//          }
//          if (isset($this->default_term)) {
//              $args['default_term'] = $this->default_term;
//          }
//          if (isset($this->sort)) {
//              $args['sort'] = $this->sort;
//          }
//          if (isset($this->args)) {
//              $args['args'] = $this->args;
//          }
        unset($args['for']);
        $for = array_map(function($item) { return jvbCheckBase($item);}, $this->for);
        register_taxonomy(jvbCheckBase($this->taxonomy), $for, $args);
inc/registrar/_setup.php
@@ -9,9 +9,11 @@
require(JVB_DIR . '/inc/registrar/fields/Field.php');
require(JVB_DIR . '/inc/registrar/fields/GroupedField.php');
require(JVB_DIR . '/inc/registrar/fields/RepeaterField.php');
require(JVB_DIR . '/inc/registrar/fields/TagListField.php');
require(JVB_DIR . '/inc/registrar/fields/OptionsField.php');
require(JVB_DIR . '/inc/registrar/fields/TaxonomyField.php');
require(JVB_DIR . '/inc/registrar/fields/Upload.php');
require(JVB_DIR . '/inc/registrar/fields/SelectorField.php');
require(JVB_DIR . '/inc/registrar/fields/UploadField.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeCalendarType.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeTrackChanges.php');
inc/registrar/config/SEO.php
@@ -33,7 +33,24 @@
    protected function initSchema():void
    {
        if (!array_key_exists('type', $this->config['schema'])){
        if (!array_key_exists('type', $this->config['schema'])) {
            $registrar = Registrar::getInstance($this->slug);
            if ($registrar) {
                switch ($registrar->getType()) {
                    case 'term':
                        $this->config['schema']['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                        break;
                    case 'post':
                        $this->config['schema']['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork';
                        break;
                    case 'user':
                        $this->config['schema']['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\ProfilePage';
                        break;
                }
            }
        }
        if (!array_key_exists('type', $this->config['schema'])) {
            error_log('Missing schema type');
            return;
        }
inc/registrar/config/seo/Helpers.php
@@ -9,11 +9,12 @@
function jvbTSFDoIt(string $slug, ?array $args):bool
{
    $tsf = tsf();
    return (null === $args)
        ? $tsf->query()->is_singular()
        && BASE.$slug === $tsf->query()->get_current_post_type()
        && jvbCheckBase($slug) === $tsf->query()->get_current_post_type()
        : 'single' === The_SEO_Framework\get_query_type_from_args( $args )
        && BASE.$slug === $tsf->query()->get_post_type_real_id( $args['id'] );
        && jvbCheckBase($slug) === $tsf->query()->get_post_type_real_id( $args['id'] );
}
function jvbTSFGetID(?array $args):int
inc/registrar/config/seo/Resolver.php
@@ -5,15 +5,16 @@
use JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\ImageObject;
use JVBase\managers\SEO\render\Thing\Intangible\Quantity\Distance;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
class Resolver {
    protected static Meta $meta;
    protected static ?Meta $meta;
    public static function resolve(string $template, Meta $meta): string
    public static function resolve(string $template, ?Meta $meta): string
    {
        self::$meta = $meta;
        return preg_replace_callback(
@@ -22,17 +23,23 @@
            $template
        );
    }
    protected static function resolveVariable(string $variable, Meta $meta): string
    protected static function resolveVariable(string $variable, ?Meta $meta = null): mixed
    {
        $variable = trim($variable);
        switch ($variable) {
            case 'CREATOR':
                return JVB()->seo()->getCreator();
                break;
        }
        if (str_contains($variable, '.')) {
            return self::resolveRelation($variable, $meta);
        }
        if ($variable === 'post_permalink') {
        if ($meta && $variable === 'post_permalink') {
            return get_the_permalink($meta->id());
        }
        if (!$meta) {
            return '';
        }
        $config = $meta->config($variable);
        if (!$config) {
            error_log('[SEO]Meta Resolver. Could not find meta configuration for variable: '.$variable);
@@ -45,7 +52,7 @@
        };
    }
    protected static function resolveRelation(string $path, Meta $meta): string
    protected static function resolveRelation(string $path, ?Meta $meta = null): string
    {
        $parts = explode('.', $path);
        $relation = array_shift($parts);
@@ -53,6 +60,15 @@
        //We need to:
            // 1) Get the id of the item we're fetching (meta value of the $relation)
        if ($relation === 'registrar') {
            if (count($parts) === 2) {
                return self::resolveRegistrar($parts[0], $parts[1]);
            } else {
                error_log('[Resolver]::resolveRelation: Registrar relation requires registrar.[slug].[property]');
                return '';
            }
        }
        $ID = $meta->get($relation);
        if (!$ID || $ID === '') {
            return '';
@@ -79,7 +95,7 @@
        return self::resolve($field, $newMeta);
    }
    protected static function resolveImage(string $variable, Meta $meta, bool $returnID = false): string
    protected static function resolveImage(string $variable, ?Meta $meta, bool $returnID = false): string
    {
        $imgID = $meta->get($variable);
        if (!$imgID || $imgID === '') {
@@ -94,7 +110,7 @@
        }
        return $image[0];
    }
    protected static function resolveObject(string $variable, Meta $meta): string
    protected static function resolveObject(string $variable, ?Meta $meta): string
    {
        //Hmmm... this should already be handled by dot notation.
        return '';
@@ -105,7 +121,7 @@
     * We need to map the values to what schema.org expects.
     * Most are defined in the JVBase\managers\schema\render namespace.
     */
    public static function resolveForSchema(string $property, string $value, mixed $schema, Meta $meta):mixed
    public static function resolveForSchema(string $property, string $value, mixed $schema, ?Meta $meta = null):mixed
    {
        $check = 'resolve'.ucfirst($property).'Property';
        if (method_exists(self::class, $check)) {
@@ -122,17 +138,20 @@
        return self::resolve($value, $meta);
    }
        public static function checkPropertyType(string $property, mixed $value, mixed $schema, Meta $meta):mixed
        public static function checkPropertyType(string $property, mixed $value, mixed $schema, ?Meta $meta):mixed
        {
            return match($property) {
                'logo', 'image', 'photo', 'primaryImageOfPage', 'thumbnail', 'associatedMedia' => self::resolveImageProperty($property, $value, $schema, $meta),
                'creator' => self::resolveCreator($property, $value, $schema, $meta),
                default => false
            };
        }
        public static function resolveImageProperty(string $property, mixed $value, mixed $schema, Meta $meta):?ImageObject
        public static function resolveImageProperty(string $property, mixed $value, mixed $schema, ?Meta $meta):?ImageObject
        {
            if (!$meta) {
                return null;
            }
            $value = str_replace('{{', '', str_replace('}}', '', $value));
            $imgID = $meta->get($value);
            error_log('Got image id: '.print_r($imgID, true));
@@ -143,7 +162,10 @@
            if (!$img) {
                return null;
            }
            Cache::for('imageSchemaObject')->flush();
            if (JVB_TESTING){
                Cache::for('imageSchemaObject')->flush();
            }
            return Cache::for('imageSchemaObject')->connect('post')->remember(
                $imgID,
                function () use ($imgID, $img) {
@@ -176,4 +198,29 @@
            );
        }
        public static function resolveCreator(string $type, mixed $value, mixed $schema, ?Meta $meta):mixed
        {
            if (is_numeric($value)) {
                //TODO generate from id
            } else if (str_contains($value, 'CREATOR')) {
                return JVB()->seo()->getCreator();
            }
            return '';
        }
        public static function resolveRegistrar(string $type, string $property):string
        {
            $registrar = Registrar::getInstance($type);
            if (!$registrar) {
                return '';
            }
            $method = 'get'.ucfirst($property);
            if (!method_exists($registrar, $method)) {
                error_log('[Resolver]::resolveRegistrar: Invalid property getter: '.$property);
                return '';
            }
            return $registrar->$method();
        }
}
inc/registrar/config/seo/Schema.php
@@ -34,7 +34,10 @@
        'description' => '{{post_excerpt}}',
    ];
    protected array $defaultArchive = [];
    protected array $defaultArchive = [
        'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage',
        'title' => '{{registrar.plural}}'
    ];
    public function __construct(string $slug, string $type)
    {
@@ -52,6 +55,16 @@
            $this->cache->connect($registrar->getType());
            $this->referenceCache->connect($registrar->getType());
            $this->archiveCache->connect($registrar->getType());
            switch ($registrar->getType()) {
                case 'term':
                    $this->defaultSchema['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                    break;
                case 'user':
                    $this->defaultSchema['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\ProfilePage';
                    break;
            }
            $this->defaultArchive['description'] = '{{registrar.'.$slug.'.description}}';
        }
        $this->initFilters();
        $this->registerHooks();
@@ -75,6 +88,8 @@
    {
        add_action('wp_head', [$this, 'outputSchema'], 1);
        add_filter('the_seo_framework_schema_graph_data', [$this, 'filterTSFSchema'], 10, 2);
        add_filter('the_seo_framework_title_from_custom_field', [$this, 'filterTSFOGTitle'], 10, 2);
        $this->maybeExcludeSingles();
    }
        protected function maybeExcludeSingles(): void
@@ -119,67 +134,120 @@
        }
    public function filterTSFSchema(array $graph, ?array $args):array
    {
        if (jvbTSFDoIt($this->slug, $args)){
        $based = jvbCheckBase($this->slug);
        if (is_front_page() || is_singular($based) || is_post_type_archive($based) || is_tax($based)) {
            return [];
        }
//      if (jvbTSFDoIt($this->slug, $args)){
//          return [];
//      }
        return $graph;
    }
    public function outputSchema():void
    {
        $registrar = Registrar::getInstance($this->slug);
        if (is_singular()) {
            $this->outputSingularSchema();
        } elseif (is_post_type_archive(jvbCheckBase($this->slug) || is_tax(jvbCheckBase($this->slug)))) {
            $this->outputArchiveSchema();
        } if ($registrar && $registrar->hasFeature('is_content') && is_single(get_option(BASE.$this->slug.'_archive'))) {
            $this->outputContentTaxArchiveSchema();
        }
    }
        public function outputSingularSchema():array
        {
            $ID = get_the_ID();
            $this->cache->flush();
            if (JVB_TESTING){
                $this->cache->flush();
            }
            return $this->cache->remember(
                $ID,
                function () use ($ID) {
                    $meta = Meta::forPost($ID);
                    $config = $this->getConfig();
                    $class = new $config['type']();
                    unset($config['type']);
                    foreach ($config as $property => $value){
                        $value = Resolver::resolveForSchema($property, $value, $config, $meta);
                        $method = 'set'.ucfirst($property);
                        $class->$method($value);
                    }
                    $class = $this->classFromConfig($config, $meta);
                    $class->setAuthor(JVB()->seo()->getCreator(true));
                    $schema = $class->outputSchema();
                    error_log('Generated archive schema: '.print_r($schema, true));
                    return $schema;
                    return $class->outputSchema();
                }
            );
        }
        public function outputContentTaxArchiveSchema():array
        {
            $ID = get_the_ID();
            if (JVB_TESTING) {
                $this->cache->flush();
            }
            return $this->cache->remember(
                $ID,
                function() use ($ID) {
                    $action = BASE.ucfirst($this->slug).'Schema';
                    $config = get_option($action, apply_filters($action, $this->defaultSchema));
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                    }
                    if (!class_exists($config['type'])) {
                        error_log('No class found for archive schema output: '.$config['type']);
                        return [];
                    }
                    $class = $this->classFromConfig($config);
                    $class->setIsPartOf(get_home_url().'/#website');
                    $itemList = new render\Thing\Intangible\ItemList\ItemList();
                    $items = get_terms([
                        'taxonomy'  => jvbCheckBase($this->slug),
//                      'hide_empty' => true,
                        'fields'    => 'ids',
                    ]);
                    $pos = 1;
                    $itemListItems = [];
                    foreach ($items as $ID) {
                        $item = $this->outputReferenceSchema($ID, 'term',false);
                        $listItem = new render\Thing\Intangible\ListItem();
                        $listItem->setPosition($pos);
                        $listItem->setItem($item);
                        $itemListItems[] = $listItem;
                        $pos++;
                    }
                    wp_reset_postdata();
                    $itemList->setItemListElement($itemListItems);
                    $class->setMainEntity($itemList);
                    return $class->outputSchema();
                }
            );
        }
        public function outputArchiveSchema():array
        {
            $this->archiveCache->flush();
            if (JVB_TESTING){
                $this->archiveCache->flush();
            }
            return $this->archiveCache->remember(
                $this->slug,
                function() {
                    $action = BASE.ucfirst($this->slug).'Archive';
                    $config = get_option($action, apply_filters($action, $this->defaultArchive));
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                    }
                    if (!class_exists($config['type'])) {
                        error_log('No class found for archive schema output: '.$config['type']);
                        return [];
                    }
                    $obj = get_queried_object();
                    $meta = (property_exists($obj, 'taxonomy')) ? Meta::forTerm($obj->term_id) : null;
                    $class = new $config['type'];
                    unset($config['type']);
                    foreach ($config as $property=>$value) {
                        $method = 'set'.ucfirst($property);
                        $class->$method($value);
                    }
                    $class = $this->classFromConfig($config, $meta);
                    $class->setIsPartOf(get_home_url().'/#website');
                    $itemList = new render\Thing\Intangible\ItemList\ItemList();
                    $items = new WP_Query([
@@ -191,7 +259,7 @@
                    $pos = 1;
                    $itemListItems = [];
                    foreach ($items->posts as $ID) {
                        $item = $this->outputReferenceSchema($ID, false);
                        $item = $this->outputReferenceSchema($ID, 'post',false);
                        $listItem = new render\Thing\Intangible\ListItem();
                        $listItem->setPosition($pos);
                        $listItem->setItem($item);
@@ -209,24 +277,43 @@
            );
        }
    public function outputReferenceSchema(int $ID, bool $outputSchema = true):mixed
    public function outputReferenceSchema(int $ID, string $type, bool $outputSchema = true):mixed
    {
        $this->referenceCache->flush();
        if (JVB_TESTING){
            $this->referenceCache->flush();
        }
        $cached = $this->referenceCache->remember(
            $ID,
            function () use ($ID) {
                $meta = Meta::forPost($ID);
                $config = $this->getConfig();
                $class = new $config['type']();
                $class->setId(get_the_permalink($ID).'/#'.$class->getTypeName());
                foreach ($this->referenceProperties as $property => $value){
                    $value = Resolver::resolveForSchema($property, $value, $this->schema, $meta);
                    $method = 'set'.ucfirst($property);
                    $class->$method($value);
            function () use ($ID, $type) {
                switch ($type) {
                    case 'post':
                        $meta = Meta::forPost($ID);
                        break;
                    case 'term':
                        $meta = Meta::forTerm($ID);
                        break;
                    case 'user':
                        $meta = Meta::forUser($ID);
                        break;
                    default:
                        error_log('Invalid type used for reference: '.print_r($type, true));
                        $meta = null;
                }
                $config = $this->getConfig('archive');
                $class = $this->classFromConfig($config, $meta);
                $class->delete('about');
                switch ($type) {
                    case 'post':
                        $class->setId(get_the_permalink($ID).'#'.$class->getTypeName());
                        break;
                    case 'term':
                        $class->setId(get_term_link($ID).'#'.$class->getTypeName());
                        break;
                }
                $schema = $class->outputSchema();
                error_log('Generated archive schema: '.print_r($schema, true));
                return $class;
            }
        );
@@ -264,7 +351,12 @@
    }
    public function defineReference(string $property, string $value):void
    {
        $class = $this->getConfig('schema')['type'];
        $config = $this->getConfig();
        if (!array_key_exists('type', $config)) {
            $config['type'] = $this->defaultSchema['type'];
            update_option(BASE.ucfirst($this->slug).'Schema', $config);
        }
        $class = $this->getConfig()['type'];
        if (!class_exists($class)) {
            error_log('[SEO]Schema::defineReference Class not found: '.$class);
            return;
@@ -294,4 +386,47 @@
            $this->defineReference($property, $value);
        }
    }
    public function filterTSFOGTitle(string $title, ?array $args):string{
        $based = jvbCheckBase($this->slug);
        if (is_singular($based)){
            $config = $this->getConfig('meta');
            $meta = Meta::forPost(get_the_ID());
            $title = Resolver::resolve($config['name'], $meta);
        } elseif (is_post_type_archive($based) || is_tax($based)) {
            $config = $this->getConfig('archive');
            $title = $config['name'];
        }
        return $title;
    }
    protected function classFromConfig(array $config, ?Meta $meta = null):mixed
    {
        if (!array_key_exists('type', $config)) {
            error_log('[Schema]::classFromConfig No class defined in config: '.print_r($config, true));
            return false;
        }
        $className = $config['type'];
        unset($config['type']);
        $class = new $className();
        foreach ($config as $property=>$value) {
            if (is_array ($value)) {
                $value = $this->classFromConfig($value, $meta);
            }
            $method = 'set'.ucfirst($property);
            if (!method_exists($class, $method)) {
                error_log('[Schema]::classFromConfig - method: '.$method.' does not exist in class: '.$className);
                continue;
            }
            if (is_string($value) && str_contains($value, '{{')) {
                $value = Resolver::resolveForSchema($property, $value, $config, $meta);
            }
            if (!empty($value)) {
                $class->$method($value);
            }
        }
        return $class;
    }
}
inc/registrar/fields/Field.php
@@ -37,12 +37,23 @@
                return;
            }
        }
        $current = get_class($this);
        $class = match($config['type']) {
            'group' => ($current !== GroupedField::class) ? new GroupedField($name, $config) : $this,
            'select', 'radio', 'checkbox' => ($current !== OptionsField::class) ? new OptionsField($name, $config) : $this,
            'repeater' => ($current !== RepeaterField::class) ? new RepeaterField($name, $config) : $this,
            'taglist' => ($current !== TagListField::class) ? new TagListField($name, $config) : $this,
            'taxonomy', 'post', 'user', 'selector' => ($current !== SelectorField::class) ? new SelectorField($name, $config) : $this,
            'upload' => ($current !== UploadField::class) ? new UploadField($name, $config) : $this,
            default => $this
        };
        foreach ($config as $key => $value) {
            if (property_exists($this, $key)) {
            if (property_exists($class, $key)) {
                $method = 'set' . ucfirst($key);
                $this->$method($value);
                $class->$method($value);
            } else {
                error_log('Instance: '.print_r($this, true));
                error_log('Instance: '.print_r($class, true));
                error_log('[JVBase\registrar\Field] Invalid key for '.$name.': '.$key);
            }
        }
inc/registrar/fields/GroupedField.php
@@ -13,12 +13,18 @@
    {
        foreach ($fields as $name => $config) {
            $this->fields[$name] = match ($config['type']) {
                'upload', 'image', 'gallery'    => new Upload($name, $config),
                'upload', 'image', 'gallery'    => new UploadField($name, $config),
                'checkbox', 'radio', 'select', 'set' => new OptionsField($name, $config),
                'repeater', 'group', 'tagList' => new GroupedField($name, $config),
                'selector', 'taxonomy', 'user', 'post' => new TaxonomyField($name, $config),
                'repeater' => new RepeaterField($name, $config),
                'tagList'   => new TagListField($name, $config),
                'group' => new GroupedField($name, $config),
                'selector', 'taxonomy', 'user', 'post' => new SelectorField($name, $config),
                default => new Field($name, $config),
            };
        }
    }
    public function getFields():array
    {
        return $this->fields;
    }
}
inc/registrar/fields/RepeaterField.php
New file
@@ -0,0 +1,50 @@
<?php
namespace JVBase\registrar\fields;
if (!defined('ABSPATH')) {
    exit;
}
class RepeaterField extends GroupedField {
    protected array $fields = [];
    protected string $add_label = 'Add Row';
    protected string $row_label = 'New Item';
    public function setAdd_label(string $label):void
    {
        $this->add_label = $label;
    }
    public function getAdd_label():string
    {
        return $this->add_label;
    }
    protected function checkFieldVariables(string $variables):bool
    {
        if (!str_contains($variables, '{{')) {
            return false;
        }
        $fields = array_filter(array_map(function($field) { return str_replace('}}','',$field);}, explode('{{',$variables)));
        foreach ($fields as $field) {
            if (!array_key_exists($field, $this->fields)) {
                error_log('['.(new \ReflectionClass($this))->getShortName().']Invalid field attempted: '.$field);
                return false;
            }
        }
        return true;
    }
    public function setRow_label(string $label):void
    {
        if (str_contains($label, '{{')) {
            if (!$this->checkFieldVariables($label)) {
                return;
            }
        }
        $this->row_label = $label;
    }
    public function getRow_label():string
    {
        return $this->row_label;
    }
}
inc/registrar/fields/SelectorField.php
New file
@@ -0,0 +1,125 @@
<?php
namespace JVBase\registrar\fields;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
class SelectorField extends Field {
    protected string $taxonomy;
    protected string $post_type;
    protected string $user_role;
    protected int $max = 0;
    protected bool $search = true;
    protected bool $createNew = false;
    protected bool $autocomplete = true;
    /**
     * @var bool isReference If true, does not set default taxonomy functionality, but is a reference to the selected items. (example: if it's a taxonomy subtype, does not set_object_terms with the selection)
     */
    protected bool $isReference = false;
    protected bool $update = true;
    protected string $subtype;
    protected array $allowedSubtype = ['taxonomy', 'user', 'post'];
    public function setTaxonomy(string $taxonomy):void
    {
        $allowed = array_merge(['tag', 'category'], Registrar::getRegistered('term'));
        if (!in_array(jvbNoBase($taxonomy), $allowed)) {
            error_log('[SelectorField]Attempted taxonomy not allowed: '.$taxonomy);
            return;
        }
        $this->taxonomy = $taxonomy;
    }
    public function getTaxonomy():string {
        return $this->taxonomy;
    }
    public function setPost_type(string $post_type):void
    {
        $allowed = array_merge(['post', 'page'], Registrar::getRegistered('post'));
        if (!in_array(jvbNoBase($post_type), $allowed)) {
            error_log('[SelectorField]Attempted post type not allowed: '.$post_type);
            return;
        }
        $this->post_type = $post_type;
    }
    public function getPost_type():string {
        return $this->post_type;
    }
    public function setUser_role(string $user_role):void
    {
        $allowed = array_merge(['administrator'], Registrar::getRegistered('user'));
        if (!in_array(jvbNoBase($user_role), $allowed)) {
            error_log('[SelectorField]Attempted user not allowed: '.$user_role);
            return;
        }
        $this->user_role = $user_role;
    }
    public function getUser_role():string {
        return $this->user_role;
    }
    public function setMax(int $max):void
    {
        $this->max = $max;
    }
    public function getMax():int
    {
        return $this->max;
    }
    public function setSearch(bool $search):void {
        $this->search = $search;
    }
    public function getSearch():bool
    {
        return $this->search;
    }
    public function setCreateNew(bool $createNew):void
    {
        $this->createNew = $createNew;
    }
    public function getCreateNew():bool
    {
        return $this->createNew;
    }
    public function setAutocomplete(bool $autocomplete):void
    {
        $this->autocomplete = $autocomplete;
    }
    public function getAutocomplete():bool
    {
        return $this->autocomplete;
    }
    public function setUpdate(bool $update):void
    {
        $this->update = $update;
    }
    public function getUpdate():bool
    {
        return $this->update;
    }
    public function setSubtype(string $subtype):void
    {
        if (!in_array($subtype, $this->allowedSubtype)) {
            error_log('[SelectorField]Attempted subtype not allowed: '.$subtype);
            return;
        }
        $this->subtype = $subtype;
    }
    public function getSubtype():string
    {
        return $this->subtype;
    }
    public function setIsReference(bool $isReference):void
    {
        $this->isReference = $isReference;
    }
    public function getIsReference():bool
    {
        return $this->isReference;
    }
}
inc/registrar/fields/TagListField.php
New file
@@ -0,0 +1,33 @@
<?php
namespace JVBase\registrar\fields;
if (!defined('ABSPATH')) {
    exit;
}
class TagListField extends RepeaterField {
    protected string $tag_format = 'first_field';
    protected string $add_label = 'Add';
    public function setTag_format(string $tag_format):void
    {
        if (str_contains($tag_format, '{{')) {
            if (!$this->checkFieldVariables($tag_format)) {
                return;
            }
            $this->tag_format = $tag_format;
            return;
        }
        if (!in_array($tag_format, ['first_field', 'all_fields'])) {
            error_log('[TagListField]Could not validate tag format: '.$tag_format);
            return;
        }
        $this->tag_format = $tag_format;
    }
    public function getTag_format():string
    {
        return $this->tag_format;
    }
}
inc/registrar/fields/TaxonomyField.php
File was deleted
inc/registrar/fields/UploadField.php
File was renamed from inc/registrar/fields/Upload.php
@@ -6,7 +6,7 @@
    exit;
}
class Upload extends Field {
class UploadField extends Field {
    protected bool $multiple = false;
    protected string $subtype;
inc/rest/routes/ContentRoutes.php
@@ -280,8 +280,9 @@
        // Run query
        $query = new WP_Query($args);
        $registrar = Registrar::getInstance(str_replace('-', '_', $this->post_type));
        $this->fields = $registrar->getFields()??[];
        $this->fields = Registrar::getFieldsFor(str_replace('-', '_', $this->post_type));
        $this->taxonomies = $this->getTaxonomies($this->post_type);
        $posts = array_map([$this, 'prepareItem'], $query->posts);
@@ -480,7 +481,7 @@
        $get = [];
        $fields = (empty($fields)) ? $this->fields : $fields;
        $get = $this->getUploadFields($fields);
        error_log('Upload fields: '.print_r($get, true));
        if (!empty($get)) {
            $actualGet = array_map(function($fieldName) {
inc/ui/CRUDSkeleton.php
@@ -216,14 +216,18 @@
     */
    public function addTaxonomyFilter(array $taxonomies, ?string $limit = null): self {
        foreach($taxonomies as $taxonomy) {
            error_log('Fetchinig taxonomy: '.print_r($taxonomy, true));
            $registrar = Registrar::getInstance($taxonomy);
            $this->taxonomies[$taxonomy] = [
                'type'  => 'taxonomy',
                'taxonomy'=> $taxonomy,
                'limit' => $limit,
                'label' => $registrar->getPlural(),
                'icon'  => $registrar->getIcon()
            ];
            if ($registrar) {
                $this->taxonomies[$taxonomy] = [
                    'type'  => 'taxonomy',
                    'taxonomy'=> $taxonomy,
                    'limit' => $limit,
                    'label' => $registrar->getPlural(),
                    'icon'  => $registrar->getIcon()
                ];
            }
        }
        return $this;
@@ -977,7 +981,8 @@
                    <option value="<?=$control?>"<?=$disabled?>><?=$label?></option>
                    <?php
                }
                foreach ($this->taxonomies as $taxonomy) {
                foreach ($this->taxonomies as $taxonomy =>$config) {
                    $registrar = Registrar::getInstance($taxonomy);
                    if (!$registrar) continue;
                    ?>
@@ -1624,7 +1629,6 @@
                        $section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
                        $tabs[$section]['content'] .= Form::render($n, '', $config);
                    } else {
                        jvbDump($config, $n);
                        echo Form::render($n, '', $config);
                    }
                }
inc/users/UserSettings.php
@@ -126,17 +126,19 @@
                'section'   => 'newsletter',
            ],
            'owner_of'    => [
                'type' => 'taxonomy',
                'type' => 'selector',
                'subtype'=> 'taxonomy',
                'label'    => __('Owner of', 'jvb'),
                'taxonomy_type' => 'reference',
                'isReference' => true,
                'taxonomy' => BASE. 'shop',
                'quickEdit'    => true,
                'default'    => '',
            ],
            'manager_of'    => [
                'type' => 'taxonomy',
                'type' => 'selector',
                'subtype' => 'taxonomy',
                'label'    => __('Manager of', 'jvb'),
                'taxonomy_type' => 'reference',
                'isReference' => true,
                'taxonomy' => BASE. 'shop',
                'hidden'    => true,
                'default'    => '',
jvb.php
@@ -82,9 +82,13 @@
{
    return [BASE.'directory', BASE.'dash', 'attachment', 'revision', 'nav_menu_item'];
}
add_filter('show_admin_bar', '__return_false');
define('JVB_TESTING', str_contains(get_home_url(),'.test'));
if (JVB_TESTING) {
    add_filter('show_admin_bar', '__return_false');
}
//if (JVB_TESTING) {
//  error_log('In testing mode...');
//} else {