places: implement hierarchy (close #511)

Signed-off-by: Varun Patil <radialapps@gmail.com>
pull/672/head
Varun Patil 2023-05-29 23:12:49 -07:00
parent f89da9e541
commit 34d48c3cc6
11 changed files with 308 additions and 56 deletions

View File

@ -4,9 +4,12 @@ All notable changes to this project will be documented in this file.
## [v5.1.1] - Unreleased
**Note:** You will need to run `occ memories:places-setup --recalculate` to re-index places (or reindex everything)
- New project home page: https://memories.gallery
- New Discord community: https://discord.gg/7Dr9f9vNjJ
- Nextcloud 27 compatibility
- **Feature**: Hierarchical places view
- **Feature**: Layout improvements especially for mobile.
- **Feature**: Allow downloading entire publicly shared albums.
- **Feature**: Basic preview generation configuration in admin interface.

View File

@ -65,6 +65,9 @@ class PlacesBackend extends Backend
public function getClusters(): array
{
$inside = (int) $this->request->getParam('inside', 0);
$marked = (int) $this->request->getParam('mark', 1);
$query = $this->tq->getBuilder();
// SELECT location name and count of photos
@ -75,7 +78,41 @@ class PlacesBackend extends Backend
$query->where($query->expr()->gt('e.admin_level', $query->expr()->literal(0, \PDO::PARAM_INT)));
// WHERE there are items with this osm_id
$query->innerJoin('e', 'memories_places', 'mp', $query->expr()->eq('mp.osm_id', 'e.osm_id'));
$mpJoinOn = [$query->expr()->eq('mp.osm_id', 'e.osm_id')];
// AND these items are inside the requested place
if ($inside > 0) {
$sub = $this->tq->getBuilder();
$sub->select($query->expr()->literal(1))->from('memories_places', 'mp_sq')
->where($sub->expr()->eq('mp_sq.osm_id', $query->createNamedParameter($inside, \PDO::PARAM_INT)))
->andWhere($sub->expr()->eq('mp_sq.fileid', 'mp.fileid'))
;
$mpJoinOn[] = $query->createFunction("EXISTS ({$sub->getSQL()})");
// Add WHERE clauses to main query to filter out admin_levels
$sub = $this->tq->getBuilder();
$sub->select('e_sq.admin_level')
->from('memories_planet', 'e_sq')
->where($sub->expr()->eq('e_sq.osm_id', $query->createNamedParameter($inside, \PDO::PARAM_INT)))
;
$adminSql = "({$sub->getSQL()})";
$query->andWhere($query->expr()->gt('e.admin_level', $query->createFunction($adminSql)))
->andWhere($query->expr()->lte('e.admin_level', $query->createFunction("{$adminSql} + 3")))
;
}
// Else if we are looking for countries
elseif ($inside === -1) {
$query->where($query->expr()->eq('e.admin_level', $query->expr()->literal(2, \PDO::PARAM_INT)));
}
// AND these items are marked (only if not inside)
elseif ($marked > 0) {
$mpJoinOn[] = $query->expr()->eq('mp.mark', $query->expr()->literal(1, \PDO::PARAM_INT));
}
// JOIN on memories_places
$query->innerJoin('e', 'memories_places', 'mp', $query->expr()->andX(...$mpJoinOn));
// WHERE these items are memories indexed photos
$query->innerJoin('mp', 'memories', 'm', $query->expr()->eq('m.fileid', 'mp.fileid'));
@ -104,8 +141,14 @@ class PlacesBackend extends Backend
// INNER JOIN back on the planet table to get the names
$query->innerJoin('sub', 'memories_planet', 'e', $query->expr()->eq('e.osm_id', 'sub.osm_id'));
// WHERE at least 3 photos if want marked clusters
if ($marked) {
$query->andWhere($query->expr()->gte('sub.count', $query->expr()->literal(3, \PDO::PARAM_INT)));
}
// ORDER BY name and osm_id
$query->orderBy($query->createFunction('LOWER(e.name)'), 'ASC');
$query->orderBy($query->createFunction('sub.count'), 'DESC');
$query->addOrderBy('e.name');
$query->addOrderBy('e.osm_id'); // tie-breaker
// FETCH all tags
@ -115,7 +158,7 @@ class PlacesBackend extends Backend
$lang = Util::getUserLang();
foreach ($places as &$row) {
$row['osm_id'] = (int) $row['osm_id'];
$row['count'] = (int) $row['count'];
$row['count'] = $marked ? 0 : (int) $row['count']; // the count is incorrect
self::choosePlaceLang($row, $lang);
}

View File

@ -26,6 +26,7 @@ namespace OCA\Memories\Command;
use OCA\Memories\Service\Places;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class PlacesSetup extends Command
@ -45,12 +46,14 @@ class PlacesSetup extends Command
$this
->setName('memories:places-setup')
->setDescription('Setup reverse geocoding')
->addOption('recalculate', 'r', InputOption::VALUE_NONE, 'Only recalculate places for existing files')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->output = $output;
$recalculate = $input->getOption('recalculate');
$this->output->writeln('Attempting to set up reverse geocoding');
@ -63,35 +66,18 @@ class PlacesSetup extends Command
$this->output->writeln('Database support was detected');
// Check if database is already set up
if ($this->places->geomCount() > 0) {
$this->output->writeln('');
$this->output->writeln('<error>Database is already set up</error>');
$this->output->writeln('<error>This will drop and re-download the planet database</error>');
$this->output->writeln('<error>This is generally not necessary to do frequently </error>');
// Ask confirmation
$this->output->writeln('');
$this->output->writeln('Are you sure you want to download the planet database?');
$this->output->write('Proceed? [y/N] ');
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if (false === $line) {
$this->output->writeln('<error>You need an interactive terminal to run this command</error>');
return 1;
}
if ('y' !== trim($line)) {
$this->output->writeln('Aborting');
return 1;
}
if ($this->places->geomCount() > 0 && !$recalculate && !$this->warnDownloaded()) {
return 1;
}
// Download the planet database
$datafile = $this->places->downloadPlanet();
// Check if we only need to recalculate
if (!$recalculate) {
// Download the planet database
$datafile = $this->places->downloadPlanet();
// Import the planet database
$this->places->importPlanet($datafile);
// Import the planet database
$this->places->importPlanet($datafile);
}
// Recalculate all places
$this->places->recalculateAll();
@ -100,4 +86,31 @@ class PlacesSetup extends Command
return 0;
}
protected function warnDownloaded(): bool
{
$this->output->writeln('');
$this->output->writeln('<error>Database is already set up</error>');
$this->output->writeln('<error>This will drop and re-download the planet database</error>');
$this->output->writeln('<error>This is generally not necessary to do frequently </error>');
// Ask confirmation
$this->output->writeln('');
$this->output->writeln('Are you sure you want to download the planet database?');
$this->output->write('Proceed? [y/N] ');
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if (false === $line) {
$this->output->writeln('<error>You need an interactive terminal to run this command</error>');
return false;
}
if ('y' !== trim($line)) {
$this->output->writeln('Aborting');
return false;
}
return true;
}
}

View File

@ -46,42 +46,25 @@ trait TimelineWritePlaces
return [];
}
// Construct WHERE clause depending on GIS type
$where = null;
if (1 === $gisType) {
$where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))";
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return [];
}
// Get places
$rows = \OC::$server->get(\OCA\Memories\Service\Places::class)->queryPoint($lat, $lon);
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select($query->createFunction('DISTINCT(osm_id)'))
->from('memories_planet_geometry')
->where($query->createFunction($where))
;
// Cancel out inner rings
$query->groupBy('poly_id', 'osm_id');
$query->having($query->createFunction('SUM(type_id) > 0'));
// memories_planet_geometry has no *PREFIX*
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
// Run query
$rows = $this->connection->executeQuery($sql)->fetchAll();
// Get last ID, i.e. the ID with highest admin_level but <= 8
$markRow = array_pop(array_filter($rows, fn ($row) => $row['admin_level'] <= 8));
// Insert records in transaction
$this->connection->beginTransaction();
foreach ($rows as $row) {
$isMark = $markRow && $row['osm_id'] === $markRow['osm_id'];
// Insert the place
$query = $this->connection->getQueryBuilder();
$query->insert('memories_places')
->values([
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT),
'mark' => $query->createNamedParameter($isMark, IQueryBuilder::PARAM_BOOL),
])
;
$query->executeStatement();

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Migration;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version502000Date20230530052850 extends SimpleMigrationStep
{
/**
* @param \Closure(): ISchemaWrapper $schemaClosure
*/
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
/**
* @param \Closure(): ISchemaWrapper $schemaClosure
*/
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('memories_places');
$table->addColumn('mark', Types::BOOLEAN, [
'notnull' => false,
'default' => false,
]);
$table->addIndex(['osm_id', 'mark'], 'memories_places_id_mk_idx');
return $schema;
}
/**
* @param \Closure(): ISchemaWrapper $schemaClosure
*/
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
}

