f7cloud_client/apps/photos/lib/Service/ReverseGeoCoderService.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

173 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Photos\Service;
use Hexogen\KDTree\FSKDTree;
use Hexogen\KDTree\FSTreePersister;
use Hexogen\KDTree\Item;
use Hexogen\KDTree\ItemFactory;
use Hexogen\KDTree\ItemList;
use Hexogen\KDTree\KDTree;
use Hexogen\KDTree\NearestSearch;
use Hexogen\KDTree\Point;
use OCA\Photos\AppInfo\Application;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
class ReverseGeoCoderService {
public const CONFIG_DISABLE_PLACES = 'disable_places';
private ?ISimpleFolder $geoNameFolderCache = null;
private ?NearestSearch $fsSearcher = null;
/** @var array<int, string> */
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;
}
// Download zip file to a tmp file.
$response = $this->clientService->newClient()->get('https://download.f7cloud.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) {
throw new \Exception("Fail to unzip place file: $res", $res);
}
$cities1000TxtSteam = $zip->getStream('cities1000.txt');
// Dump the txt file info into a smaller csv file.
$destinationStream = $this->geoNameFolder()->newFile('cities1000.csv')->write();
while (($fields = fgetcsv($cities1000TxtSteam, 0, ' ')) !== false) {
$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');
}
}
$zip->close();
}
private function loadCities1000(): array {
$csvStream = $this->geoNameFolder()->getFile('cities1000.csv')->read();
$cities = [];
while (($fields = fgetcsv($csvStream)) !== false) {
$cities[] = [
'id' => (int)$fields[0],
'name' => $fields[1],
'latitude' => (float)$fields[2],
'longitude' => (float)$fields[3],
];
}
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(), 'f7cloud_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(), 'f7cloud_photos_');
file_put_contents($kdTreeTmpFileName, $kdTreeFileContent);
$fsTree = new FSKDTree($kdTreeTmpFileName, new ItemFactory());
$this->fsSearcher = new NearestSearch($fsTree);
unlink($kdTreeTmpFileName);
}
}