Initial places implementation

pull/395/head
Varun Patil 2023-02-05 13:43:25 -08:00
parent 7f21e8802d
commit 62579b1b89
20 changed files with 425 additions and 39 deletions

View File

@ -27,6 +27,7 @@ return [
w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'), w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'), w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'), w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#places', 'url' => '/places/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'), w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
// Public folder share // Public folder share
@ -61,6 +62,9 @@ return [
['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'], ['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'], ['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'],
['name' => 'Places#places', 'url' => '/api/places', 'verb' => 'GET'],
['name' => 'Places#preview', 'url' => '/api/places/preview/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'], ['name' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'],

View File

@ -29,6 +29,9 @@ use OCA\Memories\Db\TimelineRoot;
use OCA\Memories\Exif; use OCA\Memories\Exif;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\File; use OCP\Files\File;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\Files\IRootFolder; use OCP\Files\IRootFolder;
@ -291,6 +294,45 @@ class ApiBase extends Controller
return $node; return $node;
} }
/**
* Given a list of file ids, return the first preview image possible.
*/
protected function getPreviewFromImageList(array &$list)
{
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// Try to get a preview
$userFolder = $this->rootFolder->getUserFolder($this->getUID());
foreach ($list as &$img) {
// Get the file
$files = $userFolder->getById($img);
if (0 === \count($files)) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], 512, 512, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
} catch (\Exception $e) {
continue;
}
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
/** /**
* Check if albums are enabled for this user. * Check if albums are enabled for this user.
*/ */
@ -329,6 +371,14 @@ class ApiBase extends Controller
return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID()); return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID());
} }
/**
* Check if geolocation is enabled for this user.
*/
protected function geoPlacesIsEnabled(): bool
{
return true;
}
/** /**
* Helper to get one file or null from a fiolder. * Helper to get one file or null from a fiolder.
*/ */

View File