View File

@ -88,6 +88,50 @@ class Places
}
}
/**
* Get list of osm IDs for a given point.
*/
public function queryPoint(float $lat, float $lon): array
{
// Get GIS type
$gisType = \OCA\Memories\Util::placesGISType();
// Construct WHERE clause depending on GIS type
$where = null;
if (1 === $gisType) {
$where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))";
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return [];
}
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select($query->createFunction('DISTINCT(osm_id)'))
->from('memories_planet_geometry')
->where($query->createFunction($where))
;
// Cancel out inner rings
$query->groupBy('poly_id', 'osm_id');
$query->having($query->createFunction('SUM(type_id) > 0'));
// memories_planet_geometry has no *PREFIX*
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
// Use as subquery to get admin level
$query = $this->connection->getQueryBuilder();
$query->select('sub.osm_id', 'mp.admin_level')
->from($query->createFunction("({$sql})"), 'sub')
->innerJoin('sub', 'memories_planet', 'mp', $query->expr()->eq('sub.osm_id', 'mp.osm_id'))
->orderBy('mp.admin_level', 'ASC')
;
// Run query
return $query->executeQuery()->fetchAll();
}
/**
* Download planet database file and return path to it.
*/

View File

@ -12,6 +12,10 @@
:gridItems="gridItems"
@resize="resize"
>
<template #before>
<slot name="before" />
</template>
<template v-slot="{ item }">
<div class="grid-item fill-block">
<Cluster :data="item" @click="click(item)" :link="link" />

