*/ private ?array $citiesMapping = null; public function __construct( private readonly IAppData $appData, private readonly IClientService $clientService, private readonly IConfig $config, ) { } public function getPlaceForCoordinates(float $latitude, float $longitude): string { $this->loadKdTree(); $result = $this->fsSearcher->search(new Point([$latitude, $longitude]), 1); return $this->getPlaceNameForPlaceId($result[0]->getId()); } private function geoNameFolder(): ISimpleFolder { if ($this->geoNameFolderCache === null) { try { $this->geoNameFolderCache = $this->appData->getFolder('geonames'); } catch (NotFoundException) { $this->geoNameFolderCache = $this->appData->newFolder('geonames'); } } return $this->geoNameFolderCache; } private function getPlaceNameForPlaceId(int $placeId): string { if ($this->citiesMapping === null) { $this->downloadCities1000(); $cities1000 = $this->loadCities1000(); $this->citiesMapping = []; foreach ($cities1000 as $city) { $this->citiesMapping[$city['id']] = $city['name']; } } return $this->citiesMapping[$placeId]; } public function arePlacesEnabled(): bool { if (!$this->config->getSystemValueBool('has_internet_connection', true)) { /* This feature cannot work without internet access */ return false; } return ($this->config->getAppValue(Application::APP_ID, self::CONFIG_DISABLE_PLACES, '0') !== '1'); } private function downloadCities1000(bool $force = false): void { if (!$this->arePlacesEnabled() || ($this->geoNameFolder()->fileExists('cities1000.csv') && !$force)) { return; } $response = $this->clientService->newClient()->get('https://download.nextcloud.com/server/apps/photos/cities1000.zip'); $tmpFile = tmpfile(); $cities1000ZipTmpFileName = stream_get_meta_data($tmpFile)['uri']; fclose($tmpFile); file_put_contents($cities1000ZipTmpFileName, $response->getBody()); // Unzip the txt file into a stream. $zip = new \ZipArchive; $res = $zip->open($cities1000ZipTmpFileName); if ($res !== true) { unlink($cities1000ZipTmpFileName); throw new \Exception("Failed to unzip place file: $res", $res); } $cities1000TxtStream = $zip->getStream('cities1000.txt'); if ($cities1000TxtStream === false) { $zip->close(); unlink($cities1000ZipTmpFileName); throw new \Exception("Could not extract 'cities1000.txt' from ZIP archive."); } // Dump the txt file info into a smaller csv file. $destinationStream = $this->geoNameFolder()->newFile('cities1000.csv')->write(); if ($destinationStream === false || !is_resource($destinationStream)) { fclose($cities1000TxtStream); $zip->close(); unlink($cities1000ZipTmpFileName); throw new \Exception('Failed to open destination stream for cities1000.csv'); } try { while (($fields = fgetcsv($cities1000TxtStream, 0, "\t")) !== false) { if (count($fields) < 6) { throw new \Exception('Malformed cities1000.txt row: expected at least 6 fields, got ' . count($fields)); } $result = fputcsv( $destinationStream, [ 'id' => (int)$fields[0], 'name' => $fields[1], 'latitude' => (float)$fields[4], 'longitude' => (float)$fields[5], ] ); if ($result === false) { throw new \Exception('Failed to write csv line to tmp stream'); } } } finally { fclose($cities1000TxtStream); fclose($destinationStream); $zip->close(); unlink($cities1000ZipTmpFileName); } } private function loadCities1000(): array { $csvStream = $this->geoNameFolder()->getFile('cities1000.csv')->read(); if ($csvStream === false || !is_resource($csvStream)) { throw new \Exception('Failed to open cities1000.csv for reading.'); } $cities = []; while (($fields = fgetcsv($csvStream, 0, ',', '"', '\\')) !== false) { if (count($fields) < 4) { fclose($csvStream); throw new \Exception('Malformed cities1000.csv row: expected at least 4 fields, got ' . count($fields)); } $cities[] = [ 'id' => (int)$fields[0], 'name' => $fields[1], 'latitude' => (float)$fields[2], 'longitude' => (float)$fields[3], ]; } fclose($csvStream); return $cities; } public function buildKDTree($force = false): void { if ($this->geoNameFolder()->fileExists('cities1000.bin') && !$force) { return; } $this->downloadCities1000($force); $cities1000 = $this->loadCities1000(); $itemList = new ItemList(2); foreach ($cities1000 as $city) { $itemList->addItem(new Item($city['id'], [$city['latitude'], $city['longitude']])); } $tree = new KDTree($itemList); // Persiste KDTree in app data. $persister = new FSTreePersister('/'); $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), 'nextcloud_photos_'); $persister->convert($tree, $kdTreeTmpFileName); $kdTreeString = file_get_contents($kdTreeTmpFileName); $this->geoNameFolder()->newFile('cities1000.bin', $kdTreeString); unlink($kdTreeTmpFileName); } private function loadKdTree(): void { if ($this->fsSearcher !== null) { return; } $this->buildKDTree(); $kdTreeFileContent = $this->geoNameFolder()->getFile('cities1000.bin')->getContent(); $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), 'nextcloud_photos_'); file_put_contents($kdTreeTmpFileName, $kdTreeFileContent); $fsTree = new FSKDTree($kdTreeTmpFileName, new ItemFactory()); $this->fsSearcher = new NearestSearch($fsTree); unlink($kdTreeTmpFileName); } }