@ -237,6 +237,13 @@ class DaysController extends ApiBase
} }
} }
// Filter only for one place
if ($this->geoPlacesIsEnabled()) {
if ($locationId = $this->request->getParam('place')) {
$transforms[] = [$this->timelineQuery, 'transformPlaceFilter', (int) $locationId];
}
}
// Limit number of responses for day query // Limit number of responses for day query
$limit = $this->request->getParam('limit'); $limit = $this->request->getParam('limit');
if ($limit) { if ($limit) {

View File

@ -215,6 +215,16 @@ class PageController extends Controller
return $this->main(); return $this->main();
} }
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function places()
{
return $this->main();
}
/** /**
* @NoAdminRequired * @NoAdminRequired
* *

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @license AGPL-3.0-or-later
*
* 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\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
class PlacesController extends ApiBase
{
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get list of places with counts of images
*/
public function places(): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check tags enabled for this user
if (!$this->geoPlacesIsEnabled()) {
return new JSONResponse(['message' => 'Places not enabled'], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getPlaces($root);
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get preview for a location
*/
public function preview(int $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check tags enabled for this user
if (!$this->geoPlacesIsEnabled()) {
return new JSONResponse(['message' => 'Places not enabled'], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getPlacePreviews($id, $root);
if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
shuffle($list);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function ($item) {
return $item['fileid'];
}, $list));
}
}

View File

@ -24,7 +24,6 @@ declare(strict_types=1);
namespace OCA\Memories\Controller; namespace OCA\Memories\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
class TagsController extends ApiBase class TagsController extends ApiBase
@ -90,38 +89,11 @@ class TagsController extends ApiBase
if (null === $list || 0 === \count($list)) { if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
shuffle($list);
// Get preview manager // Get preview from image list
$previewManager = \OC::$server->get(\OCP\IPreview::class); return $this->getPreviewFromImageList(array_map(static function ($item) {
return $item['fileid'];
// Try to get a preview }, $list));
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
foreach ($list as &$img) {
// Get the file
$files = $userFolder->getById($img['fileid']);
if (0 === \count($files)) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], 512, 512, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
} catch (\Exception $e) {
continue;
}
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
} }

View File

@ -14,6 +14,7 @@ class TimelineQuery
use TimelineQueryFilters; use TimelineQueryFilters;
use TimelineQueryFolders; use TimelineQueryFolders;
use TimelineQueryLivePhoto; use TimelineQueryLivePhoto;
use TimelineQueryPlaces;
use TimelineQueryPeopleFaceRecognition; use TimelineQueryPeopleFaceRecognition;
use TimelineQueryPeopleRecognize; use TimelineQueryPeopleRecognize;
use TimelineQueryTags; use TimelineQueryTags;

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineQueryPlaces
{
protected IDBConnection $connection;
public function transformPlaceFilter(IQueryBuilder &$query, string $userId, int $locationId)
{
$query->innerJoin('m', 'memories_geo', 'mg', $query->expr()->andX(
$query->expr()->eq('mg.fileid', 'm.fileid'),
$query->expr()->eq('mg.osm_id', $query->createNamedParameter($locationId)),
));
}
public function getPlaces(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT location name and count of photos
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('p.osm_id', 'p.name', $count)->from('memories_planet', 'p');
// WHERE there are items with this osm_id
$query->innerJoin('p', 'memories_geo', 'mg', $query->expr()->eq('mg.osm_id', 'p.osm_id'));
// WHERE these items are memories indexed photos
$query->innerJoin('mg', 'memories', 'm', $query->expr()->eq('m.fileid', 'mg.fileid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// GROUP and ORDER by tag name
$query->groupBy('p.osm_id');
$query->orderBy($query->createFunction('LOWER(p.name)'), 'ASC');
$query->addOrderBy('p.osm_id'); // tie-breaker
// FETCH all tags
$sql = str_replace('*PREFIX*memories_planet', 'memories_planet', $query->getSQL());
$cursor = $this->executeQueryWithCTEs($query, $sql);
$places = $cursor->fetchAll();
// Post process
foreach ($places as &$row) {
$row['osm_id'] = (int) $row['osm_id'];
$row['count'] = (int) $row['count'];
}
return $places;
}
public function getPlacePreviews(int $id, TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT all photos with this tag
$query->select('f.fileid', 'f.etag')->from('memories_geo', 'mg')
->where($query->expr()->eq('mg.osm_id', $query->createNamedParameter($id)))
;
// WHERE these items are memories indexed photos
$query->innerJoin('mg', 'memories', 'm', $query->expr()->eq('m.fileid', 'mg.fileid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// MAX 8
$query->setMaxResults(8);
// FETCH tag previews
$cursor = $this->executeQueryWithCTEs($query);
$ans = $cursor->fetchAll();
// Post-process
foreach ($ans as &$row) {
$row['fileid'] = (int) $row['fileid'];
}
return $ans;
}
}

View File

@ -154,6 +154,13 @@ class TimelineWrite
$exifJson = json_encode(['error' => 'Exif data encoding error']); $exifJson = json_encode(['error' => 'Exif data encoding error']);
} }
// Store location data
if (\array_key_exists('GPSLatitude', $exif) && \array_key_exists('GPSLongitude', $exif)) {
$lat = $exif['GPSLatitude'];
$lon = $exif['GPSLongitude'];
$this->updateGeoData($file, (float) $lat, (float) $lon);
}
if ($prevRow) { if ($prevRow) {
// Update existing row // Update existing row
// No need to set objectid again // No need to set objectid again
@ -268,4 +275,43 @@ class TimelineWrite
return $query->executeStatement(); return $query->executeStatement();
} }
/**
* Add geolocation data for a file.
*/
public function updateGeoData(File &$file, float $lat, float $lon): void
{
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select('osm_id')
->from('memories_planet')
->where($query->createFunction('ST_Contains(`geometry`, ST_GeomFromText(\'POINT('.$lon.' '.$lat.')\', 4326))'))
;
// Remove memories_planet has no *PREFIX*
$sql = $query->getSQL();
$sql = str_replace('*PREFIX*memories_planet', 'memories_planet', $sql);
// Run query
$result = $this->connection->executeQuery($sql);
$rows = $result->fetchAll();
// Delete previous records
$query = $this->connection->getQueryBuilder();
$query->delete('memories_geo')
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
// Insert records
foreach ($rows as $row) {
$query = $this->connection->getQueryBuilder();
$query->insert('memories_geo')
->values([
'fileid' => $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT),
'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
}
}
} }

View File

@ -64,6 +64,7 @@ import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue"; import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import CalendarIcon from "vue-material-design-icons/Calendar.vue"; import CalendarIcon from "vue-material-design-icons/Calendar.vue";
import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue"; import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
import MarkerIcon from "vue-material-design-icons/MapMarker.vue";
import TagsIcon from "vue-material-design-icons/Tag.vue"; import TagsIcon from "vue-material-design-icons/Tag.vue";
import MapIcon from "vue-material-design-icons/Map.vue"; import MapIcon from "vue-material-design-icons/Map.vue";
@ -88,6 +89,7 @@ export default defineComponent({
ArchiveIcon, ArchiveIcon,
CalendarIcon, CalendarIcon,
PeopleIcon, PeopleIcon,
MarkerIcon,
TagsIcon, TagsIcon,
MapIcon, MapIcon,
}, },
@ -246,6 +248,11 @@ export default defineComponent({
icon: CalendarIcon, icon: CalendarIcon,
title: t("memories", "On this day"), title: t("memories", "On this day"),
}, },
{
name: "places",
icon: MarkerIcon,
title: t("memories", "Places"),
},
{ {
name: "tags", name: "tags",
icon: TagsIcon, icon: TagsIcon,

View File

@ -290,6 +290,8 @@ export default defineComponent({
return this.t("memories", "On this day"); return this.t("memories", "On this day");
case "tags": case "tags":
return this.t("memories", "Tags"); return this.t("memories", "Tags");
case "places":
return this.t("memories", "Places");
default: default:
return ""; return "";
} }
@ -669,6 +671,12 @@ export default defineComponent({
query.set("tag", <string>this.$route.params.name); query.set("tag", <string>this.$route.params.name);
} }
// Places
if (this.$route.name === "places" && this.$route.params.name) {
const name = <string>this.$route.params.name;
query.set("place", <string>name.split("-", 2)[0]);
}
// Albums // Albums
if (this.$route.name === "albums" && this.$route.params.name) { if (this.$route.name === "albums" && this.$route.params.name) {
const user = <string>this.$route.params.user; const user = <string>this.$route.params.user;
@ -737,6 +745,8 @@ export default defineComponent({
data = await dav.getPeopleData(this.$route.name as any); data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "albums" && !this.$route.params.name) { } else if (this.$route.name === "albums" && !this.$route.params.name) {
data = await dav.getAlbumsData("3"); data = await dav.getAlbumsData("3");
} else if (this.$route.name === "places" && !this.$route.params.name) {
data = await dav.getPlacesData();
} else { } else {
// Try the cache // Try the cache
try { try {

View File

@ -68,6 +68,10 @@ export default defineComponent({
return getPreviewUrl(mock, true, 512); return getPreviewUrl(mock, true, 512);
} }
if (this.place) {
return API.PLACE_PREVIEW(this.place.fileid);
}
return API.TAG_PREVIEW(this.data.name); return API.TAG_PREVIEW(this.data.name);
}, },
@ -92,6 +96,10 @@ export default defineComponent({
: "recognize"; : "recognize";
}, },
place() {
return this.data.flag & constants.c.FLAG_IS_PLACE ? this.data : null;
},
album() { album() {
return this.data.flag & constants.c.FLAG_IS_ALBUM return this.data.flag & constants.c.FLAG_IS_ALBUM
? <IAlbum>this.data ? <IAlbum>this.data
@ -114,6 +122,13 @@ export default defineComponent({
return { name: "albums", params: { user, name } }; return { name: "albums", params: { user, name } };
} }
if (this.place) {
const id = this.place.fileid.toString();
const placeName = this.place.name || id;
const name = `${id}-${placeName}`;
return { name: "places", params: { name } };
}
return { name: "tags", params: { name: this.data.name } }; return { name: "tags", params: { name: this.data.name } };
}, },

View File

@ -43,10 +43,14 @@ export default defineComponent({
methods: { methods: {
createMatter() { createMatter() {
this.name = <string>this.$route.params.name || ""; this.name = <string>this.$route.params.name || "";
if (this.$route.name === "places") {
this.name = this.name.split("-", 2)[1];
}
}, },
back() { back() {
this.$router.push({ name: "tags" }); this.$router.push({ name: this.$route.name });
}, },
}, },
}); });

View File

@ -58,6 +58,10 @@ export default defineComponent({
: TopMatterType.NONE; : TopMatterType.NONE;
case "albums": case "albums":
return TopMatterType.ALBUM; return TopMatterType.ALBUM;
case "places":
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
default: default:
return TopMatterType.NONE; return TopMatterType.NONE;
} }

View File

@ -94,6 +94,15 @@ export default new Router({
}), }),
}, },
{
path: "/places/:name*",
component: Timeline,
name: "places",
props: (route) => ({
rootTitle: t("memories", "Places"),
}),
},
{ {
path: "/tags/:name*", path: "/tags/:name*",
component: Timeline, component: Timeline,

View File

@ -47,6 +47,14 @@ export class API {
return gen(`${BASE}/albums/download?name={user}/{name}`, { user, name }); return gen(`${BASE}/albums/download?name={user}/{name}`, { user, name });
} }
static PLACE_LIST() {
return gen(`${BASE}/places`);
}
static PLACE_PREVIEW(place: number | string) {
return gen(`${BASE}/places/preview/{place}`, { place });
}
static TAG_LIST() { static TAG_LIST() {
return gen(`${BASE}/tags`); return gen(`${BASE}/tags`);
} }

View File

@ -8,3 +8,4 @@ export * from "./dav/folders";
export * from "./dav/onthisday"; export * from "./dav/onthisday";
export * from "./dav/tags"; export * from "./dav/tags";
export * from "./dav/other"; export * from "./dav/other";
export * from "./dav/places";

View File

@ -220,6 +220,10 @@ export function convertFlags(photo: IPhoto) {
} }
delete photo.isface; delete photo.isface;
} }
if (photo.isplace) {
photo.flag |= constants.c.FLAG_IS_PLACE;
delete photo.isplace;
}
if (photo.istag) { if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG; photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag; delete photo.istag;
@ -317,9 +321,10 @@ export const constants = {
FLAG_IS_TAG: 1 << 5, FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE_RECOGNIZE: 1 << 6, FLAG_IS_FACE_RECOGNIZE: 1 << 6,
FLAG_IS_FACE_RECOGNITION: 1 << 7, FLAG_IS_FACE_RECOGNITION: 1 << 7,
FLAG_IS_ALBUM: 1 << 8, FLAG_IS_PLACE: 1 << 8,
FLAG_SELECTED: 1 << 9, FLAG_IS_ALBUM: 1 << 9,
FLAG_LEAVING: 1 << 10, FLAG_SELECTED: 1 << 10,
FLAG_LEAVING: 1 << 11,
}, },
TagDayID: TagDayID, TagDayID: TagDayID,

View File

@ -0,0 +1,45 @@
import { IDay, IPhoto, ITag } from "../../types";
import { constants } from "../Utils";
import axios from "@nextcloud/axios";
import { API } from "../API";
/**
* Get list of tags and convert to Days response
*/
export async function getPlacesData(): Promise<IDay[]> {
// Query for photos
let data: {
osm_id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(API.PLACE_LIST());
data = res.data;
} catch (e) {
throw e;
}
// Add flag to previews
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response
return [
{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map(
(tag) =>
({
...tag,
id: tag.osm_id,
fileid: tag.osm_id,
flag: constants.c.FLAG_IS_TAG,
istag: true,
isplace: true,
} as ITag)
),
},
];
}

View File

@ -104,6 +104,8 @@ export type IPhoto = {
isalbum?: boolean; isalbum?: boolean;
/** Is this a face */ /** Is this a face */
isface?: "recognize" | "facerecognition"; isface?: "recognize" | "facerecognition";
/** Is this a place */
isplace?: boolean;
/** Optional datetaken epoch */ /** Optional datetaken epoch */
datetaken?: number; datetaken?: number;
}; };