places: implement hierarchy (close #511)
Signed-off-by: Varun Patil <radialapps@gmail.com>pull/672/head
parent
f89da9e541
commit
34d48c3cc6
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue