diff --git a/src/database.js b/src/database.js index 437b783..a9a1135 100644 --- a/src/database.js +++ b/src/database.js @@ -47,8 +47,13 @@ export function regexp_match(string, regex) { return string.match(regex) != null; } +export function is_in_geo_polygon(lat, long, polyid) { + return true; +} + export function add_custom_functions(db) { db.create_function("REGEXP", regexp_match); + db.create_function("IS_IN_GEO_POLYGON", is_in_geo_polygon); } // Digikam stores its tree of tags as individual tags, diff --git a/src/main.js b/src/main.js index 5b21c09..ccf78c3 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import { UserQuery, user_query_from_search_string, maybe_image_query, do_image_q import { Browser } from './browser.js'; import { UserQueryWidget } from './userquerywidget.js'; import { ResultsView } from './resultsview.js'; +import { PolygonStoreProvider } from './polygons.js'; const useStyles = makeStyles(theme => ({ root: { @@ -125,16 +126,18 @@ export function LoadedMainPage(props) { return ( <> - - - {albums && } + + + + {albums && } + + + + + {photos && } + - - - - {photos && } - - + ); } @@ -150,14 +153,14 @@ export function MainPage() { return ( {({ db_error, db }) => ( <> {(db == null && !db_error) && } {db_error && } - {db != null && } + {db != null && } )} diff --git a/src/polygons.js b/src/polygons.js new file mode 100644 index 0000000..2462584 --- /dev/null +++ b/src/polygons.js @@ -0,0 +1,21 @@ +import React, { createContext, useReducer } from 'react'; + +const polygonStore = createContext({}); +const { Provider } = polygonStore; + +const PolygonStoreProvider = ({ children }) => { + const [state, dispatch] = useReducer((state, action) => { + switch (action.type) { + case 'add polygon': + const newstate = state; + newstate[action.payload.id] = action.payload.data; + return newstate; + default: + throw new Error(); + }; + }, {}); + + return {children}; +}; + +export { polygonStore, PolygonStoreProvider } \ No newline at end of file diff --git a/src/queries.js b/src/queries.js index 04b49e3..70ec079 100644 --- a/src/queries.js +++ b/src/queries.js @@ -33,7 +33,7 @@ export function do_image_query(query, database, collection_path, collection_thum } resolve(photos); }) - .catch(err => { throw err; }); + .catch(err => { throw err; }); }); } @@ -167,9 +167,9 @@ export class TimeFilter extends ResultFilter { to_sql_where() { var operator = ''; - if(this.type == TimeFilterTypeEnum.AFTER) { + if (this.type == TimeFilterTypeEnum.AFTER) { operator = '>='; - } else if(this.type == TimeFilterTypeEnum.BEFORE) { + } else if (this.type == TimeFilterTypeEnum.BEFORE) { operator = '<='; } else { throw new Error("Unsupported time filter type."); @@ -303,6 +303,34 @@ export class LogicalOperatorFilter extends ResultFilter { } } +export class LocationFilter extends ResultFilter { + // The location filter always compares object locations (points) + // to a location polygon and filters objects outside said polygon. + constructor(rtype, polygon, polygon_store_dispatch) { + super(rtype); + this.polygon = polygon; + this.polygon_store_dispatch = polygon_store_dispatch; + } + + to_sql_where() { + // When we turn this into a query, we will use a custom SQL function which compares to a + // polygon. However, we don't want to encode an entire polygon in an SQL query. + // Therefore we make use of polygons stored in Javascript state, which can be read + // by the custom comparison function invoked by SQL.js. + // We need to store our polygon in said state and then return the query to guarantee that + // it will have access to said polygon. + const _ = require('lodash'); + const poly = { + id: _.uniqueId('polygon_'), + data: this.polygon + } + this.polygon_store_dispatch({ type: 'add polygon', payload: poly }); + return 'IS_IN_GEO_POLYGON(ImagePositions.latitude, ImagePositions.longitude, "' + poly.id + '")'; + } + + simplify() { return this; } +} + export class UserQuery { image_filter = new ConstFilter(ResultTypeEnum.IMAGE, false); album_filter = new ConstFilter(ResultTypeEnum.ALBUM, false); @@ -316,21 +344,20 @@ export function image_query_with_where(maybe_where) { + "FROM Images INNER JOIN Albums ON Images.album=Albums.id " + "LEFT JOIN ImageTags ON Images.id=ImageTags.imageid " + "LEFT JOIN ImageInformation ON Images.id=ImageInformation.imageid " - + "LEFT JOIN Tags ON ImageTags.tagid=Tags.id " + (maybe_where ? maybe_where : "") + + "LEFT JOIN ImagePositions ON ImagePositions.imageid=Images.id " + + "LEFT JOIN Tags ON ImageTags.tagid=Tags.id " + + (maybe_where ? maybe_where : "") + " GROUP BY Images.id;"; } // This query will return database entries with the fields "id" and "relativePath" for each matching album. export function album_query_with_where(maybe_where) { - var query = "SELECT Albums.id, Albums.relativePath FROM Albums"; + var query = "SELECT Albums.id, Albums.relativePath FROM Albums " + + "LEFT JOIN Images ON Images.album=Albums.id " + + "LEFT JOIN ImageTags ON ImageTags.imageid=Images.id " + + "LEFT JOIN ImagePositions ON ImagePositions.imageid=Images.id " + + "LEFT JOIN Tags ON Tags.id=ImageTags.tagid"; - // If there is tags/images stuff in the where clause, we need to do a join on those tables. - if(maybe_where && (maybe_where.includes("Tags.") || maybe_where.includes("Images."))) { - query = query + " LEFT JOIN Images ON Images.album=Albums.id " - + " LEFT JOIN ImageTags ON ImageTags.imageid=Images.id " - + " LEFT JOIN Tags ON Tags.id=ImageTags.tagid"; - } - query = query + " " + (maybe_where ? maybe_where : "") + " GROUP BY Albums.id;"; return query; @@ -338,20 +365,17 @@ export function album_query_with_where(maybe_where) { // This query will return database entries with the fields "id" and "name" for each matching tag. export function tag_query_with_where(maybe_where) { - var query = "SELECT Tags.id, Tags.name, Tags.fullname FROM Tags LEFT JOIN TagProperties ON Tags.id=TagProperties.tagid"; + var query = "SELECT Tags.id, Tags.name, Tags.fullname FROM Tags LEFT JOIN TagProperties ON Tags.id=TagProperties.tagid " + + "LEFT JOIN ImageTags ON ImageTags.tagid=Tags.id " + + "LEFT JOIN Images ON Images.id=ImageTags.imageid " + + "LEFT JOIN ImagePositions ON ImagePositions.imageid=Images.id " + + "LEFT JOIN Albums ON Albums.id=Images.album"; // Add a clause to the WHERE to hide internal tags. var exclude_internal = ' (Tags.name="People" OR TagProperties.property IS NULL OR TagProperties.property<>"internalTag")'; var where = maybe_where ? maybe_where + ' AND' + exclude_internal : "WHERE " + exclude_internal; - - // If there is albums/images stuff in the where clause, we need to do a join on those tables. - if(where.includes("Albums.") || where.includes("Images.")) { - query = query + " LEFT JOIN ImageTags ON ImageTags.tagid=Tags.id " - + " LEFT JOIN Images ON Images.id=ImageTags.imageid " - + " LEFT JOIN Albums ON Albums.id=Images.album"; - } query = query + " " + where + " GROUP BY Tags.id;"; return query; diff --git a/src/userquerywidget.js b/src/userquerywidget.js index 29cf0c9..5303eef 100644 --- a/src/userquerywidget.js +++ b/src/userquerywidget.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useContext } from 'react'; import Switch from '@material-ui/core/Switch'; import Box from '@material-ui/core/Box'; @@ -24,10 +24,12 @@ import { format } from 'date-fns'; import { makeStyles } from '@material-ui/core/styles'; +import { polygonStore } from './polygons.js'; + import { filter_is_const_false, ConstFilter, LogicalOperatorFilter, MatchingFilter, ResultTypeEnum, LogicalOperatorEnum, MatchTypeEnum, NegationFilter, TimeFilterTypeEnum, - TimeFilter, ImageTypeFilter, ImageTypeEnum + TimeFilter, ImageTypeFilter, ImageTypeEnum, LocationFilter } from './queries.js' import { Typography } from '@material-ui/core'; @@ -55,6 +57,14 @@ const useStyles = makeStyles(theme => ({ }, })); +export function EditLocationFilterExpression(props) { + const { onChange, filter } = props; + + return ( + TODO + ); +} + export function EditTimeFilterExpression(props) { const { onChange, filter } = props; @@ -229,6 +239,8 @@ export function EditFilterExpressionDialog(props) { const { onClose, startingFilter, open } = props; const [filter, setFilter] = React.useState(startingFilter); + const polygons_dispatch = useContext(polygonStore).dispatch; + const FilterTypeEnum = { CONST: 0, NEGATION: 1, @@ -236,6 +248,7 @@ export function EditFilterExpressionDialog(props) { LOGICAL: 3, TIME: 4, IMAGETYPE: 5, + LOCATION: 6, }; useEffect(() => { @@ -256,6 +269,8 @@ export function EditFilterExpressionDialog(props) { setFilter(new TimeFilter(filter.result_type, Date.now(), TimeFilterTypeEnum.AFTER)); } else if (val == FilterTypeEnum.IMAGETYPE) { setFilter(new ImageTypeFilter(filter.result_type, ImageTypeEnum.PHOTO)); + } else if (val == FilterTypeEnum.LOCATION) { + setFilter(new LocationFilter(filter.result_type, [[0.0, 0.0]], polygons_dispatch)); } else { throw new Error('Unsupported filter type: ' + val); } @@ -268,6 +283,7 @@ export function EditFilterExpressionDialog(props) { else if (filter instanceof LogicalOperatorFilter) { return FilterTypeEnum.LOGICAL; } else if (filter instanceof TimeFilter) { return FilterTypeEnum.TIME; } else if (filter instanceof ImageTypeFilter) { return FilterTypeEnum.IMAGETYPE; } + else if (filter instanceof LocationFilter) { return FilterTypeEnum.LOCATION; } else { throw new Error('Unsupported filter type: ' + filter); } @@ -293,6 +309,8 @@ export function EditFilterExpressionDialog(props) { control = } else if (filter instanceof ImageTypeFilter) { control = + } else if (filter instanceof LocationFilter) { + control = } // If this is a "leaf" filter, we will allow changing the filter type in the dialog. @@ -302,7 +320,8 @@ export function EditFilterExpressionDialog(props) { (filter instanceof ConstFilter) || (filter instanceof MatchingFilter) || (filter instanceof TimeFilter) || - (filter instanceof ImageTypeFilter); + (filter instanceof ImageTypeFilter) || + (filter instanceof LocationFilter); return ( @@ -319,6 +338,7 @@ export function EditFilterExpressionDialog(props) { Matching Date/Time Media type + Location } @@ -567,9 +587,9 @@ export function ImageTypeFilterExpressionControl(props) { const { expr, onClick, onChange } = props; var icon = false; - if(expr.type == ImageTypeEnum.PHOTO) { + if (expr.type == ImageTypeEnum.PHOTO) { icon = - } else if(expr.type == ImageTypeEnum.VIDEO) { + } else if (expr.type == ImageTypeEnum.VIDEO) { icon = } else { throw new Error("Unsupported image type"); @@ -587,6 +607,22 @@ export function ImageTypeFilterExpressionControl(props) { ); } +export function LocationFilterExpressionControl(props) { + const classes = useStyles(); + const { expr, onClick, onChange } = props; + + return ( + + ); +} + export function FilterExpressionControl(props) { const { expr, onChange, isRoot } = props; const [anchorEl, setAnchorEl] = React.useState(null); @@ -676,6 +712,8 @@ export function FilterExpressionControl(props) { filter_elem = } else if (expr instanceof ImageTypeFilter) { filter_elem = + } else if (expr instanceof LocationFilter) { + filter_elem = } else { throw new Error('Unsupported filter expression'); }