diff --git a/api/src/paths/v2/iapp.ts b/api/src/paths/v2/iapp.ts index fe7c6eafd..595f2b518 100644 --- a/api/src/paths/v2/iapp.ts +++ b/api/src/paths/v2/iapp.ts @@ -331,15 +331,23 @@ export function getIAPPSQLv2(filterObject: any) { sqlStatement = fromStatement(sqlStatement, filterObject); sqlStatement = whereStatement(sqlStatement, filterObject); sqlStatement = groupByStatement(sqlStatement, filterObject); - if (!filterObject.vt_request) { + if (!filterObject.vt_request && !filterObject.boundingBoxOnly) { sqlStatement = orderByStatement(sqlStatement, filterObject); sqlStatement = limitStatement(sqlStatement, filterObject); sqlStatement = offSetStatement(sqlStatement, filterObject); - } else { + } else if (filterObject.vt_request) { sqlStatement.append(` ) SELECT ST_AsMVT(mvtgeom.*, 'data', 4096, 'geom', 'feature_id') as data from mvtgeom;`); + } else if (filterObject.boundingBoxOnly) { + // wrap the whole thing into a subquery for the aggregate function + const wrappedStatement = SQL` WITH userQuery AS ( `.append(sqlStatement.text).append(` ) + SELECT ST_AsText(ST_Extent(geometry(geog))) as bbox + FROM invasivesbc.iapp_spatial + WHERE geog IS not null + AND site_id in (SELECT site_id + FROM userQuery) `); + return wrappedStatement; } - //defaultLog.debug({ label: 'getIAPPBySearchFilterCriteria', message: 'sql', body: sqlStatement }); return sqlStatement; } catch (e) { defaultLog.debug({ label: 'getIAPPBySearchFilterCriteria', message: 'error', body: e.message }); @@ -593,9 +601,7 @@ function whereStatement(sqlStatement: SQLStatement, filterObject: any) { break; case 'all_species_on_site': where.append( - `${filter.operator2} LOWER(sites.all_species_on_site) ${filter.operator === 'CONTAINS' ? 'like' : 'not like'} LOWER('%${ - filter.filter - }%') ` + `${filter.operator2} LOWER(sites.all_species_on_site) ${filter.operator === 'CONTAINS' ? 'like' : 'not like'} LOWER('%${filter.filter}%') ` ); break; case 'max_survey': diff --git a/api/src/paths/v2/iapp/bbox.ts b/api/src/paths/v2/iapp/bbox.ts new file mode 100644 index 000000000..71b9c00b3 --- /dev/null +++ b/api/src/paths/v2/iapp/bbox.ts @@ -0,0 +1,113 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SQLStatement } from 'sql-template-strings'; +import { ALL_ROLES, SECURITY_ON } from 'constants/misc'; +import { getLogger } from 'utils/logger'; +import { getDBConnection } from 'database/db'; +import { getIAPPSQLv2, sanitizeIAPPFilterObject } from '../iapp'; + +const NAMESPACE = 'IAPP-bbox'; + +const defaultLog = getLogger(NAMESPACE); +export const POST: Operation = [postHandler()]; + +POST.apiDoc = { + description: 'Fetch bounding box based on search criteria', + tags: [NAMESPACE], + security: SECURITY_ON ? [{ Bearer: ALL_ROLES }] : [], + requestBody: { + description: 'Recordset search filter criteria', + content: { + 'application/json': { + schema: { + properties: {} + } + } + } + }, + responses: { + 200: { + description: 'Bounding box response object', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + bbox: { + type: 'string', + description: 'Bounding box for the given filters' + } + } + } + } + } + }, + 401: { + $ref: '#/components/responses/401' + }, + 503: { + $ref: '#/components/responses/503' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * @desc Create Bounding box based on the filter properties for a given recordset + */ +function postHandler(): RequestHandler { + return async (req, res) => { + const connection = await getDBConnection(); + if (!connection) { + return res.status(503).json({ + message: 'Database connection unavailable', + namespace: NAMESPACE, + code: 503 + }); + } + try { + defaultLog.debug({ label: NAMESPACE, message: 'postHandler', body: req.body }); + if (req.body?.filterObjects?.[0]) { + const filterObject = sanitizeIAPPFilterObject(req.body.filterObjects[0], req); + filterObject.boundingBoxOnly = true; + const iappSql: SQLStatement = getIAPPSQLv2(filterObject); + const response = await connection.query(iappSql.text, iappSql.values); + + if (response.rowCount > 0) { + return res.status(200).json(response.rows[0]); + } else { + return res.status(404).json({ + message: 'No Results', + request: req.body, + namespace: NAMESPACE, + code: 404 + }); + } + } else { + return res.status(400).json({ + message: 'Missing filter Objects from request', + request: req.body, + namespace: NAMESPACE, + code: 400 + }); + } + } catch (error) { + defaultLog.debug({ + label: NAMESPACE, + message: 'error', + error + }); + return res.status(500).json({ + message: 'Server Error occured', + request: req.body, + namespace: NAMESPACE, + code: 500, + error + }); + } finally { + connection.release(); + } + }; +} diff --git a/app/src/UI/LegacyMap/LayerPicker/LayerPicker.tsx b/app/src/UI/LegacyMap/LayerPicker/LayerPicker.tsx index 03a5faaea..5f6439cd4 100644 --- a/app/src/UI/LegacyMap/LayerPicker/LayerPicker.tsx +++ b/app/src/UI/LegacyMap/LayerPicker/LayerPicker.tsx @@ -2,7 +2,6 @@ import { MOBILE } from 'state/build-time-config'; import LayersIcon from '@mui/icons-material/Layers'; import CloseIcon from '@mui/icons-material/Close'; import { IconButton, Switch } from '@mui/material'; -import './LayerPicker.css'; import { useState } from 'react'; import LpModules from 'constants/LpModules'; import LayerPickerPathOption from './LayerPickerPathRow'; @@ -14,6 +13,8 @@ import Accordion from 'UI/Accordion/Accordion'; import { useDispatch, useSelector } from 'utils/use_selector'; import UserSettings from 'state/actions/userSettings/UserSettings'; +import './LayerPicker.css'; + export const LayerPicker = () => { const closeLayerPicker = () => { setShowLayerPicker(false); diff --git a/app/src/UI/LegacyMap/Map.tsx b/app/src/UI/LegacyMap/Map.tsx index edc8d8f1a..f8bfaa784 100644 --- a/app/src/UI/LegacyMap/Map.tsx +++ b/app/src/UI/LegacyMap/Map.tsx @@ -15,7 +15,6 @@ import { rebuildLayersOnTableHashUpdate, refreshColoursOnColourUpdate, refreshVisibilityOnToggleUpdate, - removeDeletedRecordSetLayersOnRecordSetDelete, removeLayersOnNetworkConnectivityChange } from 'UI/LegacyMap/helpers/functional/recordset-layers'; import { @@ -41,7 +40,9 @@ import { PMTiles, Protocol } from 'pmtiles'; import { TileCacheService } from 'utils/tile-cache'; import { Coordinates } from 'UI/LegacyMap/helpers/components/Coordinates'; import { ReactiveLayers } from 'UI/LegacyMap/helpers/components/ReactiveLayers'; +import { CurrentActivityLayer } from 'UI/LegacyMap/helpers/components/CurrentActivityLayer'; import { DrawControls } from 'UI/LegacyMap/helpers/components/DrawControls'; +import { toggleLayerOnBool } from 'UI/LegacyMap/helpers/functional/utility-functions'; /* @@ -255,7 +256,6 @@ export const Map = ({ children }) => { rebuildLayersOnTableHashUpdate(storeLayers, map, MapMode, API_BASE, connectedToNetwork); refreshColoursOnColourUpdate(storeLayers, map); refreshVisibilityOnToggleUpdate(storeLayers, map); - removeDeletedRecordSetLayersOnRecordSetDelete(storeLayers, map); }, [storeLayers, map, mapReady, connectedToNetwork, loggedInOrWorkingOffline]); // Layer picker: @@ -310,6 +310,18 @@ export const Map = ({ children }) => { }, 1000); }, [map]); + // toggle public map pmtile layer + useEffect(() => { + if (!mapReady) return; + if (!map) return; + if (loggedInOrWorkingOffline) { + toggleLayerOnBool(map, 'invasivesbc-pmtile-vector', false); + toggleLayerOnBool(map, 'iapp-pmtile-vector', false); + toggleLayerOnBool(map, 'invasivesbc-pmtile-vector-label', false); + toggleLayerOnBool(map, 'iapp-pmtile-vector-label', false); + } + }, [loggedInOrWorkingOffline, map, mapReady]); + return (
@@ -323,6 +335,7 @@ export const Map = ({ children }) => { + {children} diff --git a/app/src/UI/LegacyMap/helpers/components/CurrentActivityLayer.tsx b/app/src/UI/LegacyMap/helpers/components/CurrentActivityLayer.tsx new file mode 100644 index 000000000..a3497e0e1 --- /dev/null +++ b/app/src/UI/LegacyMap/helpers/components/CurrentActivityLayer.tsx @@ -0,0 +1,99 @@ +import { useContext, useEffect, useState } from 'react'; +import { MapContext } from 'UI/LegacyMap/helpers/components/MapContext'; +import { LAYER_Z_FOREGROUND } from 'UI/LegacyMap/helpers/functional/layer-definitions'; +import { useSelector } from 'utils/use_selector'; + +const CurrentActivityLayer = ({ mapReady }) => { + const map = useContext(MapContext); + const [geo, setGeo] = useState(null); + + const activityGeometryArray = useSelector((state) => state.ActivityPage.activity?.geometry); + const { url } = useSelector((state) => state.AppMode); + + // react to changes in the geometry or current page and set our rendered geo appropriately + // render if a) we're on the Activity page and b) There is a geo object in the Activity + useEffect(() => { + if (activityGeometryArray && activityGeometryArray[0] && url?.includes('Activity')) { + setGeo(activityGeometryArray[0]); + } else { + setGeo(null); + } + }, [activityGeometryArray, url]); + + useEffect(() => { + if (!map) return; + if (!mapReady) return; + + // add the layer if needed + + const LAYER_ID = 'current-activity-'; + + const SHAPE_LAYER = `${LAYER_ID}-shape`; + const OUTLINE_LAYER = `${LAYER_ID}-outline`; + const ZOOM_CIRCLE_LAYER = `${LAYER_ID}-zoomoutcircle`; + + if (geo) { + map + .addSource(LAYER_ID, { + type: 'geojson', + data: geo + }) + .addLayer( + { + id: SHAPE_LAYER, + source: LAYER_ID, + type: 'fill', + paint: { + 'fill-color': 'white', + 'fill-outline-color': 'black', + 'fill-opacity': 0.7 + }, + minzoom: 0, + maxzoom: 24 + }, + LAYER_Z_FOREGROUND + ) + .addLayer( + { + id: OUTLINE_LAYER, + source: LAYER_ID, + type: 'line', + paint: { + 'line-color': 'black', + 'line-opacity': 1, + 'line-width': 3 + }, + minzoom: 0, + maxzoom: 24 + }, + LAYER_Z_FOREGROUND + ) + .addLayer( + { + id: ZOOM_CIRCLE_LAYER, + source: LAYER_ID, + type: 'circle', + paint: { + 'circle-color': 'white', + 'circle-radius': 3 + }, + minzoom: 0, + maxzoom: 24 + }, + LAYER_Z_FOREGROUND + ); + + return () => { + // cleanup effect -- remove created entries in reverse + map.removeLayer(ZOOM_CIRCLE_LAYER); + map.removeLayer(OUTLINE_LAYER); + map.removeLayer(SHAPE_LAYER); + map.removeSource(LAYER_ID); + }; + } + }, [geo]); + + return null; +}; + +export { CurrentActivityLayer }; diff --git a/app/src/UI/LegacyMap/helpers/components/DrawControls.tsx b/app/src/UI/LegacyMap/helpers/components/DrawControls.tsx index 1196c807e..87e57a2b7 100644 --- a/app/src/UI/LegacyMap/helpers/components/DrawControls.tsx +++ b/app/src/UI/LegacyMap/helpers/components/DrawControls.tsx @@ -1,6 +1,6 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { MapContext } from 'UI/LegacyMap/helpers/components/MapContext'; -import MapboxDraw, { DrawCustomMode } from '@mapbox/mapbox-gl-draw'; +import MapboxDraw from '@mapbox/mapbox-gl-draw'; import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import { useDispatch, useSelector } from 'utils/use_selector'; import { MAP_ON_SHAPE_CREATE, MAP_ON_SHAPE_UPDATE } from 'state/actions'; @@ -34,7 +34,6 @@ const DrawControls = () => { const whatsHereToggle = useSelector((state) => state.Map.whatsHere.toggle); const tileCacheMode = useSelector((state) => state.Map.tileCacheMode); - const activityGeo = useSelector((state) => state.ActivityPage.activity?.geometry); const drawingCustomLayer = useSelector((state) => state.Map.drawingCustomLayer); const appModeURL = useSelector((state) => state.AppMode.url); @@ -113,7 +112,13 @@ const DrawControls = () => { const drawShapeUpdate = useCallback((event) => { if (!drawInstance.current) return; + if (!['direct_select', 'simple_select'].includes(drawInstance.current.getMode())) { + // we're not done drawing until we revert to one of these modes + return; + } + const editedGeo = drawInstance.current.getAll().features[0]; + if (editedGeo?.id !== event?.features?.[0]?.id) { dispatch({ type: MAP_ON_SHAPE_UPDATE, payload: editedGeo }); } @@ -137,10 +142,6 @@ const DrawControls = () => { break; case TargetMode.ACTIVITY: drawInstance.current.changeMode('do_nothing'); - if (activityGeo && activityGeo[0] && activityGeo[0].id) { - drawInstance.current.deleteAll(); - drawInstance.current.add(activityGeo[0]); - } break; default: break; @@ -148,38 +149,31 @@ const DrawControls = () => { //drawInstance.current.changeMode('whats_here_box_mode'); } - }, [mode, activityGeo]); + }, [mode]); useEffect(() => { if (!map) { return; } - const modes = (() => { - return Object.assign( - { - draw_rectangle: DrawRectangle, - do_nothing: DoNothing, - lots_of_points: LotsOfPointsMode, - whats_here_box_mode: WhatsHereBoxMode - }, - MapboxDraw.modes - ); - })(); - drawInstance.current = new MapboxDraw({ displayControlsDefault: true, controls: { combine_features: false, uncombine_features: false }, - defaultMode: 'simple_select', - modes: modes as { [modeKey: string]: DrawCustomMode }, + modes: { + ...MapboxDraw.modes, + draw_rectangle: DrawRectangle, + do_nothing: DoNothing, + lots_of_points: LotsOfPointsMode, + whats_here_box_mode: WhatsHereBoxMode + }, styles: [ { id: 'gl-draw-line', type: 'line', - filter: ['all', ['==', '$type', 'LineString']], + // filter: ['all', ['==', '$type', 'LineString']], layout: { 'line-cap': 'round', 'line-join': 'round' @@ -187,7 +181,7 @@ const DrawControls = () => { paint: { 'line-color': '#D20C0C', 'line-dasharray': [0.2, 2], - 'line-width': 2 + 'line-width': 3 } } ] diff --git a/app/src/UI/LegacyMap/helpers/functional/current-record.ts b/app/src/UI/LegacyMap/helpers/functional/current-record.ts index 136233f40..163dc8260 100644 --- a/app/src/UI/LegacyMap/helpers/functional/current-record.ts +++ b/app/src/UI/LegacyMap/helpers/functional/current-record.ts @@ -24,40 +24,48 @@ export const refreshCurrentRecMakers = (map, options: any) => { }; export const refreshHighlightedRecord = (map, options: any) => { - const layerID = 'highlightRecordLayer'; - if (map && map.getLayer(layerID + 'shape')) { - map.removeLayer(layerID + 'shape'); + const LAYER_ID = 'highlightRecordLayer'; + + const SHAPE_LAYER = `${LAYER_ID}-shape`; + const OUTLINE_LAYER = `${LAYER_ID}-outline`; + const ZOOM_CIRCLE_LAYER = `${LAYER_ID}-zoomoutcircle`; + + if (!map) { + return; + } + + if (map.getLayer(SHAPE_LAYER)) { + map.removeLayer(SHAPE_LAYER); } - if (map && map.getLayer(layerID + 'outline')) { - map.removeLayer(layerID + 'outline'); + if (map.getLayer(OUTLINE_LAYER)) { + map.removeLayer(OUTLINE_LAYER); } - if (map && map.getLayer(layerID + 'zoomoutcircle')) { - map.removeLayer(layerID + 'zoomoutcircle'); + if (map.getLayer(ZOOM_CIRCLE_LAYER)) { + map.removeLayer(ZOOM_CIRCLE_LAYER); } - if (map && map.getLayer(layerID)) { - map.removeLayer(layerID); + if (map.getLayer(LAYER_ID)) { + map.removeLayer(LAYER_ID); } - if (map && map.getSource(layerID)) { - map.removeSource(layerID); + if (map.getSource(LAYER_ID)) { + map.removeSource(LAYER_ID); } if ( - map && options.userRecordOnHoverRecordType === 'Activity' && options.userRecordOnHoverRecordRow && options.userRecordOnHoverRecordRow?.geometry?.[0] ) { map - .addSource(layerID, { + .addSource(LAYER_ID, { type: 'geojson', data: options.userRecordOnHoverRecordRow.geometry[0] }) .addLayer( { - id: layerID + 'shape', - source: layerID, + id: SHAPE_LAYER, + source: LAYER_ID, type: 'fill', paint: { 'fill-color': 'white', @@ -71,8 +79,8 @@ export const refreshHighlightedRecord = (map, options: any) => { ) .addLayer( { - id: layerID + 'outline', - source: layerID, + id: OUTLINE_LAYER, + source: LAYER_ID, type: 'line', paint: { 'line-color': 'black', @@ -86,8 +94,8 @@ export const refreshHighlightedRecord = (map, options: any) => { ) .addLayer( { - id: layerID + 'zoomoutcircle', - source: layerID, + id: ZOOM_CIRCLE_LAYER, + source: LAYER_ID, type: 'circle', paint: { 'circle-color': 'white', @@ -100,16 +108,16 @@ export const refreshHighlightedRecord = (map, options: any) => { ); } - if (map && options.userRecordOnHoverRecordType === 'IAPP' && options.userRecordOnHoverRecordRow) { + if (options.userRecordOnHoverRecordType === 'IAPP' && options.userRecordOnHoverRecordRow) { map - .addSource(layerID, { + .addSource(LAYER_ID, { type: 'geojson', data: options.userRecordOnHoverRecordRow.geometry }) .addLayer( { - id: layerID, - source: layerID, + id: LAYER_ID, + source: LAYER_ID, type: 'circle', paint: { 'circle-color': 'yellow', diff --git a/app/src/UI/LegacyMap/helpers/functional/recordset-layers.ts b/app/src/UI/LegacyMap/helpers/functional/recordset-layers.ts index b617291db..a24f3b7ef 100644 --- a/app/src/UI/LegacyMap/helpers/functional/recordset-layers.ts +++ b/app/src/UI/LegacyMap/helpers/functional/recordset-layers.ts @@ -10,23 +10,33 @@ import { LAYER_Z_BACKGROUND, LAYER_Z_FOREGROUND, LAYER_Z_MID } from 'UI/LegacyMa import { FALLBACK_COLOR } from 'UI/LegacyMap/helpers/functional/constants'; import { safelySetPaintProperty } from 'UI/LegacyMap/helpers/functional/utility-functions'; import { MOBILE } from 'state/build-time-config'; -import { RecordSetType } from 'interfaces/UserRecordSet'; +import { RecordSetType, UserRecordCacheStatus } from 'interfaces/UserRecordSet'; import VECTOR_MAP_FONT_FACE from 'constants/vectorMapFontFace'; +import { RecordCacheServiceFactory } from 'utils/record-cache/context'; const LAYER_ID_PREFIX = 'recordset-layer-'; /** DRY Handler for formatting LayerIDs */ const formatLayerID = (recordSetID: string, tableFiltersHash: string): string => `${LAYER_ID_PREFIX}${recordSetID}-hash-${tableFiltersHash}`; -export const createOfflineIappLayer = (map: maplibregl.Map, layer: any) => { - if (!layer?.layerState?.cacheMetadata?.hasOwnProperty('cachedGeoJson')) { +export const createOfflineIappLayer = async (map: maplibregl.Map, layer: any) => { + if (layer?.layerState?.cacheMetadataStatus !== UserRecordCacheStatus.CACHED || !layer.layerState.mapToggle) { + return; + } + const service = await RecordCacheServiceFactory.getPlatformInstance(); + const repo = await service.getRepository(layer.recordSetID); + + if (!repo?.cachedGeoJson) { return; } const layerID = formatLayerID(layer.recordSetID, layer.tableFiltersHash); - const source: GeoJSONSourceSpecification = layer.layerState.cacheMetadata.cachedGeoJson; + const source: GeoJSONSourceSpecification = repo.cachedGeoJson; const color: string = layer.layerState.color ?? FALLBACK_COLOR; const labelLayer: SymbolLayerSpecification = getLabelLayer(layerID, { color, minzoom: 10, get_tag: 'name' }); const circleLayer: CircleLayerSpecification = getCircleMarkerZoomedOutLayer(layerID, { color }); + + const existingSource = map.getSource(layerID); + if (existingSource) return; // Due to the async nature of the local DB Calls, check the layer wasn't created during a re-render map.addSource(layerID, source); map.addLayer(circleLayer, LAYER_Z_FOREGROUND); map.addLayer(labelLayer, LAYER_Z_FOREGROUND); @@ -177,20 +187,24 @@ const getLabelLayer = (layerID: string, options: LayerOptions): SymbolLayerSpeci * @desc Uses the device's recordset Cache data to display geoJson Layers on the map when offline * Displays two layers: Points at high levels, and shapes at lower */ -export const createOfflineActivityLayer = (map: maplibregl.Map, layer: any) => { - if ( - (['1', '2'].includes(layer.recordSetID) && !layer.layerState.colorScheme) || - !layer?.layerState?.cacheMetadata?.hasOwnProperty('cachedGeoJson') - ) { +export const createOfflineActivityLayer = async (map: maplibregl.Map, layer: any) => { + if (layer?.layerState?.cacheMetadataStatus !== UserRecordCacheStatus.CACHED || !layer.layerState.mapToggle) { + return; + } + const service = await RecordCacheServiceFactory.getPlatformInstance(); + const metadata = await service.getRepository(layer.recordSetID); + + if (!metadata?.cachedCentroid || !metadata?.cachedGeoJson) { return; } + const CENTROID_TO_GEOJSON_ZOOM = 12; const GEOJSON_ID = formatLayerID(layer.recordSetID, layer.tableFiltersHash); const CENTROID_ID = `${GEOJSON_ID}-centroid`; const color = getPaintBySchemeOrColor(layer); - const geoJsonSourcObj: GeoJSONSourceSpecification = layer.layerState.cacheMetadata.cachedGeoJson; - const centroidSourceObj: GeoJSONSourceSpecification = layer.layerState.cacheMetadata.cachedCentroid; + const geoJsonSourceObj: GeoJSONSourceSpecification = metadata.cachedGeoJson; + const centroidSourceObj: GeoJSONSourceSpecification = metadata.cachedCentroid; const circleMarkerZoomedOutLayerCentroid: CircleLayerSpecification = getCircleMarkerZoomedOutLayer(CENTROID_ID, { color, @@ -214,7 +228,9 @@ export const createOfflineActivityLayer = (map: maplibregl.Map, layer: any) => { get_tag: 'name' }); - map.addSource(GEOJSON_ID, geoJsonSourcObj); + const existingSource = map.getSource(GEOJSON_ID); + if (existingSource) return; // Due to the async nature of the local DB Calls, check the layer wasn't created during a re-render + map.addSource(GEOJSON_ID, geoJsonSourceObj); map.addLayer(fillLayer, LAYER_Z_FOREGROUND); map.addLayer(borderLayer, LAYER_Z_FOREGROUND); map.addLayer(circleMarkerZoomedOutLayer, LAYER_Z_FOREGROUND); @@ -375,13 +391,13 @@ export const rebuildLayersOnTableHashUpdate = ( }); // now update the layers that are in the store - storeLayers.map((layer: Record) => { + storeLayers.forEach(async (layer: Record) => { if ((layer.geoJSON && layer.loading === false) || (mode === 'VECTOR_ENDPOINT' && layer.filterObject)) { const sourceId = formatLayerID(layer.recordSetID, layer.tableFiltersHash); deleteStaleRecordsetLayer(map, layer); const existingSource = map.getSource(sourceId); if (existingSource) return; - createMapLayer(map, layer, mode, API_BASE, MOBILE_OFFLINE); + await createMapLayer(map, layer, mode, API_BASE, MOBILE_OFFLINE); } }); }; @@ -389,22 +405,22 @@ export const rebuildLayersOnTableHashUpdate = ( /** * @desc Handler logic for creating a new layer based on Network condition and recordset type */ -const createMapLayer = ( +const createMapLayer = async ( map: maplibregl.Map, layer: Record, mapMode: string, apiBase: string, isOfflineLayer: boolean -): void => { +): Promise => { if (layer.type === RecordSetType.Activity) { if (isOfflineLayer) { - createOfflineActivityLayer(map, layer); + await createOfflineActivityLayer(map, layer); } else { createOnlineActivityLayer(map, layer, mapMode, apiBase); } } else if (layer.type === RecordSetType.IAPP) { if (isOfflineLayer) { - createOfflineIappLayer(map, layer); + await createOfflineIappLayer(map, layer); } else { createOnlineIappLayer(map, layer, mapMode, apiBase); } @@ -470,20 +486,3 @@ export const refreshVisibilityOnToggleUpdate = (storeLayers, map: maplibregl.Map }); }); }; - -/** TODO: */ -export const removeDeletedRecordSetLayersOnRecordSetDelete = (storeLayers, map) => { - map.getLayersOrder().map((layer: any) => { - if ( - storeLayers.filter((l: any) => l.recordSetID === layer).length === 0 && - !['wms-test-layer', 'wms-test-layer2', 'invasives-vector', 'buildings'].includes(layer) - ) { - //map.current.removeLayer(layer); - //map.current.removeSource(layer); - } - }); - storeLayers.map((layer) => { - // get matching layers for type - // update visibility if doesn't match - }); -}; diff --git a/app/src/UI/Overlay/Records/RecordSet/Filter.tsx b/app/src/UI/Overlay/Records/RecordSet/Filter.tsx index bb4f8af8f..2d7a65f89 100644 --- a/app/src/UI/Overlay/Records/RecordSet/Filter.tsx +++ b/app/src/UI/Overlay/Records/RecordSet/Filter.tsx @@ -291,14 +291,16 @@ const Filter = ({ setID, id, userOfflineMobile }: PropTypes) => { {input} - + + + diff --git a/app/src/UI/Overlay/Records/RecordSet/RecordSet.tsx b/app/src/UI/Overlay/Records/RecordSet/RecordSet.tsx index 0aed27aef..6cd89b32e 100644 --- a/app/src/UI/Overlay/Records/RecordSet/RecordSet.tsx +++ b/app/src/UI/Overlay/Records/RecordSet/RecordSet.tsx @@ -58,53 +58,57 @@ export const RecordSet = (props) => {
- + + +
- + + +
@@ -112,27 +116,29 @@ export const RecordSet = (props) => { classes={{ tooltip: 'toolTip' }} title="Add a new filter, drawn, uploaded KML, or just text search on a field." > - + + +
diff --git a/app/src/UI/Overlay/Records/RecordSetCacheButtons.tsx b/app/src/UI/Overlay/Records/RecordSetCacheButtons.tsx index d1ca507cf..889a35529 100644 --- a/app/src/UI/Overlay/Records/RecordSetCacheButtons.tsx +++ b/app/src/UI/Overlay/Records/RecordSetCacheButtons.tsx @@ -18,7 +18,7 @@ const RecordSetCacheButtons = ({ recordSet, setId }: PropTypes) => { const handleClick = (e: MouseEvent) => { e.stopPropagation(); - switch (recordSet.cacheMetadata.status) { + switch (recordSet.cacheMetadataStatus) { case UserRecordCacheStatus.NOT_CACHED: downloadCache(); break; @@ -96,9 +96,9 @@ const RecordSetCacheButtons = ({ recordSet, setId }: PropTypes) => { UserRecordCacheStatus.NOT_CACHED, UserRecordCacheStatus.ERROR, UserRecordCacheStatus.DOWNLOADING - ].includes(recordSet.cacheMetadata?.status) + ].includes(recordSet.cacheMetadataStatus) ); - }, [recordSet.cacheMetadata?.status, connected]); + }, [recordSet.cacheMetadataStatus, connected]); return ( @@ -109,7 +109,7 @@ const RecordSetCacheButtons = ({ recordSet, setId }: PropTypes) => { onClick={handleClick} variant="outlined" > - {formatStatusKey(recordSet.cacheMetadata?.status)} + {formatStatusKey(recordSet.cacheMetadataStatus)} diff --git a/app/src/interfaces/UserRecordSet.ts b/app/src/interfaces/UserRecordSet.ts index 81e465daf..0bed9341a 100644 --- a/app/src/interfaces/UserRecordSet.ts +++ b/app/src/interfaces/UserRecordSet.ts @@ -1,6 +1,3 @@ -import { GeoJSONSourceSpecification } from 'maplibre-gl'; -import { RepositoryBoundingBoxSpec } from 'utils/tile-cache'; - export enum RecordSetType { IAPP = 'IAPP', Activity = 'Activity' @@ -32,11 +29,5 @@ export interface UserRecordSet { name: string; server_id: any; }; - cacheMetadata: { - status: UserRecordCacheStatus; - idList?: string[]; - cachedGeoJson?: GeoJSONSourceSpecification; - cachedCentroid?: GeoJSONSourceSpecification; - bbox?: RepositoryBoundingBoxSpec; - }; + cacheMetadataStatus: UserRecordCacheStatus; } diff --git a/app/src/main.tsx b/app/src/main.tsx index 5eeaa8a49..4e51e5180 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -10,6 +10,7 @@ import { TileCacheService } from 'utils/tile-cache'; import { Context, TileCacheServiceFactory } from 'utils/tile-cache/context'; import { MOBILE } from 'state/build-time-config'; import TileCache from 'state/actions/cache/TileCache'; +import { RecordCacheServiceFactory } from 'utils/record-cache/context'; if ('serviceWorker' in navigator) { navigator.serviceWorker.register(import.meta.env.MODE === 'production' ? '/worker.js' : '/dev-sw.js?dev-sw', { @@ -21,8 +22,10 @@ async function mountApp(CONFIG) { const { store, persistor } = setupStore(CONFIG); let tileCache: TileCacheService | null = null; + if (MOBILE) { tileCache = await TileCacheServiceFactory.getPlatformInstance(); + await RecordCacheServiceFactory.getPlatformInstance(); // load any caches present store.dispatch(TileCache.repositoryList()); } diff --git a/app/src/state/actions/cache/RecordCache.ts b/app/src/state/actions/cache/RecordCache.ts index 80758610c..41022046e 100644 --- a/app/src/state/actions/cache/RecordCache.ts +++ b/app/src/state/actions/cache/RecordCache.ts @@ -30,15 +30,14 @@ class RecordCache { ) => { const service = await RecordCacheServiceFactory.getPlatformInstance(); const state: RootState = getState() as RootState; - - const idsToCache: string[] = state.Map.layers - .find((l) => l.recordSetID == spec.setId) - .IDList.map((id: string | number) => id.toString()); + const idsToCache: string[] = + state.Map.layers.find((l) => l.recordSetID == spec.setId)?.IDList.map((id: string | number) => id.toString()) ?? + []; const recordSet = state.UserSettings.recordSets[spec.setId]; const bbox = await getBoundingBoxFromRecordsetFilters(recordSet); - const responseData = await service.downloadCache({ + const downloadCompleted = await service.download({ API_BASE: state.Configuration.current.API_BASE, bbox, idsToCache, @@ -46,14 +45,9 @@ class RecordCache { setId: spec.setId }); - // Will Refactor the current uses of Cache Metadata separately [Maintain only cache status] return { - status: UserRecordCacheStatus.CACHED, - idList: idsToCache, - bbox: bbox, setId: spec.setId, - cachedGeoJson: responseData.cachedGeoJson, - cachedCentroid: responseData.cachedCentroid + status: downloadCompleted ? UserRecordCacheStatus.CACHED : UserRecordCacheStatus.NOT_CACHED }; } ); diff --git a/app/src/state/actions/userSettings/RecordSet.ts b/app/src/state/actions/userSettings/RecordSet.ts index 956a49e33..563d1eeff 100644 --- a/app/src/state/actions/userSettings/RecordSet.ts +++ b/app/src/state/actions/userSettings/RecordSet.ts @@ -67,7 +67,7 @@ class RecordSet { if ( MOBILE && [UserRecordCacheStatus.CACHED, UserRecordCacheStatus.DOWNLOADING].includes( - recordSets[spec.setId]?.cacheMetadata?.status + recordSets[spec.setId].cacheMetadataStatus ) ) { const deletionResult = await thunkAPI.dispatch(RecordCache.deleteCache(spec)); @@ -99,9 +99,7 @@ class RecordSet { name: '', server_id: 0 }, - cacheMetadata: { - status: UserRecordCacheStatus.NOT_CACHED - } + cacheMetadataStatus: UserRecordCacheStatus.NOT_CACHED }); } diff --git a/app/src/state/reducers/alertsAndPrompts.ts b/app/src/state/reducers/alertsAndPrompts.ts index 30f2cbdf2..04c8ff8e7 100644 --- a/app/src/state/reducers/alertsAndPrompts.ts +++ b/app/src/state/reducers/alertsAndPrompts.ts @@ -6,6 +6,7 @@ import Prompt from 'state/actions/prompts/Prompt'; import { PromptAction } from 'interfaces/prompt-interfaces'; import RecordCache from 'state/actions/cache/RecordCache'; import cacheAlertMessages from 'constants/alerts/cacheAlerts'; +import { UserRecordCacheStatus } from 'interfaces/UserRecordSet'; interface AlertsAndPromptsState { alerts: AlertMessage[]; @@ -50,12 +51,13 @@ export function createAlertsAndPromptsReducer( } else if (RegExp(Prompt.NEW_PROMPT).exec(action.type)) { const newPrompt: PromptAction = action.payload; draftState.prompts = addPrompt(state.prompts, newPrompt); - } else if (RecordCache.requestCaching.fulfilled.match(action)) { + } else if ( + RecordCache.requestCaching.fulfilled.match(action) && + action.payload.status === UserRecordCacheStatus.CACHED + ) { draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheSuccess); } else if (RecordCache.requestCaching.rejected.match(action)) { - if (action?.error?.message !== 'Early Exit') { - draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheFailed); - } + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheFailed); } else if (RecordCache.deleteCache.rejected.match(action)) { draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetDeleteCacheFailed); } else if (RecordCache.stopDownload.fulfilled.match(action)) { diff --git a/app/src/state/reducers/map.ts b/app/src/state/reducers/map.ts index 4fbf687cc..481d13209 100644 --- a/app/src/state/reducers/map.ts +++ b/app/src/state/reducers/map.ts @@ -60,9 +60,9 @@ import { SortFilter } from 'interfaces/filterParams'; import TileCache from 'state/actions/cache/TileCache'; import MapActions from 'state/actions/map'; import GeoTracking from 'state/actions/geotracking/GeoTracking'; -import RecordCache from 'state/actions/cache/RecordCache'; import IappActions from 'state/actions/activity/Iapp'; import Activity from 'state/actions/activity/Activity'; +import RecordCache from 'state/actions/cache/RecordCache'; export enum LeafletWhosEditingEnum { ACTIVITY = 'ACTIVITY', @@ -417,9 +417,11 @@ function createMapReducer(configuration: AppConfig): (MapState, AnyAction) => Ma in) and builder.addCase instead of switches, although I assume you lose fallthrough cases then. */ return createNextState(state, (draftState: Draft) => { - if (UserSettings.RecordSet.remove.match(action)) { - const index = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload); - draftState.layers.splice(index, 1); + if (UserSettings.RecordSet.requestRemoval.fulfilled.match(action)) { + const index = draftState.layers.findIndex((layer) => layer.recordSetID === action.meta.arg.setId); + if (index !== -1) { + draftState.layers.splice(index, 1); + } } else if (UserSettings.RecordSet.set.match(action)) { const layerIndex = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload.setName); Object.keys(action.payload.updatedSet).forEach((key) => { @@ -471,14 +473,6 @@ function createMapReducer(configuration: AppConfig): (MapState, AnyAction) => Ma for (const r of removalList) { draftState.enabledOverlayLayers.splice(draftState.enabledOverlayLayers.indexOf(r), 1); } - } else if (RecordCache.requestCaching.fulfilled.match(action)) { - const index = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload.setId); - draftState.layers[index].layerState.cacheMetadata = { - ...draftState.layers[index].layerState.cacheMetadata, - cachedCentroid: action.payload.cachedCentroid, - cachedGeoJson: action.payload.cachedGeoJson, - bbox: action.payload.bbox - }; } else if (UserSettings.InitState.getSuccess.match(action)) { Object.keys(action.payload.recordSets).forEach((setID) => { let layerIndex = draftState.layers.findIndex((layer) => layer.recordSetID === setID); @@ -517,24 +511,28 @@ function createMapReducer(configuration: AppConfig): (MapState, AnyAction) => Ma page: 0 }); } else if (WhatsHere.server_filtered_ids_fetched.match(action)) { - draftState.whatsHere.serverActivityIDs = action.payload.activities; - draftState.whatsHere.serverIAPPIDs = action.payload.iapp; + const { iapp, activities } = action.payload; + draftState.whatsHere.serverActivityIDs = activities; + draftState.whatsHere.serverIAPPIDs = iapp; const toggledOnActivityLayers = draftState.layers.filter( ({ type, layerState }) => type === RecordSetType.Activity && layerState.mapToggle ); const toggledOnIAPPLayers = draftState.layers.filter( ({ type, layerState }) => type === RecordSetType.IAPP && layerState.mapToggle ); - const localActivityIDs = toggledOnActivityLayers.flatMap( - (layer) => layer.IDList ?? layer?.layerState?.cacheMetadata?.idList ?? [] - ); - const localIappIds = toggledOnIAPPLayers.flatMap( - (layer) => layer.IDList ?? layer?.layerState?.cacheMetadata?.idList ?? [] - ); - const iappIds = localIappIds.filter((l) => draftState.whatsHere.serverIAPPIDs.includes(l)); - const activityIds = localActivityIDs.filter((l) => draftState.whatsHere.serverActivityIDs.includes(l)); + + const localActivityIDs = toggledOnActivityLayers.flatMap((layer) => layer.IDList ?? []); + const localIappIds = toggledOnIAPPLayers.flatMap((layer) => layer.IDList ?? []); + + const iappIds = localIappIds.filter((l) => iapp.includes(l) || iapp.includes(l.toString())); + const activityIds = localActivityIDs.filter((l) => activities.includes(l)); draftState.whatsHere.ActivityIDs = Array.from(new Set(activityIds)); draftState.whatsHere.IAPPIDs = Array.from(new Set(iappIds)); + } else if (RecordCache.requestCaching.fulfilled.match(action)) { + const index = draftState.layers.findIndex((layer) => layer.recordSetID === action.meta.arg.setId); + if (index !== -1) { + draftState.layers[index].layerState.cacheMetadataStatus = action.payload.status; + } } else if (WhatsHere.sort_filter_update.match(action)) { const { field, direction } = action.payload; if (action.payload.type === RecordSetType.IAPP) { @@ -756,7 +754,7 @@ function createMapReducer(configuration: AppConfig): (MapState, AnyAction) => Ma case IAPP_GET_IDS_FOR_RECORDSET_REQUEST: { let index = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload.recordSetID); if (!draftState.layers[index]) { - draftState.layers.push({ recordSetID: action.payload.recordSetID, type: RecordSetType.Activity }); + draftState.layers.push({ recordSetID: action.payload.recordSetID, type: RecordSetType.IAPP }); index = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload.recordSetID); } draftState.layers[index].tableFiltersHash = action.payload.tableFiltersHash; @@ -812,8 +810,7 @@ function createMapReducer(configuration: AppConfig): (MapState, AnyAction) => Ma if (!draftState.layers[index]) { draftState.layers.push({ recordSetID: action.payload.recordSetID, - type: action.payload.recordSetType, - cacheMetadata: action.payload.cacheMetadata + type: action.payload.recordSetType }); } index = draftState.layers.findIndex((layer) => layer.recordSetID === action.payload.recordSetID); diff --git a/app/src/state/reducers/userSettings.ts b/app/src/state/reducers/userSettings.ts index 121e8c5ba..55e45e211 100644 --- a/app/src/state/reducers/userSettings.ts +++ b/app/src/state/reducers/userSettings.ts @@ -197,33 +197,15 @@ function createUserSettingsReducer(configuration: AppConfig): (UserSettingsState } else if (WhatsHere.toggle.match(action)) { draftState.recordsExpanded = action.payload ? false : draftState.recordsExpanded; } else if (RecordCache.requestCaching.pending.match(action)) { - draftState.recordSets[action.meta.arg.setId].cacheMetadata = { - status: UserRecordCacheStatus.DOWNLOADING - }; + draftState.recordSets[action.meta.arg.setId].cacheMetadataStatus = UserRecordCacheStatus.DOWNLOADING; } else if (RecordCache.requestCaching.rejected.match(action) || RecordCache.deleteCache.rejected.match(action)) { - if (action.error.message === 'Early Exit') { - draftState.recordSets[action.meta.arg.setId].cacheMetadata = { - status: UserRecordCacheStatus.NOT_CACHED - }; - } else { - draftState.recordSets[action.meta.arg.setId].cacheMetadata = { - status: UserRecordCacheStatus.ERROR - }; - } + draftState.recordSets[action.meta.arg.setId].cacheMetadataStatus = UserRecordCacheStatus.ERROR; } else if (RecordCache.requestCaching.fulfilled.match(action)) { - draftState.recordSets[action.meta.arg.setId].cacheMetadata = { - status: UserRecordCacheStatus.CACHED, - idList: action.payload.idList, - bbox: action.payload.bbox, - cachedGeoJson: action.payload.cachedGeoJson, - cachedCentroid: action.payload.cachedCentroid - }; + draftState.recordSets[action.meta.arg.setId].cacheMetadataStatus = action.payload.status; } else if (RecordCache.deleteCache.pending.match(action) || RecordCache.stopDownload.pending.match(action)) { - draftState.recordSets[action.meta.arg.setId].cacheMetadata.status = UserRecordCacheStatus.DELETING; + draftState.recordSets[action.meta.arg.setId].cacheMetadataStatus = UserRecordCacheStatus.DELETING; } else if (RecordCache.deleteCache.fulfilled.match(action)) { - draftState.recordSets[action.meta.arg.setId].cacheMetadata = { - status: UserRecordCacheStatus.NOT_CACHED - }; + draftState.recordSets[action.meta.arg.setId].cacheMetadataStatus = UserRecordCacheStatus.NOT_CACHED; } else if (Activity.deleteSuccess.match(action)) { draftState.activeActivity = null; draftState.activeActivityDescription = null; diff --git a/app/src/state/sagas/activity/offline.ts b/app/src/state/sagas/activity/offline.ts index 695f49d33..12550b135 100644 --- a/app/src/state/sagas/activity/offline.ts +++ b/app/src/state/sagas/activity/offline.ts @@ -74,7 +74,7 @@ export function* handle_ACTIVITY_GET_LOCAL_REQUEST(action: PayloadAction } } else { try { - const service: RecordCacheService = yield RecordCacheServiceFactory.getPlatformInstance(); + const service = yield RecordCacheServiceFactory.getPlatformInstance(); const result = yield service.loadActivity(activityID); const datav2 = { diff --git a/app/src/state/sagas/iappsite/dataAccess.ts b/app/src/state/sagas/iappsite/dataAccess.ts index 1ef408a19..561dc0ccb 100644 --- a/app/src/state/sagas/iappsite/dataAccess.ts +++ b/app/src/state/sagas/iappsite/dataAccess.ts @@ -15,7 +15,7 @@ export function* handle_IAPP_GET_REQUEST(iappId: PayloadAction) { try { const connected = yield select(selectNetworkConnected); if (MOBILE && !connected) { - const service: RecordCacheService = yield RecordCacheServiceFactory.getPlatformInstance(); + const service = yield RecordCacheServiceFactory.getPlatformInstance(); const result = yield service.loadIapp(iappId.payload, IappRecordMode.Record); yield put(IappActions.getSuccess(result)); } else { diff --git a/app/src/state/sagas/map.ts b/app/src/state/sagas/map.ts index 8374c6bf4..f08280b77 100644 --- a/app/src/state/sagas/map.ts +++ b/app/src/state/sagas/map.ts @@ -68,7 +68,7 @@ import { InvasivesAPI_Call } from 'hooks/useInvasivesApi'; import { TRACKING_SAGA_HANDLERS } from 'state/sagas/map/tracking'; import WhatsHere from 'state/actions/whatsHere/WhatsHere'; import Prompt from 'state/actions/prompts/Prompt'; -import { RecordSetType, UserRecordCacheStatus, UserRecordSet } from 'interfaces/UserRecordSet'; +import { RecordSetType, UserRecordCacheStatus } from 'interfaces/UserRecordSet'; import UserSettings from 'state/actions/userSettings/UserSettings'; import { SortFilter } from 'interfaces/filterParams'; import Activity from 'state/actions/activity/Activity'; @@ -84,6 +84,8 @@ import { RecordCacheServiceFactory } from 'utils/record-cache/context'; import bboxToPolygon from 'utils/bboxToPolygon'; import IappActions from 'state/actions/activity/Iapp'; import IappRecord from 'interfaces/IappRecord'; +import { RepositoryMetadata } from 'utils/record-cache'; +import NetworkActions from 'state/actions/network/NetworkActions'; function* handle_USER_SETTINGS_GET_INITIAL_STATE_SUCCESS(action) { yield put({ type: MAP_INIT_REQUEST, payload: {} }); @@ -158,15 +160,21 @@ function* handle_WHATS_HERE_FEATURE(whatsHereFeature: PayloadAction) { yield put(WhatsHere.server_filtered_ids_fetched(activitiesServerIDList, iappServerIDList)); } else { // Get IDs from Offline Caches - const { recordSets } = yield select(selectUserSettings); - const recordSetsInBoundingBox = Object.keys(recordSets).filter((set) => { - const { bbox, status } = recordSets[set].cacheMetadata; - const recordSetIsCached = status === UserRecordCacheStatus.CACHED; - return recordSetIsCached && bbox && booleanIntersects(whatsHereFeature.payload, bboxToPolygon(bbox)); + const service = yield RecordCacheServiceFactory.getPlatformInstance(); + const repos = yield service.listRepositories(); + + const recordSetsInBoundingBox = repos.filter((repo: RepositoryMetadata) => { + const { status, bbox } = repo; + return ( + status === UserRecordCacheStatus.CACHED && + bbox && + booleanIntersects(whatsHereFeature.payload, bboxToPolygon(bbox as any)) + ); }); + const overlappingRecords: string[] = []; recordSetsInBoundingBox.flatMap((set) => - recordSets[set].cacheMetadata.cachedGeoJson.data.features.forEach((shape: Feature) => { + set.cachedGeoJson.data.features.forEach((shape: Feature) => { if (booleanIntersects(whatsHereFeature.payload, shape)) { overlappingRecords.push(shape?.properties?.description); } @@ -215,7 +223,7 @@ function* handle_WHATS_HERE_IAPP_ROWS_REQUEST() { let records: IappRecord[]; if (MOBILE && !connected) { const service = yield RecordCacheServiceFactory.getPlatformInstance(); - records = yield service.fetchPaginatedCachedIappRecords( + records = yield service.getPaginatedCachedIappRecords( whatsHere.IAPPIDs.map((id) => id.toString()), whatsHere.IAPPPage, whatsHere.IAPPLimit @@ -305,7 +313,7 @@ function* handle_WHATS_HERE_ACTIVITY_ROWS_REQUEST() { let records: UserRecord[]; if (MOBILE && !connected) { const service = yield RecordCacheServiceFactory.getPlatformInstance(); - records = yield service.fetchPaginatedCachedRecords( + records = yield service.getPaginatedCachedActivityRecords( whatsHere.ActivityIDs, whatsHere.ActivityPage, whatsHere.ActivityLimit @@ -631,7 +639,7 @@ function* handle_PAGE_OR_LIMIT_UPDATE(action) { } } -function* handle_MAP_INIT_FOR_RECORDSETS(action: PayloadAction) { +function* handle_MAP_INIT_FOR_RECORDSETS() { interface ActionType { type: string; payload: any; @@ -657,7 +665,6 @@ function* handle_MAP_INIT_FOR_RECORDSETS(action: PayloadAction) { const newUninitializedLayers = newLayerIDs.map((layer) => { return { recordSetID: layer, recordSetType: userSettingsState.recordSets[layer].recordSetType }; }); - // combined: const allUninitializedLayers = [...currentUninitializedLayers, ...newUninitializedLayers]; @@ -724,14 +731,14 @@ function* handle_REMOVE_SERVER_BOUNDARY(action) { yield put(UserSettings.KML.delete(action.payload.id)); } -function* handle_DRAW_CUSTOM_LAYER(action) { +function* handle_DRAW_CUSTOM_LAYER() { const panelState = yield select((state) => state.AppMode.panelOpen); if (panelState) { yield put({ type: TOGGLE_PANEL }); } } -function* handle_CUSTOM_LAYER_DRAWN(actions) { +function* handle_CUSTOM_LAYER_DRAWN() { const panelState = yield select((state) => state.AppMode.panelOpen); if (!panelState) { yield put({ type: TOGGLE_PANEL }); @@ -774,7 +781,7 @@ function* handle_MAP_ON_SHAPE_UPDATE(action) { } } -function handle_MAP_TOGGLE_GEOJSON_CACHE(action) { +function handle_MAP_TOGGLE_GEOJSON_CACHE() { location.reload(); } @@ -829,10 +836,13 @@ function* activitiesPageSaga() { takeEvery(DRAW_CUSTOM_LAYER, handle_DRAW_CUSTOM_LAYER), takeEvery(CUSTOM_LAYER_DRAWN, handle_CUSTOM_LAYER_DRAWN), + //Conditions where we may want to redraw the Map layers, fetch IDLists, so on + takeEvery(NetworkActions.online, handle_MAP_INIT_FOR_RECORDSETS), + takeEvery(UserSettings.RecordSet.add, handle_MAP_INIT_FOR_RECORDSETS), + takeEvery(MAP_INIT_FOR_RECORDSET, handle_MAP_INIT_FOR_RECORDSETS), + takeEvery(REFETCH_SERVER_BOUNDARIES, refetchServerBoundaries), takeEvery(WhatsHere.server_filtered_ids_fetched, handle_WHATS_HERE_SERVER_FILTERED_IDS_FETCHED), - - takeEvery(UserSettings.RecordSet.add, handle_MAP_INIT_FOR_RECORDSETS), takeEvery(UserSettings.RecordSet.cycleColourById, handle_RECORDSET_ROTATE_COLOUR), takeEvery(UserSettings.RecordSet.toggleVisibility, handle_RECORDSET_TOGGLE_VISIBILITY), takeEvery(UserSettings.RecordSet.toggleLabelVisibility, handle_RECORDSET_TOGGLE_LABEL_VISIBILITY), @@ -841,7 +851,6 @@ function* activitiesPageSaga() { takeEvery(MAP_TOGGLE_GEOJSON_CACHE, handle_MAP_TOGGLE_GEOJSON_CACHE), takeEvery(UserSettings.InitState.getSuccess, handle_USER_SETTINGS_GET_INITIAL_STATE_SUCCESS), takeEvery(MAP_INIT_REQUEST, handle_MAP_INIT_REQUEST), - takeEvery(MAP_INIT_FOR_RECORDSET, handle_MAP_INIT_FOR_RECORDSETS), takeEvery(Activity.GeoJson.get, handle_ACTIVITIES_GEOJSON_GET_REQUEST), takeEvery(ACTIVITIES_GEOJSON_REFETCH_ONLINE, handle_ACTIVITIES_GEOJSON_REFETCH_ONLINE), takeEvery(IAPP_GEOJSON_GET_REQUEST, handle_IAPP_GEOJSON_GET_REQUEST), diff --git a/app/src/state/sagas/map/dataAccess.ts b/app/src/state/sagas/map/dataAccess.ts index c14969d6b..c1b19e6c3 100644 --- a/app/src/state/sagas/map/dataAccess.ts +++ b/app/src/state/sagas/map/dataAccess.ts @@ -11,11 +11,12 @@ import { FILTERS_PREPPED_FOR_VECTOR_ENDPOINT, IAPP_GEOJSON_GET_ONLINE, IAPP_GEOJSON_GET_SUCCESS, - IAPP_GET_IDS_FOR_RECORDSET_ONLINE + IAPP_GET_IDS_FOR_RECORDSET_ONLINE, + IAPP_GET_IDS_FOR_RECORDSET_SUCCESS } from 'state/actions'; import { ACTIVITY_GEOJSON_SOURCE_KEYS, selectMap } from 'state/reducers/map'; import WhatsHere from 'state/actions/whatsHere/WhatsHere'; -import { RecordSetType, UserRecordSet } from 'interfaces/UserRecordSet'; +import { RecordSetType, UserRecordSet, UserRecordCacheStatus } from 'interfaces/UserRecordSet'; import { MOBILE } from 'state/build-time-config'; import { RecordCacheServiceFactory } from 'utils/record-cache/context'; import GeoShapes from 'constants/geoShapes'; @@ -77,16 +78,14 @@ export function* handle_PREP_FILTERS_FOR_VECTOR_ENDPOINT(action) { return; } - yield put({ - type: FILTERS_PREPPED_FOR_VECTOR_ENDPOINT, - payload: { - filterObject: filterObject, - recordSetID: action.payload.recordSetID, - tableFiltersHash: action.payload.tableFiltersHash, - recordSetType: recordset.recordSetType, - cacheMetadata: recordset.cacheMetadata ?? null - } - }); + const payload = { + filterObject: filterObject, + recordSetID: action.payload.recordSetID, + tableFiltersHash: action.payload.tableFiltersHash, + recordSetType: recordset.recordSetType + }; + + yield put({ type: FILTERS_PREPPED_FOR_VECTOR_ENDPOINT, payload }); } catch (e) { console.error(e); throw e; @@ -115,12 +114,15 @@ export function* handle_ACTIVITIES_GET_IDS_FOR_RECORDSET_REQUEST(action) { }); } else { const recordSet = currentState.recordSets[action.payload.recordSetID] ?? null; - if (recordSet?.cacheMetadata?.idList) { + if (recordSet.cacheMetadataStatus === UserRecordCacheStatus.CACHED) { + const service = yield RecordCacheServiceFactory.getPlatformInstance(); + const ids = yield service.getIdList(action.payload.recordSetID); + yield put({ type: ACTIVITIES_GET_IDS_FOR_RECORDSET_SUCCESS, payload: { recordSetID: action.payload.recordSetID, - IDList: recordSet.cacheMetadata.idList ?? [], + IDList: ids ?? [], tableFiltersHash: action.payload.tableFiltersHash } }); @@ -155,6 +157,19 @@ export function* handle_IAPP_GET_IDS_FOR_RECORDSET_REQUEST(action) { tableFiltersHash: action.payload.tableFiltersHash } }); + } else { + const service = yield RecordCacheServiceFactory.getPlatformInstance(); + if (yield service.isCached(action.payload.recordSetID)) { + const ids = yield service.getIdList(action.payload.recordSetID); + yield put({ + type: IAPP_GET_IDS_FOR_RECORDSET_SUCCESS, + payload: { + recordSetID: action.payload.recordSetID, + IDList: ids, + tableFiltersHash: action.payload.tableFiltersHash + } + }); + } } } catch (e) { console.error(e); @@ -221,11 +236,9 @@ export function* handle_ACTIVITIES_TABLE_ROWS_GET_REQUEST(action) { } if (userMobileOffline) { - const recordSetIdList = yield select( - (state) => state.UserSettings.recordSets[recordSetID].cacheMetadata.idList ?? [] - ); const service = yield RecordCacheServiceFactory.getPlatformInstance(); - const records = yield service.fetchPaginatedCachedRecords(recordSetIdList, page, limit); + const recordSetIdList = yield service.getIdList(recordSetID); + const records = yield service.getPaginatedCachedActivityRecords(recordSetIdList, page, limit); yield put( Activity.getRowsSuccess({ recordSetID: recordSetID, @@ -275,9 +288,9 @@ export function* handle_IAPP_TABLE_ROWS_GET_REQUEST(action: PayloadAction + */ +abstract class BaseCacheService< + RepoMetadata, + RepositoryDownloadRequestSpec, + ProgressCallbackParams, + RepositoryStatusSchema +> { + /** Update any details for a Repository */ + protected abstract addOrUpdateRepository(repositoryId: RepoMetadata): Promise; + + /** Remove one Repository from the collection along with its contentes */ + public abstract deleteRepository(repositoryId: string): Promise; + + /** Pull metadata for one Repository in the Collection */ + public abstract getRepository(repositoryId: string): Promise; + + /** List metadata for all Repositories */ + public abstract listRepositories(): Promise; + + /** Change the status for a given Repository */ + public abstract setRepositoryStatus(repositoryId: string, status: RepositoryStatusSchema): Promise; + + /** Download a new Repository along with its contents */ + public abstract download( + spec: RepositoryDownloadRequestSpec, + progressCallback?: (currentProgress: ProgressCallbackParams) => void + ): Promise; + + protected constructor() {} +} + +export default BaseCacheService; diff --git a/app/src/utils/filterRecordsetsByNetworkState.ts b/app/src/utils/filterRecordsetsByNetworkState.ts index 77d62f15e..f9df48df9 100644 --- a/app/src/utils/filterRecordsetsByNetworkState.ts +++ b/app/src/utils/filterRecordsetsByNetworkState.ts @@ -8,7 +8,7 @@ import { UserRecordCacheStatus, UserRecordSet } from 'interfaces/UserRecordSet'; */ const filterRecordsetsByNetworkState = (recordSets: Record, userOffline: boolean): string[] => Object.keys(recordSets).filter((set) => { - return !userOffline || recordSets[set].cacheMetadata.status === UserRecordCacheStatus.CACHED; + return !userOffline || recordSets[set].cacheMetadataStatus === UserRecordCacheStatus.CACHED; }); export default filterRecordsetsByNetworkState; diff --git a/app/src/utils/getBoundingBoxFromRecordsetFilters.ts b/app/src/utils/getBoundingBoxFromRecordsetFilters.ts index 2083d9b51..03d9c58b2 100644 --- a/app/src/utils/getBoundingBoxFromRecordsetFilters.ts +++ b/app/src/utils/getBoundingBoxFromRecordsetFilters.ts @@ -1,5 +1,5 @@ import bbox from '@turf/bbox'; -import { UserRecordSet } from 'interfaces/UserRecordSet'; +import { RecordSetType, UserRecordSet } from 'interfaces/UserRecordSet'; import { getCurrentJWT } from 'state/sagas/auth/auth'; import { getSelectColumnsByRecordSetType } from 'state/sagas/map/dataAccess'; import { parse } from 'wkt'; @@ -9,13 +9,16 @@ const getBoundingBoxFromRecordsetFilters = async (recordSet: UserRecordSet): Pro const { recordSetType } = recordSet; const filterObj = { recordSetType: recordSetType, - sortColumn: 'short_id', + sortColumn: recordSetType === RecordSetType.Activity ? 'short_id' : 'site_id', sortOrder: 'DESC', tableFilters: recordSet.tableFilters, selectColumns: getSelectColumnsByRecordSetType(recordSetType) }; - - const data = await fetch(`${CONFIGURATION_API_BASE}/api/v2/activities/bbox`, { + const url = + recordSetType === RecordSetType.Activity + ? `${CONFIGURATION_API_BASE}/api/v2/activities/bbox` + : `${CONFIGURATION_API_BASE}/api/v2/IAPP/bbox`; + const data = await fetch(url, { method: 'POST', headers: { Authorization: await getCurrentJWT(), @@ -26,10 +29,10 @@ const getBoundingBoxFromRecordsetFilters = async (recordSet: UserRecordSet): Pro const [minLongitude, minLatitude, maxLongitude, maxLatitude] = bbox(parse(data.bbox)); return { - minLatitude: minLatitude, - maxLatitude: maxLongitude, - minLongitude: minLongitude, - maxLongitude: maxLatitude + minLatitude, + maxLatitude, + minLongitude, + maxLongitude }; }; diff --git a/app/src/utils/record-cache/context.ts b/app/src/utils/record-cache/context.ts index c774bd903..ebeee2cea 100644 --- a/app/src/utils/record-cache/context.ts +++ b/app/src/utils/record-cache/context.ts @@ -1,9 +1,10 @@ import { Platform, PLATFORM } from 'state/build-time-config'; +import { RecordCacheService } from 'utils/record-cache/index'; import { SQLiteRecordCacheService } from 'utils/record-cache/sqlite-cache'; import { LocalForageRecordCacheService } from 'utils/record-cache/localforage-cache'; class RecordCacheServiceFactory { - static async getPlatformInstance() { + static async getPlatformInstance(): Promise { if (PLATFORM == Platform.IOS) { return SQLiteRecordCacheService.getInstance(); } diff --git a/app/src/utils/record-cache/index.ts b/app/src/utils/record-cache/index.ts index 7978a72c2..9b0c91332 100644 --- a/app/src/utils/record-cache/index.ts +++ b/app/src/utils/record-cache/index.ts @@ -5,6 +5,7 @@ import { RecordSetType, UserRecordCacheStatus } from 'interfaces/UserRecordSet'; import { GeoJSONSourceSpecification } from 'maplibre-gl'; import { getCurrentJWT } from 'state/sagas/auth/auth'; import { getSelectColumnsByRecordSetType } from 'state/sagas/map/dataAccess'; +import BaseCacheService from 'utils/base-classes/BaseCacheService'; import { RepositoryBoundingBoxSpec } from 'utils/tile-cache'; export enum IappRecordMode { @@ -25,7 +26,7 @@ export interface RecordCacheDownloadRequestSpec { * @property { GeoJSONSourceSpecification } cachedCentroid Cached Points for high map layers * @property { UserRecordCacheStatus } status Cache Status. */ -export interface RecordCacheAddSpec { +export interface RepositoryMetadata { setId: string; cacheTime: Date; cachedIds: string[]; @@ -58,47 +59,57 @@ export interface CacheDownloadSpec { recordSetType: RecordSetType; } -abstract class RecordCacheService { +abstract class RecordCacheService extends BaseCacheService< + RepositoryMetadata, + CacheDownloadSpec, + RecordCacheProgressCallbackParameters, + UserRecordCacheStatus +> { private readonly RECORDS_BETWEEN_PROGRESS_UPDATES = 25; - protected constructor() {} + + protected constructor() { + super(); + } static async getInstance(): Promise { throw new Error('unimplemented in abstract base class'); } + protected abstract addOrUpdateRepository(spec: RepositoryMetadata): Promise; - abstract saveActivity(id: string, data: unknown): Promise; + protected abstract deleteCachedRecordsFromIds(idsToDelete: string[], recordSetType: RecordSetType): Promise; - abstract saveIapp(id: string, iappRecord: unknown, iappTableRow: unknown): Promise; + /** */ + public abstract loadActivity(id: string): Promise; - abstract deleteCachedRecordsFromIds(idsToDelete: string[], recordSetType: RecordSetType): Promise; + public abstract loadIapp(id: string, type: IappRecordMode): Promise; - abstract loadActivity(id: string): Promise; + protected abstract saveActivity(id: string, data: unknown): Promise; - abstract loadIapp(id: string, type: IappRecordMode): Promise; + protected abstract saveIapp(id: string, iappRecord: unknown, iappTableRow: unknown): Promise; - abstract fetchPaginatedCachedIappRecords( + public abstract getPaginatedCachedActivityRecords( recordSetIdList: string[], page: number, limit: number - ): Promise; - - abstract fetchPaginatedCachedRecords(recordSetIdList: string[], page: number, limit: number): Promise; - - abstract addOrUpdateRepository(spec: RecordCacheAddSpec): Promise; + ): Promise; - abstract deleteRepository(repositoryId: string): Promise; + public abstract getPaginatedCachedIappRecords( + recordSetIdList: string[], + page: number, + limit: number + ): Promise; - abstract listRepositories(): Promise; + public abstract isCached(repositoryId: string): Promise; - abstract loadIappRecordsetSourceMetadata(ids: string[]): Promise; + public abstract getIdList(repositoryId: string): Promise; - abstract loadRecordsetSourceMetadata(ids: string[]): Promise; + protected abstract createIappRecordsetSourceMetadata(ids: string[]): Promise; - abstract setRepositoryStatus(repositoryId: string, status: UserRecordCacheStatus): Promise; + protected abstract createActivityRecordsetSourceMetadata(ids: string[]): Promise; abstract checkForAbort(id: string): Promise; - async downloadCache(spec: CacheDownloadSpec): Promise> { + public async download(spec: CacheDownloadSpec): Promise { const args = { idsToCache: spec.idsToCache, setId: spec.setId, @@ -119,33 +130,35 @@ abstract class RecordCacheService { bbox: spec.bbox }); + let downloadCompleted = true; if (spec.recordSetType === RecordSetType.Activity && (await this.downloadActivity(args))) { - Object.assign(responseData, await this.loadRecordsetSourceMetadata(spec.idsToCache)); + Object.assign(responseData, await this.createActivityRecordsetSourceMetadata(spec.idsToCache)); } else if (spec.recordSetType === RecordSetType.IAPP && (await this.downloadIapp(args))) { - Object.assign(responseData, await this.loadIappRecordsetSourceMetadata(spec.idsToCache)); + Object.assign(responseData, await this.createIappRecordsetSourceMetadata(spec.idsToCache)); } else { + downloadCompleted = false; this.deleteRepository(spec.setId); - throw Error('Early Exit'); } - await this.addOrUpdateRepository({ - setId: spec.setId, - cacheTime: new Date(), - cachedIds: spec.idsToCache, - recordSetType: spec.recordSetType, - status: UserRecordCacheStatus.CACHED, - cachedGeoJson: responseData.cachedGeoJson, - cachedCentroid: responseData.cachedCentroid, - bbox: spec.bbox - }); - - return responseData; + if (downloadCompleted) { + await this.addOrUpdateRepository({ + setId: spec.setId, + cacheTime: new Date(), + cachedIds: spec.idsToCache, + recordSetType: spec.recordSetType, + status: UserRecordCacheStatus.CACHED, + cachedGeoJson: responseData.cachedGeoJson, + cachedCentroid: responseData.cachedCentroid, + bbox: spec.bbox + }); + } + return downloadCompleted; } /** * Download Records for IAPP Given a list of IDs * @returns { boolean } download was successful */ - async downloadIapp( + private async downloadIapp( spec: RecordCacheDownloadRequestSpec, progressCallback?: (currentProgress: RecordCacheProgressCallbackParameters) => void ): Promise { @@ -195,7 +208,7 @@ abstract class RecordCacheService { * Download Records for Activities Given a list of IDs * @returns { boolean } download was successful */ - async downloadActivity( + private async downloadActivity( spec: RecordCacheDownloadRequestSpec, progressCallback?: (currentProgress: RecordCacheProgressCallbackParameters) => void ): Promise { @@ -216,7 +229,7 @@ abstract class RecordCacheService { } return !abort; } - async stopDownload(repositoryId: string): Promise { + public async stopDownload(repositoryId: string): Promise { const repositories = await this.listRepositories(); const foundIndex = repositories.findIndex((repo) => repo.setId === repositoryId); if (foundIndex === -1) throw Error(`Repository ${repositoryId} wasn't found`); diff --git a/app/src/utils/record-cache/localforage-cache.ts b/app/src/utils/record-cache/localforage-cache.ts index bdb4c3172..6ba5efb5f 100644 --- a/app/src/utils/record-cache/localforage-cache.ts +++ b/app/src/utils/record-cache/localforage-cache.ts @@ -3,7 +3,7 @@ import localForage from 'localforage'; import centroid from '@turf/centroid'; import { IappRecordMode, - RecordCacheAddSpec, + RepositoryMetadata, RecordCacheService, RecordSetSourceMetadata } from 'utils/record-cache/index'; @@ -32,6 +32,26 @@ class LocalForageRecordCacheService extends RecordCacheService { return LocalForageRecordCacheService._instance; } + async isCached(repositoryId: string): Promise { + try { + return (await this.getRepository(repositoryId)).status === UserRecordCacheStatus.CACHED; + } catch (e) { + return false; + } + } + + async getRepository(repositoryId: string): Promise { + const repos = await this.listRepositories(); + const foundIndex = repos.findIndex((p) => p.setId === repositoryId); + if (foundIndex === -1) throw Error(`Repository ${repositoryId} not found`); + + return repos[foundIndex]; + } + + async getIdList(repositoryId: string): Promise { + return (await this.getRepository(repositoryId)).cachedIds ?? []; + } + async saveActivity(id: string, data: unknown): Promise { if (this.store == null) { throw new Error('cache not available'); @@ -60,6 +80,7 @@ class LocalForageRecordCacheService extends RecordCacheService { } return true; } + async saveIapp(id: string, iappRecord: IappRecord, iappTableRow: IappTableRow): Promise { if (this.store == null) { throw new Error('cache not available'); @@ -79,7 +100,7 @@ class LocalForageRecordCacheService extends RecordCacheService { return data[type]; } - async fetchPaginatedCachedIappRecords( + async getPaginatedCachedIappRecords( recordSetIdList: string[], page: number, limit: number, @@ -119,7 +140,11 @@ class LocalForageRecordCacheService extends RecordCacheService { * @param limit Maximum results per page * @returns { UserRecord[] } Filter Objects */ - async fetchPaginatedCachedRecords(recordSetIdList: string[], page: number, limit: number): Promise { + async getPaginatedCachedActivityRecords( + recordSetIdList: string[], + page: number, + limit: number + ): Promise { if (recordSetIdList?.length === 0) { return []; } @@ -139,7 +164,7 @@ class LocalForageRecordCacheService extends RecordCacheService { * @param ids ids to filter * @returns { RecordSetSourceMetadata } Returns cached GeoJson, all IAPP Sites are Points. */ - async loadIappRecordsetSourceMetadata(ids: string[]): Promise { + async createIappRecordsetSourceMetadata(ids: string[]): Promise { const geoJsonArr: any[] = []; for (const id of ids) { const data: IappRecord = await this.loadIapp(id, IappRecordMode.Row); @@ -163,7 +188,7 @@ class LocalForageRecordCacheService extends RecordCacheService { * @param ids ids to filter * @returns { RecordSetSourceMetadata } Two formatted queries for High/Low zoom layers */ - async loadRecordsetSourceMetadata(ids: string[]): Promise { + async createActivityRecordsetSourceMetadata(ids: string[]): Promise { const centroidArr: any[] = []; const geoJsonArr: any[] = []; @@ -237,7 +262,7 @@ class LocalForageRecordCacheService extends RecordCacheService { * @desc Create or Update an entry in the cachedSet Repository * @param newSet Data to update */ - async addOrUpdateRepository(newSet: RecordCacheAddSpec): Promise { + async addOrUpdateRepository(newSet: RepositoryMetadata): Promise { if (this.store == null) { throw new Error('cache not available'); } @@ -253,12 +278,12 @@ class LocalForageRecordCacheService extends RecordCacheService { await this.store.setItem(LocalForageRecordCacheService.CACHED_SETS_METADATA_KEY, cachedSets); } - async listRepositories(): Promise { + async listRepositories(): Promise { if (this.store == null) { return []; } - const metadata: RecordCacheAddSpec[] = + const metadata: RepositoryMetadata[] = (await this.store.getItem(LocalForageRecordCacheService.CACHED_SETS_METADATA_KEY)) ?? []; if (metadata == null) { console.error('expected key not found'); diff --git a/app/src/utils/record-cache/sqlite-cache.ts b/app/src/utils/record-cache/sqlite-cache.ts index f4d8fc35b..69b29c742 100644 --- a/app/src/utils/record-cache/sqlite-cache.ts +++ b/app/src/utils/record-cache/sqlite-cache.ts @@ -8,7 +8,7 @@ import { RecordSetType, UserRecordCacheStatus } from 'interfaces/UserRecordSet'; import { GeoJSONSourceSpecification } from 'maplibre-gl'; import { IappRecordMode, - RecordCacheAddSpec, + RepositoryMetadata, RecordCacheService, RecordSetSourceMetadata } from 'utils/record-cache/index'; @@ -64,7 +64,6 @@ class SQLiteRecordCacheService extends RecordCacheService { private static _instance: SQLiteRecordCacheService; private cacheDB: SQLiteDBConnection | null = null; - protected constructor() { super(); } @@ -77,7 +76,7 @@ class SQLiteRecordCacheService extends RecordCacheService { return SQLiteRecordCacheService._instance; } - async addOrUpdateRepository(spec: RecordCacheAddSpec): Promise { + async addOrUpdateRepository(spec: RepositoryMetadata): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); } @@ -98,6 +97,44 @@ class SQLiteRecordCacheService extends RecordCacheService { } } + async getRepository(repositoryId: string): Promise { + if (this.cacheDB == null) { + throw new Error(CACHE_UNAVAILABLE); + } + const repoData = await this.cacheDB.query( + //language=SQLite + `SELECT DATA + FROM CACHE_METADATA + WHERE SET_ID = ? + LIMIT 1`, + [repositoryId] + ); + return JSON.parse(repoData?.values?.[0]['DATA']) ?? {}; + } + + async isCached(repositoryId: string): Promise { + if (this.cacheDB == null) { + throw new Error(CACHE_UNAVAILABLE); + } + const metadata = await this.cacheDB.query( + //language=SQLite + `SELECT STATUS + FROM CACHE_METADATA + WHERE SET_ID = ? + LIMIT 1 + `, + [repositoryId] + ); + return metadata?.values?.[0]?.['STATUS'] === UserRecordCacheStatus.CACHED; + } + + async getIdList(repositoryId: string): Promise { + if (this.cacheDB == null) { + throw Error(CACHE_UNAVAILABLE); + } + return (await this.getRepository(repositoryId)).cachedIds ?? []; + } + async deleteRepository(repositoryId: string): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); @@ -107,11 +144,11 @@ class SQLiteRecordCacheService extends RecordCacheService { `SELECT DATA FROM CACHE_METADATA` ); - const repositoryMetadata: RecordCacheAddSpec[] = + const repositoryMetadata: RepositoryMetadata[] = rawRepositoryMetadata?.values?.map((set) => JSON.parse(set['DATA'])) ?? []; const targetIndex = repositoryMetadata.findIndex((set) => set.setId === repositoryId); - if (targetIndex === -1) throw Error('Repository not found'); + if (targetIndex === -1) return; const { cachedIds, recordSetType } = repositoryMetadata[targetIndex]; @@ -133,50 +170,32 @@ class SQLiteRecordCacheService extends RecordCacheService { ); } - async listRepositories(): Promise { + async listRepositories(): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); } const repositories = await this.cacheDB.query( - //language=SQLite - `SELECT * - FROM CACHE_METADATA` - ); - return repositories?.values?.map((entry) => JSON.parse(entry['DATA']) as RecordCacheAddSpec) ?? []; - } - - /** - * @desc Helper method to fetch and parse repo metadata - */ - private async getRepoData(repositoryId: string) { - if (this.cacheDB == null) { - throw new Error(CACHE_UNAVAILABLE); - } - const repoData = await this.cacheDB.query( //language=SQLite `SELECT DATA - FROM CACHE_METADATA - WHERE SET_ID = ?`, - [repositoryId] + FROM CACHE_METADATA` ); - return JSON.parse(repoData?.values?.[0]['DATA']) ?? {}; + const response = repositories?.values?.map((entry) => JSON.parse(entry['DATA']) as RepositoryMetadata) ?? []; + return response; } async setRepositoryStatus(repositoryId: string, status: UserRecordCacheStatus): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); } - const currData = await this.getRepoData(repositoryId); + const currData = await this.getRepository(repositoryId); + if (Object.keys(currData).length === 0) return; // Repo doesn't exist. currData.status = status; - await this.cacheDB.query( - //language=SQLite - `UPDATE CACHE_METADATA - SET STATUS = ?, - CACHE_TIME = ?, - DATA = ? - WHERE SET_ID = ?`, - [status, JSON.stringify(currData.cacheTime), JSON.stringify(currData), repositoryId] - ); + + this.addOrUpdateRepository({ + ...currData, + setId: repositoryId, + status: status + }); } async checkForAbort(repositoryId: string): Promise { @@ -207,7 +226,11 @@ class SQLiteRecordCacheService extends RecordCacheService { * @param limit Maximum results per page * @returns { UserRecord[] } Filter Objects */ - async fetchPaginatedCachedRecords(recordSetIdList: string[], page: number, limit: number): Promise { + async getPaginatedCachedActivityRecords( + recordSetIdList: string[], + page: number, + limit: number + ): Promise { if (!recordSetIdList || recordSetIdList.length === 0) { return []; } @@ -240,7 +263,7 @@ class SQLiteRecordCacheService extends RecordCacheService { return response; } - async fetchPaginatedCachedIappRecords(recordSetIdList: string[], page: number, limit: number): Promise { + async getPaginatedCachedIappRecords(recordSetIdList: string[], page: number, limit: number): Promise { if (!recordSetIdList || recordSetIdList.length === 0) { return []; } @@ -302,6 +325,12 @@ class SQLiteRecordCacheService extends RecordCacheService { const stringified = JSON.stringify(data); const short_id = (data as Record)?.short_id; const geometry = (data as Record)?.geometry; + geometry.forEach((_, i) => { + geometry[i].properties = { + name: short_id, + description: id + }; + }); const geojson = JSON.stringify(geometry) ?? null; await this.cacheDB.query( //language=SQLite @@ -356,7 +385,7 @@ class SQLiteRecordCacheService extends RecordCacheService { return JSON.parse(result.values[0][dataType]); } - async loadIappRecordsetSourceMetadata(ids: string[]): Promise { + async createIappRecordsetSourceMetadata(ids: string[]): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); } @@ -378,7 +407,8 @@ class SQLiteRecordCacheService extends RecordCacheService { }; return { cachedGeoJson }; } - async loadRecordsetSourceMetadata(ids: string[]): Promise { + + async createActivityRecordsetSourceMetadata(ids: string[]): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); } @@ -395,9 +425,7 @@ class SQLiteRecordCacheService extends RecordCacheService { results?.values?.forEach((item) => { try { - const label = item['SHORT_ID']; JSON.parse(item['GEOJSON'])?.forEach((feature: Feature) => { - feature.properties = { name: label }; centroidArr.push(centroid(feature)); geoJsonArr.push(feature); }); @@ -405,6 +433,7 @@ class SQLiteRecordCacheService extends RecordCacheService { console.error('Error parsing record:', e); } }); + const cachedCentroid: GeoJSONSourceSpecification = { type: 'geojson', data: { @@ -421,6 +450,7 @@ class SQLiteRecordCacheService extends RecordCacheService { }; return { cachedCentroid, cachedGeoJson }; } + async deleteCachedRecordsFromIds(idsToDelete: string[], recordSetType: RecordSetType): Promise { if (this.cacheDB == null) { throw new Error(CACHE_UNAVAILABLE); @@ -433,7 +463,7 @@ class SQLiteRecordCacheService extends RecordCacheService { const RECORD_TABLE = RecordsToTable[recordSetType]; const BATCH_AMOUNT = 100; - this.cacheDB.beginTransaction(); + await this.cacheDB.beginTransaction(); try { for (let i = 0; i < idsToDelete.length; i += BATCH_AMOUNT) { const sliced = idsToDelete.slice(i, Math.min(i + BATCH_AMOUNT, idsToDelete.length)); @@ -444,12 +474,13 @@ class SQLiteRecordCacheService extends RecordCacheService { [...sliced] ); } - this.cacheDB.commitTransaction(); + await this.cacheDB.commitTransaction(); } catch (e) { - this.cacheDB.rollbackTransaction(); + await this.cacheDB.rollbackTransaction(); throw e; } } + private async initializeRecordCache(sqlite: SQLiteConnection) { // Hold Migrations as named variable so we can use length to update the Db version automagically // Note: toVersion must be an integer. diff --git a/app/src/utils/tile-cache/context.ts b/app/src/utils/tile-cache/context.ts index 3428863e5..b5568412f 100644 --- a/app/src/utils/tile-cache/context.ts +++ b/app/src/utils/tile-cache/context.ts @@ -5,7 +5,7 @@ import { SQLiteTileCacheService } from 'utils/tile-cache/sqlite-cache'; import { LocalForageCacheService } from 'utils/tile-cache/localforage-cache'; class TileCacheServiceFactory { - static async getPlatformInstance() { + static async getPlatformInstance(): Promise { if ([Platform.IOS, Platform.ANDROID].includes(PLATFORM)) { return SQLiteTileCacheService.getInstance(); } diff --git a/app/src/utils/tile-cache/index.ts b/app/src/utils/tile-cache/index.ts index 543db6a7f..a2e9a1373 100644 --- a/app/src/utils/tile-cache/index.ts +++ b/app/src/utils/tile-cache/index.ts @@ -1,3 +1,4 @@ +import BaseCacheService from 'utils/base-classes/BaseCacheService'; import { base64toBuffer, lat2tile, long2tile } from 'utils/tile-cache/helpers'; // base64-encoded blank tile image 256x256 (opaque, light blue) @@ -42,7 +43,6 @@ enum RepositoryStatus { FAILED = 'FAILED', UNKNOWN = 'UNKNOWN' } - interface TilePromise { id: string; url: string; @@ -50,6 +50,7 @@ interface TilePromise { y: number; z: number; } + export interface TileCacheProgressCallbackParameters { repository: string; message: string; @@ -64,8 +65,15 @@ export interface RepositoryStatistics { tileCount: number; } -abstract class TileCacheService { - protected constructor() {} +abstract class TileCacheService extends BaseCacheService< + RepositoryMetadata, + RepositoryDownloadRequestSpec, + TileCacheProgressCallbackParameters, + RepositoryStatus +> { + protected constructor() { + super(); + } static generateFallbackTile(): TileData { return { @@ -106,13 +114,7 @@ abstract class TileCacheService { abstract setTile(repository: string, z: number, x: number, y: number, tileData: Uint8Array): Promise; - abstract getRepository(id: string): Promise; - - abstract listRepositories(): Promise; - - abstract deleteRepository(repository: string): Promise; - - abstract setRepositoryStatus(repository: string, status: RepositoryStatus): Promise; + protected abstract addOrUpdateRepository(spec: RepositoryMetadata): Promise; private async downloadTile(tileDetails: TilePromise): Promise { const { id, url, x, y, z } = tileDetails; @@ -151,7 +153,7 @@ abstract class TileCacheService { const executing = new Set(); try { - await this.addRepository({ + await this.addOrUpdateRepository({ id: spec.id, status: RepositoryStatus.DOWNLOADING, maxZoom: spec.maxZoom, @@ -234,8 +236,6 @@ abstract class TileCacheService { public abstract updateDescription(repository: string, newDescription: string): Promise; protected abstract cleanupOrphanTiles(): Promise; - - protected abstract addRepository(spec: RepositoryMetadata): Promise; } export { TileCacheService, FALLBACK_IMAGE, RepositoryStatus }; diff --git a/app/src/utils/tile-cache/localforage-cache.ts b/app/src/utils/tile-cache/localforage-cache.ts index d82b9a806..ffdac1c13 100644 --- a/app/src/utils/tile-cache/localforage-cache.ts +++ b/app/src/utils/tile-cache/localforage-cache.ts @@ -21,7 +21,7 @@ interface TileKey { class LocalForageCacheService extends TileCacheService { private static _instance: LocalForageCacheService; - private static REPOSITORY_METADATA_KEY = 'repositories'; + private static readonly REPOSITORY_METADATA_KEY = 'repositories'; private store: LocalForage | null = null; @@ -222,7 +222,7 @@ class LocalForageCacheService extends TileCacheService { } } - protected async addRepository(spec: RepositoryMetadata) { + protected async addOrUpdateRepository(spec: RepositoryMetadata) { if (this.store == null) { throw new Error('cache not available'); } @@ -230,11 +230,11 @@ class LocalForageCacheService extends TileCacheService { const repositories = await this.listRepositories(); const foundIndex = repositories.findIndex((p) => p.id == spec.id); if (foundIndex !== -1) { - throw new Error('repository already exists'); + repositories[foundIndex] = spec; + } else { + repositories.push(spec); } - repositories.push(spec); - await this.store.setItem(LocalForageCacheService.REPOSITORY_METADATA_KEY, repositories); } diff --git a/app/src/utils/tile-cache/sqlite-cache.ts b/app/src/utils/tile-cache/sqlite-cache.ts index eaf0e9569..2b9dc56bf 100644 --- a/app/src/utils/tile-cache/sqlite-cache.ts +++ b/app/src/utils/tile-cache/sqlite-cache.ts @@ -190,7 +190,7 @@ class SQLiteTileCacheService extends TileCacheService { ); } - protected async addRepository(spec: RepositoryMetadata): Promise { + protected async addOrUpdateRepository(spec: RepositoryMetadata): Promise { if (this.cacheDB == null) { throw new Error('cache not available'); } @@ -207,7 +207,15 @@ class SQLiteTileCacheService extends TileCacheService { MIN_LONGITUDE, MAX_LONGITUDE) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, + ON CONFLICT(TILESET) + DO UPDATE SET + DESCRIPTION = excluded.DESCRIPTION, + STATUS = excluded.STATUS, + MAX_ZOOM = excluded.MAX_ZOOM, + MIN_LATITUDE = excluded.MIN_LATITUDE, + MAX_LATITUDE = excluded.MAX_LATITUDE, + MIN_LONGITUDE = excluded.MIN_LONGITUDE, + MAX_LONGITUDE = excluded.MAX_LONGITUDE`, [ spec.id, spec.description,