From 62579b1b89596b854958647e408714bc76b9ba0d Mon Sep 17 00:00:00 2001 From: Varun Patil Date: Sun, 5 Feb 2023 13:43:25 -0800 Subject: [PATCH] Initial places implementation --- appinfo/routes.php | 4 + lib/Controller/ApiBase.php | 50 +++++++++++ lib/Controller/DaysController.php | 7 ++ lib/Controller/PageController.php | 10 +++ lib/Controller/PlacesController.php | 99 ++++++++++++++++++++++ lib/Controller/TagsController.php | 38 ++------- lib/Db/TimelineQuery.php | 1 + lib/Db/TimelineQueryPlaces.php | 87 +++++++++++++++++++ lib/Db/TimelineWrite.php | 46 ++++++++++ src/App.vue | 7 ++ src/components/Timeline.vue | 10 +++ src/components/frame/Tag.vue | 15 ++++ src/components/top-matter/TagTopMatter.vue | 8 +- src/components/top-matter/TopMatter.vue | 6 +- src/router.ts | 9 ++ src/services/API.ts | 8 ++ src/services/DavRequests.ts | 1 + src/services/Utils.ts | 11 ++- src/services/dav/places.ts | 45 ++++++++++ src/types.ts | 2 + 20 files changed, 425 insertions(+), 39 deletions(-) create mode 100644 lib/Controller/PlacesController.php create mode 100644 lib/Db/TimelineQueryPlaces.php create mode 100644 src/services/dav/places.ts diff --git a/appinfo/routes.php b/appinfo/routes.php index aa549204..8cb158ed 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -27,6 +27,7 @@ return [ w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'), w(['name' => 'Page#recognize', 'url' => '/recognize/{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'), // Public folder share @@ -61,6 +62,9 @@ return [ ['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', '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' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'], diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php index 56cd16f7..7f3dda34 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -29,6 +29,9 @@ use OCA\Memories\Db\TimelineRoot; use OCA\Memories\Exif; use OCP\App\IAppManager; 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\Folder; use OCP\Files\IRootFolder; @@ -291,6 +294,45 @@ class ApiBase extends Controller 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. */ @@ -329,6 +371,14 @@ class ApiBase extends Controller 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. */ diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 765c98c0..2b4a9b0d 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -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 = $this->request->getParam('limit'); if ($limit) { diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index fb500503..31a30a1f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -215,6 +215,16 @@ class PageController extends Controller return $this->main(); } + /** + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function places() + { + return $this->main(); + } + /** * @NoAdminRequired * diff --git a/lib/Controller/PlacesController.php b/lib/Controller/PlacesController.php new file mode 100644 index 00000000..af8119e9 --- /dev/null +++ b/lib/Controller/PlacesController.php @@ -0,0 +1,99 @@ + + * @author Varun Patil + * @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 . + */ + +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)); + } +} diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index 77001b00..54ac632a 100644 --- a/lib/Controller/TagsController.php +++ b/lib/Controller/TagsController.php @@ -24,7 +24,6 @@ declare(strict_types=1); namespace OCA\Memories\Controller; use OCP\AppFramework\Http; -use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\JSONResponse; class TagsController extends ApiBase @@ -90,38 +89,11 @@ class TagsController extends ApiBase if (null === $list || 0 === \count($list)) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } + shuffle($list); - // Get preview manager - $previewManager = \OC::$server->get(\OCP\IPreview::class); - - // Try to get a preview - $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); + // Get preview from image list + return $this->getPreviewFromImageList(array_map(static function ($item) { + return $item['fileid']; + }, $list)); } } diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index b18869b8..956dcbc7 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -14,6 +14,7 @@ class TimelineQuery use TimelineQueryFilters; use TimelineQueryFolders; use TimelineQueryLivePhoto; + use TimelineQueryPlaces; use TimelineQueryPeopleFaceRecognition; use TimelineQueryPeopleRecognize; use TimelineQueryTags; diff --git a/lib/Db/TimelineQueryPlaces.php b/lib/Db/TimelineQueryPlaces.php new file mode 100644 index 00000000..cd32076b --- /dev/null +++ b/lib/Db/TimelineQueryPlaces.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index 27d14679..8e44eb58 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -154,6 +154,13 @@ class TimelineWrite $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) { // Update existing row // No need to set objectid again @@ -268,4 +275,43 @@ class TimelineWrite 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(); + } + } } diff --git a/src/App.vue b/src/App.vue index 519d2ae2..355d6f6b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -64,6 +64,7 @@ import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue"; import ArchiveIcon from "vue-material-design-icons/PackageDown.vue"; import CalendarIcon from "vue-material-design-icons/Calendar.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 MapIcon from "vue-material-design-icons/Map.vue"; @@ -88,6 +89,7 @@ export default defineComponent({ ArchiveIcon, CalendarIcon, PeopleIcon, + MarkerIcon, TagsIcon, MapIcon, }, @@ -246,6 +248,11 @@ export default defineComponent({ icon: CalendarIcon, title: t("memories", "On this day"), }, + { + name: "places", + icon: MarkerIcon, + title: t("memories", "Places"), + }, { name: "tags", icon: TagsIcon, diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index ee270be2..9ddfe78e 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -290,6 +290,8 @@ export default defineComponent({ return this.t("memories", "On this day"); case "tags": return this.t("memories", "Tags"); + case "places": + return this.t("memories", "Places"); default: return ""; } @@ -669,6 +671,12 @@ export default defineComponent({ query.set("tag", this.$route.params.name); } + // Places + if (this.$route.name === "places" && this.$route.params.name) { + const name = this.$route.params.name; + query.set("place", name.split("-", 2)[0]); + } + // Albums if (this.$route.name === "albums" && this.$route.params.name) { const user = this.$route.params.user; @@ -737,6 +745,8 @@ export default defineComponent({ data = await dav.getPeopleData(this.$route.name as any); } else if (this.$route.name === "albums" && !this.$route.params.name) { data = await dav.getAlbumsData("3"); + } else if (this.$route.name === "places" && !this.$route.params.name) { + data = await dav.getPlacesData(); } else { // Try the cache try { diff --git a/src/components/frame/Tag.vue b/src/components/frame/Tag.vue index e9fe9d0b..c0fc0091 100644 --- a/src/components/frame/Tag.vue +++ b/src/components/frame/Tag.vue @@ -68,6 +68,10 @@ export default defineComponent({ return getPreviewUrl(mock, true, 512); } + if (this.place) { + return API.PLACE_PREVIEW(this.place.fileid); + } + return API.TAG_PREVIEW(this.data.name); }, @@ -92,6 +96,10 @@ export default defineComponent({ : "recognize"; }, + place() { + return this.data.flag & constants.c.FLAG_IS_PLACE ? this.data : null; + }, + album() { return this.data.flag & constants.c.FLAG_IS_ALBUM ? this.data @@ -114,6 +122,13 @@ export default defineComponent({ 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 } }; }, diff --git a/src/components/top-matter/TagTopMatter.vue b/src/components/top-matter/TagTopMatter.vue index 1ef92618..ec6d5033 100644 --- a/src/components/top-matter/TagTopMatter.vue +++ b/src/components/top-matter/TagTopMatter.vue @@ -43,10 +43,14 @@ export default defineComponent({ methods: { createMatter() { this.name = this.$route.params.name || ""; + + if (this.$route.name === "places") { + this.name = this.name.split("-", 2)[1]; + } }, back() { - this.$router.push({ name: "tags" }); + this.$router.push({ name: this.$route.name }); }, }, }); @@ -66,4 +70,4 @@ export default defineComponent({ display: inline-block; } } - \ No newline at end of file + diff --git a/src/components/top-matter/TopMatter.vue b/src/components/top-matter/TopMatter.vue index 166c6448..d6c32945 100644 --- a/src/components/top-matter/TopMatter.vue +++ b/src/components/top-matter/TopMatter.vue @@ -58,6 +58,10 @@ export default defineComponent({ : TopMatterType.NONE; case "albums": return TopMatterType.ALBUM; + case "places": + return this.$route.params.name + ? TopMatterType.TAG + : TopMatterType.NONE; default: return TopMatterType.NONE; } @@ -65,4 +69,4 @@ export default defineComponent({ }, }, }); - \ No newline at end of file + diff --git a/src/router.ts b/src/router.ts index 12c6c784..5dd80e02 100644 --- a/src/router.ts +++ b/src/router.ts @@ -94,6 +94,15 @@ export default new Router({ }), }, + { + path: "/places/:name*", + component: Timeline, + name: "places", + props: (route) => ({ + rootTitle: t("memories", "Places"), + }), + }, + { path: "/tags/:name*", component: Timeline, diff --git a/src/services/API.ts b/src/services/API.ts index defd3836..e16e1e82 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -47,6 +47,14 @@ export class API { 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() { return gen(`${BASE}/tags`); } diff --git a/src/services/DavRequests.ts b/src/services/DavRequests.ts index c9b2420c..ff10e1e3 100644 --- a/src/services/DavRequests.ts +++ b/src/services/DavRequests.ts @@ -8,3 +8,4 @@ export * from "./dav/folders"; export * from "./dav/onthisday"; export * from "./dav/tags"; export * from "./dav/other"; +export * from "./dav/places"; diff --git a/src/services/Utils.ts b/src/services/Utils.ts index fefef193..6361e083 100644 --- a/src/services/Utils.ts +++ b/src/services/Utils.ts @@ -220,6 +220,10 @@ export function convertFlags(photo: IPhoto) { } delete photo.isface; } + if (photo.isplace) { + photo.flag |= constants.c.FLAG_IS_PLACE; + delete photo.isplace; + } if (photo.istag) { photo.flag |= constants.c.FLAG_IS_TAG; delete photo.istag; @@ -317,9 +321,10 @@ export const constants = { FLAG_IS_TAG: 1 << 5, FLAG_IS_FACE_RECOGNIZE: 1 << 6, FLAG_IS_FACE_RECOGNITION: 1 << 7, - FLAG_IS_ALBUM: 1 << 8, - FLAG_SELECTED: 1 << 9, - FLAG_LEAVING: 1 << 10, + FLAG_IS_PLACE: 1 << 8, + FLAG_IS_ALBUM: 1 << 9, + FLAG_SELECTED: 1 << 10, + FLAG_LEAVING: 1 << 11, }, TagDayID: TagDayID, diff --git a/src/services/dav/places.ts b/src/services/dav/places.ts new file mode 100644 index 00000000..bf9f2329 --- /dev/null +++ b/src/services/dav/places.ts @@ -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 { + // Query for photos + let data: { + osm_id: number; + count: number; + name: string; + previews: IPhoto[]; + }[] = []; + try { + const res = await axios.get(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) + ), + }, + ]; +} diff --git a/src/types.ts b/src/types.ts index 6920f254..ad7b1e38 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,8 @@ export type IPhoto = { isalbum?: boolean; /** Is this a face */ isface?: "recognize" | "facerecognition"; + /** Is this a place */ + isplace?: boolean; /** Optional datetaken epoch */ datetaken?: number; };