View File

@ -1,12 +1,16 @@
<template>
<div v-if="noParams" class="container no-user-select">
<div v-if="noParams" class="container no-user-select cluster-view">
<XLoadingIcon class="loading-icon centered" v-if="loading" />
<TopMatter />
<EmptyContent v-if="!items.length && !loading" />
<ClusterGrid :items="items" :minCols="minCols" />
<ClusterGrid :items="items" :minCols="minCols" ref="denali">
<template #before>
<DynamicTopMatter ref="dtm" />
</template>
</ClusterGrid>
</div>
<Timeline v-else />
@ -22,6 +26,7 @@ import TopMatter from './top-matter/TopMatter.vue';
import ClusterGrid from './ClusterGrid.vue';
import Timeline from './Timeline.vue';
import EmptyContent from './top-matter/EmptyContent.vue';
import DynamicTopMatter from './top-matter/DynamicTopMatter.vue';
import * as dav from '../services/DavRequests';
@ -35,6 +40,7 @@ export default defineComponent({
ClusterGrid,
Timeline,
EmptyContent,
DynamicTopMatter,
},
mixins: [UserConfig],
@ -79,6 +85,10 @@ export default defineComponent({
this.items = [];
this.loading++;
await this.$nextTick();
// @ts-ignore
await this.$refs.dtm?.refresh?.();
if (route === 'albums') {
this.items = await dav.getAlbums(3, this.config.album_list_sort);
} else if (route === 'tags') {

View File

@ -11,6 +11,7 @@ import { defineComponent } from 'vue';
import UserMixin from '../../mixins/UserConfig';
import FolderDynamicTopMatter from './FolderDynamicTopMatter.vue';
import PlacesDynamicTopMatterVue from './PlacesDynamicTopMatter.vue';
import OnThisDay from './OnThisDay.vue';
import * as PublicShareHeader from './PublicShareHeader';
@ -25,6 +26,8 @@ export default defineComponent({
currentmatter(): any {
if (this.routeIsFolders) {
return FolderDynamicTopMatter;
} else if (this.routeIsPlaces) {
return PlacesDynamicTopMatterVue;
} else if (this.routeIsBase && this.config.enable_top_memories) {
return OnThisDay;
}

View File

@ -0,0 +1,78 @@
<template>
<div class="places-dtm">
<NcButton class="place" :key="place.cluster_id" v-for="place of places" :to="route(place)">
{{ place.name }}
</NcButton>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import axios from '@nextcloud/axios';
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
import { API } from '../../services/API';
import type { ICluster } from '../../types';
export default defineComponent({
name: 'PlacesDynamicTopMatter',
data: () => ({
places: [] as ICluster[],
}),
components: {
NcButton,
},
methods: {
async refresh(): Promise<boolean> {
// Clear folders
this.places = [];
// Get ID of place from URL
const placeId = Number(this.$route.params.name?.split('-')[0]) || -1;
const url = API.Q(API.PLACE_LIST(), { inside: placeId });
// Make API call to get subfolders
try {
this.places = (await axios.get<ICluster[]>(url)).data;
} catch (e) {
console.error(e);
return false;
}
return this.places.length > 0;
},
route(place: ICluster) {
return {
name: 'places',
params: {
name: place.cluster_id + '-' + place.name,
},
};
},
},
});
</script>
<style lang="scss" scoped>
.places-dtm {
margin: 0 0.3em;
button.place {
font-size: 0.85em;
min-height: unset;
display: inline-block;
margin: 3px 2px;
padding: 1px 6px;
}
@media (min-width: 769px) {
margin-right: 44px;
}
}
</style>

View File

@ -97,6 +97,11 @@ export default defineComponent({
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
}
// Hide shadow if inside cluster view
.cluster-view & {
box-shadow: none;
}
@media (max-width: 768px) {
padding-left: 10px; // extra space visual
}