From 47e77f9fac1155c536b2b87fec552c7fcce66fa6 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 01 Jun 2026 18:06:34 +0000
Subject: [PATCH] =Timeline block fixes. Next up: adding article schema classes
---
assets/js/concise/DataStore.js | 566 +++++++++++++++++++++++++++++++++++++++++++++++++++----
1 files changed, 517 insertions(+), 49 deletions(-)
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index d4cb8bc..284c913 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -6,6 +6,7 @@
* this.store = window.jvbStore.register('feed', { config });
*/
class DataStore {
+
constructor() {
// Singleton pattern
if (DataStore.instance) {
@@ -44,13 +45,13 @@
* @param {object|array} configs An object defining the store, or an array of objects defining the stores
* @param {number} version the database version
*/
- register(name, configs = [], version = 1.1) {
+ register(name, configs = [], version = 1.25) {
if (!Array.isArray(configs)) configs = [configs];
if (configs.length === 0) return;
if (!this.dbConfig.has(name)) {
this.dbConfig.set(name, {
- dbName: `jvb_${name}`,
+ dbName: `${jvbBase.base}${name}`,
version: version,
stores: {},
_initialized: false
@@ -81,8 +82,11 @@
endpoint: null,
apiBase: jvbSettings.api,
filters: {},
+ ignore: [], //any filters to ignore when filtering store locally
required: null,
+ isAuth: false,
+
// Cache
TTL: 3600000, // 1 hour
useHttpCaching: true,
@@ -104,6 +108,12 @@
_initialized: false
};
+ store.ignoreFilters = new Set([
+ ... ['search', 'page', 'per_page', 'orderby', 'order'],
+ ... ['context', 'source'],
+ ... store.config.ignore
+ ]);
+
store.config.headers = {
'X-WP-Nonce': window.auth.getNonce(),
...store.config.headers
@@ -137,9 +147,14 @@
// Data methods
fetch: () => this.fetch(name),
save: (item) => this.save(name, item),
+ saveMany: (items) => this.saveMany(name, items),
delete: (id) => this.delete(name, id),
+ deleteMany: (items) => this.deleteMany(name, items),
get: (id) => this.get(name, id),
+ getMany: (ids) => this.getMany(name, ids),
getAll: () => this.getAll(name),
+ getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value),
+ filterByIndex: (criteria) => this.filterByIndex(name, criteria),
getFiltered: () => this.getFiltered(name),
clear: () => this.clear(name),
@@ -459,13 +474,16 @@
let result;
tx.oncomplete = () => resolve(result);
- tx.onerror = () => reject(tx.error);
+ tx.onerror = () => {
+ const error = tx.error || new Error('Transaction failed with unknown error');
+ reject(error);
+ };
// Call callback immediately to queue operations
try {
result = callback(objectStore, tx);
} catch (error) {
- reject(error);
+ reject(error || new Error('Callback failed with unknown error'));
}
});
}
@@ -502,9 +520,10 @@
const cached = store.cache.get(cacheKey);
if (cached && this.isCacheValid(cached, store.config.TTL)) {
+ let items = cached.items.map(itemId => this.get(name, itemId));
this.notify(name, 'data-loaded', {
cached: true,
- items: cached.items || []
+ items: items??[]
});
return cached;
}
@@ -525,11 +544,27 @@
const controller = new AbortController();
store.currentRequest = controller;
- const response = await fetch(url, {
- method: 'GET',
- headers,
- signal: controller.signal
- });
+ let response;
+ if (store.isAuth) {
+ response = await window.auth.fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal
+ });
+ } else {
+ response = await fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal
+ });
+ }
+
+ if (!response.ok) {
+ // Access the error details from the response body
+ const errorBody = await response.text();
+ // Throw a new error with a descriptive message
+ throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`);
+ }
if (response.status === 304) {
// 304 means "Not Modified" - use cached data if available
@@ -564,7 +599,6 @@
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
-
const data = await response.json();
await this.processFetchedData(name, data, cacheKey, response);
@@ -577,11 +611,14 @@
return data;
} catch (error) {
- if (error.name !== 'AbortError') {
- console.error(`Fetch error for store "${name}":`, error);
+ const isAbortError = error?.name === 'AbortError';
+
+ if (!isAbortError) {
+ console.error(`Fetch error for store "${name}":`, error.message);
+ console.dir(error);
this.notify(name, 'fetch-error', { error });
+ throw error;
}
- throw error;
} finally {
store.isFetching = false;
@@ -616,8 +653,8 @@
*/
async processFetchedData(name, data, cacheKey, response) {
const store = this.stores.get(name);
- const items = data.items || [];
- const changes = []; // Track all changes
+ const items = (data.items || []).filter(item => item && typeof item === 'object');
+ const changes = [];
// Batch process with single transaction
if (store.db && items.length > 0) {
@@ -645,7 +682,8 @@
endpoint: store.config.endpoint,
filters: { ...store.filters },
etag: response.headers.get('ETag'),
- lastModified: response.headers.get('Last-Modified')
+ lastModified: response.headers.get('Last-Modified'),
+ has_more: data.has_more || false
};
store.cache.set(cacheKey, cacheEntry);
@@ -666,6 +704,12 @@
queue_stats: data.queue_stats || {}
};
+ for (let [key, value] of Object.entries(store.filters)) {
+ if (typeof value === 'string' && value.includes(',')) {
+ this.createSplitCacheEntries(name, items, key, store.filters, response);
+ }
+ }
+
// Emit events for items with status changes
changes.forEach(changeInfo => {
if (changeInfo.statusChanged) {
@@ -678,6 +722,39 @@
});
}
+ createSplitCacheEntries(name, items, key, filters, response) {
+ const store = this.stores.get(name);
+ const keys = filters[key].split(',').map(v => v.trim());
+
+ keys.forEach(value => {
+ let temp = {};
+ temp[key] = value;
+ const newFilters = {
+ ... filters,
+ [key]: value
+ };
+ const cacheKey = this.generateCacheKey(newFilters);
+ if(store.cache.has(cacheKey)) return;
+ let filteredItems = this.filterByIndex(name,temp).map(item => this.getItemKey(item, store.config.keyPath));
+
+ const entry = {
+ key: cacheKey,
+ items: filteredItems,
+ timestamp: Date.now(),
+ endpoint: store.config.endpoint,
+ filters: newFilters,
+ etag: response.headers.get('Etag'),
+ lastModified: response.headers.get('Last-Modified'),
+ has_more: filteredItems.length === 20,
+ }
+ store.cache.set(cacheKey, entry);
+ if (store.db?.objectStoreNames.contains('cache')) {
+ this.withTransaction(name, 'cache', 'readwrite', (objectStore) =>{
+ objectStore.put(entry);
+ });
+ }
+ })
+ }
/***********************************************************************
* SAVE OPERATIONS
***********************************************************************/
@@ -735,8 +812,57 @@
return changeInfo.key;
}
+ /**
+ * Save multiple items in a single transaction (batch write)
+ * @param {string} name - Store name
+ * @param {Array|Map} items - Array of items or Map of items to save
+ * @returns {Promise<Array>} - Array of saved keys
+ */
+ async saveMany(name, items) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ // Convert Map to array if needed
+ const itemArray = items instanceof Map
+ ? Array.from(items.values())
+ : Array.isArray(items) ? items : Object.values(items);
+
+ if (itemArray.length === 0) return [];
+
+ const changes = [];
+
+ // Process all items and update in-memory store
+ itemArray.forEach(item => {
+ const changeInfo = this._saveItem(name, item);
+ changes.push(changeInfo);
+ });
+
+ // Single transaction for all writes
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ changes.forEach(changeInfo => {
+ objectStore.put(changeInfo.processed);
+ });
+ });
+
+ // Notify once for batch
+ this.notify(name, 'items-saved', {
+ count: changes.length,
+ keys: changes.map(c => c.key)
+ });
+
+ return changes.map(c => c.key);
+ }
+
processForStorage(obj, validate = true, path = 'root') {
- if (obj === null || obj === undefined) return { valid: true, data: obj };
+ if (obj === null) {
+ return { valid: true, data: null };
+ }
+ if (obj === undefined) {
+ if (validate) {
+ return { valid: false, error: `Undefined value at ${path}` };
+ }
+ return { valid: true, data: undefined };
+ }
const type = typeof obj;
@@ -747,30 +873,32 @@
// Reject functions
if (type === 'function') {
- return validate ? { valid: false, error: `Function at ${path}` } : { valid: true, data: null };
+ if (validate) return { valid: false, error: `Function at ${path}` };
+
+ return { valid: true, data: undefined };
}
// DOM elements
if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
- return validate ? { valid: false, error: `DOM element at ${path}` } : { valid: true, data: null };
+ if (validate) return { valid: false, error: `DOM element at ${path}` };
+
+ return { valid: true, data: undefined };
}
// FormData - convert and continue
if (obj instanceof FormData) {
- return validate
- ? { valid: false, error: `FormData at ${path}` }
- : { valid: true, data: this.formDataToObject(obj) };
+
+ return { valid: true, data: this.formDataToObject(obj) };
}
// Preserve safe types
- if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
+ if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj) || obj instanceof Blob) {
return { valid: true, data: obj };
}
// Convert Sets to Arrays
if (obj instanceof Set) {
- const arr = Array.from(obj);
- return this.processForStorage(arr, validate, path);
+ return this.processForStorage(Array.from(obj), validate, path);
}
// Convert Maps to Objects
@@ -784,7 +912,7 @@
for (let i = 0; i < obj.length; i++) {
const result = this.processForStorage(obj[i], validate, `${path}[${i}]`);
if (!result.valid) return result;
- if (result.data !== null) processed.push(result.data);
+ if (result.data !== undefined) processed.push(result.data);
}
return { valid: true, data: processed };
}
@@ -793,16 +921,20 @@
if (type === 'object') {
const processed = {};
for (const [key, value] of Object.entries(obj)) {
+ if (value === undefined) continue;
const result = this.processForStorage(value, validate, `${path}.${key}`);
if (!result.valid) return result;
- if (result.data !== null) processed[key] = result.data;
+ // Include null values, skip undefined
+ if (result.data !== undefined || value === null) {
+ processed[key] = result.data;
+ }
}
return { valid: true, data: processed };
}
- return validate
- ? { valid: false, error: `Unknown type at ${path}` }
- : { valid: true, data: null };
+ if (validate) return { valid: false, error: `Unknown type at ${path}` };
+
+ return { valid: true, data: undefined };
}
/***********************************************************************
@@ -820,30 +952,277 @@
this.notify(name, 'item-deleted', { id });
}
+ /**
+ * Delete multiple items in a single transaction (batch delete)
+ * @param {string} name - Store name
+ * @param {Array|Set} ids - Array or Set of IDs to delete
+ * @returns {Promise<Array>} - Array of deleted IDs
+ */
+ async deleteMany(name, ids) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ // Convert Set to array if needed
+ const idArray = ids instanceof Set
+ ? Array.from(ids)
+ : Array.isArray(ids) ? ids : Object.keys(ids);
+
+ if (idArray.length === 0) return [];
+
+ // Update in-memory store
+ idArray.forEach(id => {
+ store.data.delete(id);
+ });
+
+ // Single transaction for all deletes
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ idArray.forEach(id => {
+ objectStore.delete(id);
+ });
+ });
+
+ // Notify once for batch
+ this.notify(name, 'items-deleted', {
+ count: idArray.length,
+ ids: idArray
+ });
+
+ return idArray;
+ }
+
get(name, id) {
const store = this.stores.get(name);
return store.data.get(id);
}
+ /**
+ * Get multiple items by IDs in a single call
+ * @param {string} name - Store name
+ * @param {Array|Set} ids - Array or Set of IDs to retrieve
+ * @param {boolean} skipMissing - If true, omit missing items; if false, include null for missing
+ * @returns {Array} - Array of items (in same order as IDs)
+ */
+ getMany(name, ids, skipMissing = true) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ const idArray = ids instanceof Set
+ ? Array.from(ids)
+ : Array.isArray(ids) ? ids : Object.keys(ids);
+
+ if (idArray.length === 0) return [];
+
+ if (skipMissing) {
+ return idArray.reduce((acc, id) => {
+ const item = store.data.get(id);
+ if (item) acc.push(item);
+ return acc;
+ }, []);
+ }
+
+ // Preserve order, include null for missing
+ return idArray.map(id => store.data.get(id) ?? null);
+ }
+
getAll(name) {
const store = this.stores.get(name);
return Array.from(store.data.values());
}
+ /**
+ * Filter in-memory data by multiple index/value pairs
+ * @param {string} name - Store name
+ * @param {Object} criteria - Object of { indexName: acceptedValue(s) }
+ * @returns {Array} - Items matching ALL criteria
+ *
+ * @example
+ * filterByIndex(name, { field: 'upload_123', status: ['queued', 'uploading'] })
+ */
+ filterByIndex(name, criteria) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ return Array.from(store.data.values()).filter(item => {
+ if (!item || typeof item !== 'object') return false;
+ return Object.entries(criteria).every(([key, value]) => {
+ const accepted = Array.isArray(value) ? value : [value];
+ return accepted.includes(item[key]);
+ });
+ });
+ }
+ /**
+ * Get all items matching an index value
+ * @param {string} name - Store name
+ * @param {string} indexName - Name of the index to query
+ * @param {*} value - Value to match
+ * @returns {Promise<Array>} - Matching items
+ */
+ async getAllByIndex(name, indexName, value) {
+ const store = this.stores.get(name);
+ const values = Array.isArray(value) ? value : [value];
+
+ // Try IndexedDB index query first (more efficient for large datasets)
+ if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) {
+ try {
+ const tx = store.db.transaction([store.config.storeName], 'readonly');
+ const objectStore = tx.objectStore(store.config.storeName);
+
+ if (objectStore.indexNames.contains(indexName)) {
+ const index = objectStore.index(indexName);
+
+ const results = await Promise.all(
+ values.map(v => new Promise((resolve, reject) => {
+ const request = index.getAll(v);
+ request.onsuccess = () => resolve(request.result || []);
+ request.onerror = () => reject(request.error);
+ }))
+ );
+
+ return results.flat();
+ }
+ } catch (error) {
+ console.warn(`Index query failed for "${indexName}", falling back to filter:`, error);
+ }
+ }
+
+ // Fallback: filter in-memory data
+ return Array.from(store.data.values()).filter(item => values.includes(item[indexName]));
+ }
getFiltered(name) {
const store = this.stores.get(name);
const cacheKey = this.generateCacheKey(store.filters);
const cacheEntry = store.cache.get(cacheKey);
- if (cacheEntry && cacheEntry.items) {
- return cacheEntry.items.reduce((acc, id) => {
+ // First check if we have cached results for exact filters
+ if (cacheEntry?.items) {
+ const items = cacheEntry.items.reduce((acc, id) => {
const item = store.data.get(id);
if (item) acc.push(item);
return acc;
}, []);
+ return this.applyOrdering(items, store);
}
- return this.getAll(name);
+ const allItems = Array.from(store.data.values());
+
+ const searchQuery = store.filters.search?.toLowerCase().trim() || '';
+
+ const filterPredicates = [];
+
+ // Handle taxonomy filters separately
+ if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
+ Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
+ const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
+
+ filterPredicates.push(item => {
+ if (!item.taxonomies || !item.taxonomies[taxonomy]) {
+ return false;
+ }
+ const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
+ const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
+ return matches;
+ });
+ });
+ }
+
+ // Handle other filters
+ for (const [key, value] of Object.entries(store.filters)) {
+ if (key === 'taxonomy') {
+ if (typeof value === 'string' && !value.includes(',')) {
+ filterPredicates.push(item => item.taxonomy === value);
+ }
+ continue;
+ }
+ if (store.ignoreFilters.has(key)) {
+ continue;
+ }
+ if (value === null || value === undefined || value === '') continue;
+ if (value === 'all') continue;
+
+ if (typeof value === 'string' && value.includes(',')) {
+ const accepted = value.split(',').map(v => v.trim());
+ filterPredicates.push(item => accepted.includes(String(item[key])));
+ } else {
+ filterPredicates.push(item => String(item[key]) === String(value));
+ }
+ }
+
+ const filtered = allItems.filter(item => {
+ for (const predicate of filterPredicates) {
+ if (!predicate(item)) return false;
+ }
+ return !(searchQuery && !this.searchObject(item, searchQuery));
+ });
+
+ return this.applyOrdering(filtered, store);
+ }
+
+ applyOrdering(items, store) {
+ if (!Array.isArray(items)) items = Array.from(items);
+ if (items.length === 0) return items;
+
+ const orderby = store.filters.orderby || 'date';
+ const order = (store.filters.order || 'desc').toLowerCase();
+
+ // Handle random ordering
+ if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
+ return this.shuffle(items);
+ }
+
+ items.sort((a, b) => {
+ let aVal, bVal;
+
+ switch (orderby) {
+ case 'alphabetical':
+ case 'title':
+ aVal = (a.title || a.name || '').toLowerCase();
+ bVal = (b.title || b.name || '').toLowerCase();
+ break;
+ case 'modified':
+ aVal = new Date(a.modified || a.date || 0);
+ bVal = new Date(b.modified || b.date || 0);
+ break;
+ case 'date':
+ default:
+ aVal = new Date(a.date || a.modified || 0);
+ bVal = new Date(b.date || b.modified || 0);
+ }
+
+ if (aVal < bVal) return order === 'asc' ? -1 : 1;
+ if (aVal > bVal) return order === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ return items;
+ }
+
+ shuffle(items) {
+ const array = items.slice();
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+ return array;
+ }
+
+ searchObject(obj, search) {
+ if (!obj || typeof obj !== 'object') {
+ return typeof obj === 'string' && obj.toLowerCase().includes(search);
+ }
+
+ for (const value of Object.values(obj)) {
+ if (value === null || value === undefined) continue;
+
+ if (typeof value === 'object') {
+ if (this.searchObject(value, search)) return true;
+ continue;
+ }
+
+ if (typeof value === 'string' && value.toLowerCase().includes(search)) {
+ return true;
+ }
+ }
+ return false;
}
async clear(name) {
@@ -859,40 +1238,127 @@
}
/***********************************************************************
- * FILTER OPERATIONS (UNIFIED)
+ * FILTER OPERATIONS
***********************************************************************/
-
- /**
- * Unified filter update - handles all filter operations
- */
async updateFilters(name, updates, clearAll = false) {
const store = this.stores.get(name);
const oldFilters = { ...store.filters };
if (clearAll) {
store.filters = { ...store.config.filters };
- } else {
- // Apply updates (null/undefined/'' = delete)
- Object.entries(updates).forEach(([key, value]) => {
- if (value === null || value === undefined || value === '') {
- delete store.filters[key];
- } else {
- store.filters[key] = value;
- }
- });
}
+ Object.entries(updates).forEach(([key, value]) => {
+ if (value === null || value === undefined || value === '') {
+ delete store.filters[key];
+ } else {
+ store.filters[key] = value;
+ }
+ });
this.notify(name, 'filters-changed', {
oldFilters,
filters: store.filters,
updates
});
- if (store.config.endpoint) {
+ const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
+ if (store.config.endpoint && shouldFetch) {
await this.fetch(name);
+ } else {
+ const filtered = this.getFiltered(name);
+ this.notify(name, 'data-loaded', {
+ cached: true,
+ items: filtered
+ });
}
}
+ /**
+ * Determine if we need to fetch or can use local data
+ * @param {string} name - Store name
+ * @param {object} updates - Filter updates being applied
+ * @param {object} oldFilters - Previous filter state
+ * @returns {Promise<boolean>} - True if fetch is needed, false if local filtering suffices
+ */
+ async shouldFetchWithFilters(name, updates, oldFilters) {
+ const store = this.stores.get(name);
+
+ // If no endpoint or no lastResponse, always fetch
+ if (!store.config.endpoint || !store.lastResponse) {
+ return true;
+ }
+
+ if (store.lastResponse.has_more === false) {
+ if (this.hasCompleteData(store, store.filters)) {
+ return false;
+ }
+ }
+
+ if ('page' in updates) {
+ const newPage = updates.page;
+ const oldPage = oldFilters.page || 1;
+
+ // If trying to go to a higher page but no more data available
+ if (newPage > oldPage && !store.lastResponse.has_more) {
+ // Reset page to last valid page
+ store.filters.page = oldPage;
+ return false;
+ }
+ }
+
+ // SEARCH OPTIMIZATION: Check if we need to fetch for search
+ if ('search' in updates) {
+ const searchQuery = updates.search?.trim() || '';
+ const oldSearch = oldFilters.search?.trim() || '';
+
+ // If search is being cleared, we might already have the data
+ if (!searchQuery && oldSearch) {
+ // Check if we have all base data (without search)
+ const baseFilters = { ...store.filters };
+ delete baseFilters.search;
+ baseFilters.page = 1;
+
+ // If we have complete base data, no need to fetch
+ if (this.hasCompleteData(store, baseFilters)) {
+ return false;
+ }
+ }
+
+ // If search is new or changed, check if we have all data to filter locally
+ if (searchQuery && searchQuery !== oldSearch) {
+ // Check: do we have all data for base filters (no search, page 1)?
+ const baseFilters = { ...store.filters };
+ delete baseFilters.search;
+ baseFilters.page = 1;
+
+ // If we have complete base data, we can filter locally
+ if (this.hasCompleteData(store, baseFilters)) {
+ return false;
+ }
+ }
+ }
+
+ // Default: fetch is needed
+ return true;
+ }
+
+ /**
+ * Check if we have complete data for given filters
+ * @param {object} store - Store instance
+ * @param {object} filters - Filters to check
+ * @returns {boolean} - True if we have all data
+ */
+ hasCompleteData(store, filters) {
+ const cacheKey = this.generateCacheKey(filters);
+ const cached = store.cache.get(cacheKey);
+
+ if (!cached) return false;
+
+ // Check if cache indicates no more data
+ return cached.has_more === false || store.lastResponse?.has_more === false;
+ }
+
setFilter(name, key, value) {
return this.updateFilters(name, { [key]: value });
}
@@ -902,6 +1368,8 @@
const hasChanges = Object.keys(filters).some(
key => store.filters[key] !== filters[key]
+ ) || Object.keys(store.filters).some(
+ key => !(key in filters) && filters !== store.config.filters
);
if (!hasChanges) return;
--
Gitblit v1.10.0