feat(todo): the server finds places through the instance's Photon (ADR 0033, D32)

Todo's locations search through the person's own instance, as Train's
planner does, not a public service: `SearchEndpoint` on `PlaceSearch`, for
those signed in, 60 searches a minute, nothing kept or logged. Its `Place`
carries the address's parts and the object in OpenStreetMap, and a shop's
car park or charger is left out, which came first for `Lindau EDEKA`.

`compose.todo-search.yaml` puts Todo's server on compose.search.yaml's
Photon. A file of its own: without it Todo starts as it did, and offers no
search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 14:58:38 +01:00
commit c9d3eb4dd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1880 additions and 12 deletions

View file

@ -13,6 +13,10 @@
import 'dart:async' as _ida;
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/search/place_point.dart'
as _icyq0v3n;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -26,6 +30,44 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_todo_client/migrations/migration_registry.dart';
/// Places found by the instance's Photon (ADR 0033, D32), for those signed
/// in: Photon has no sign-in of its own, so the server alone reaches it.
/// Neither the query nor the places are kept or logged, and a task's location
/// stays in its sealed payload. Refusals are `SearchRefused`.
/// {@category Endpoint}
class EndpointSearch extends _isc.EndpointRef {
EndpointSearch(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'search';
/// Whether the instance searches for places.
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
'search',
'offered',
{},
);
/// The places [query] finds, nearest [near] first (the map's centre, or
/// the person), named in [language] (`en`, `de`; else each its own name).
/// Refused where the instance has no Photon or it fails, for a query of
/// fewer than `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery`
/// characters, and beyond `PlaceSearch.perMinute` searches a minute.
_ida.Future<List<_i15767wh.Place>> places(
String query, {
_icyq0v3n.PlacePoint? near,
String? language,
}) => caller.callServerEndpoint<List<_i15767wh.Place>>(
'search',
'places',
{
'query': query,
'near': near,
'language': language,
},
);
}
class Modules {
Modules(Client client) {
base = _ihk4p5t7.Caller(client);
@ -73,13 +115,16 @@ class Client extends _isc.ServerpodClientShared {
disconnectStreamsOnLostInternetConnection,
httpClientOverride: httpClientOverride,
) {
search = EndpointSearch(this);
modules = Modules(this);
}
late final EndpointSearch search;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {};
Map<String, _isc.EndpointRef> get endpointRefLookup => {'search': search};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -13,6 +13,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -25,12 +27,18 @@ import 'label/label.dart' as _ivak08z4;
import 'privacy/content_key.dart' as _imed3x5d;
import 'project/project.dart' as _i9c44spp;
import 'project/section.dart' as _imadkvi9;
import 'search/place.dart' as _iadh29yw;
import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
export 'label/label.dart';
export 'privacy/content_key.dart';
export 'project/project.dart';
export 'project/section.dart';
export 'search/place.dart';
export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'client.dart';
@ -501,6 +509,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _imadkvi9.Section) {
return _imadkvi9.Section.fromJson(data) as T;
}
if (t == _iadh29yw.Place) {
return _iadh29yw.Place.fromJson(data) as T;
}
if (t == _itf0ur2u.PlacePoint) {
return _itf0ur2u.PlacePoint.fromJson(data) as T;
}
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _icak60vd.TodoSettings) {
return _icak60vd.TodoSettings.fromJson(data) as T;
}
@ -519,12 +536,26 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_imadkvi9.Section?>()) {
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iadh29yw.Place?>()) {
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
}
if (t == _isc.getType<_itf0ur2u.PlacePoint?>()) {
return (data != null ? _itf0ur2u.PlacePoint.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ihbxkwke.SearchRefused?>()) {
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_icak60vd.TodoSettings?>()) {
return (data != null ? _icak60vd.TodoSettings.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == List<_i15767wh.Place>) {
return (data as List).map((e) => deserialize<_i15767wh.Place>(e)).toList()
as T;
}
try {
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -549,6 +580,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
_imed3x5d.ContentKey => 'ContentKey',
_i9c44spp.Project => 'Project',
_imadkvi9.Section => 'Section',
_iadh29yw.Place => 'Place',
_itf0ur2u.PlacePoint => 'PlacePoint',
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_ => null,
@ -576,6 +610,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'Project';
case _imadkvi9.Section():
return 'Section';
case _iadh29yw.Place():
return 'Place';
case _itf0ur2u.PlacePoint():
return 'PlacePoint';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _icak60vd.TodoSettings():
return 'TodoSettings';
case _ib4f8uyw.Task():
@ -628,6 +668,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Section') {
return deserialize<_imadkvi9.Section>(data['data']);
}
if (dataClassName == 'Place') {
return deserialize<_iadh29yw.Place>(data['data']);
}
if (dataClassName == 'PlacePoint') {
return deserialize<_itf0ur2u.PlacePoint>(data['data']);
}
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'TodoSettings') {
return deserialize<_icak60vd.TodoSettings>(data['data']);
}

View file

@ -0,0 +1,226 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A place found by the instance's search (ADR 0033, D32): what the location
/// sheet lists and a task's location is made of. Never stored by the server:
/// a task's location is in its sealed payload.
abstract class Place
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Place._({
required this.name,
this.detail,
required this.kind,
this.osmValue,
this.osmType,
this.osmId,
this.street,
this.housenumber,
this.district,
this.city,
required this.lat,
required this.lon,
});
factory Place({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) = _PlaceImpl;
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
return Place(
name: jsonSerialization['name'] as String,
detail: jsonSerialization['detail'] as String?,
kind: jsonSerialization['kind'] as String,
osmValue: jsonSerialization['osmValue'] as String?,
osmType: jsonSerialization['osmType'] as String?,
osmId: jsonSerialization['osmId'] as int?,
street: jsonSerialization['street'] as String?,
housenumber: jsonSerialization['housenumber'] as String?,
district: jsonSerialization['district'] as String?,
city: jsonSerialization['city'] as String?,
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
/// Its name, or its street and number for an address.
String name;
/// Where it is, as the list shows under the name: its street, its town and
/// its country, those the name doesn't say already; null where none.
String? detail;
/// What kind of place, as Photon names it (`city`, `street`, `house`,
/// `locality`, `other`, …).
String kind;
/// What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
/// …); null where Photon doesn't say.
String? osmValue;
/// Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
/// its ID; what its opening hours are looked up and its page opened by.
String? osmType;
int? osmId;
/// Its address, each part where Photon has it.
String? street;
String? housenumber;
String? district;
String? city;
double lat;
double lon;
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Place copyWith({
String? name,
String? detail,
String? kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PlaceImpl extends Place {
_PlaceImpl({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) : super._(
name: name,
detail: detail,
kind: kind,
osmValue: osmValue,
osmType: osmType,
osmId: osmId,
street: street,
housenumber: housenumber,
district: district,
city: city,
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Place copyWith({
String? name,
Object? detail = _Undefined,
String? kind,
Object? osmValue = _Undefined,
Object? osmType = _Undefined,
Object? osmId = _Undefined,
Object? street = _Undefined,
Object? housenumber = _Undefined,
Object? district = _Undefined,
Object? city = _Undefined,
double? lat,
double? lon,
}) {
return Place(
name: name ?? this.name,
detail: detail is String? ? detail : this.detail,
kind: kind ?? this.kind,
osmValue: osmValue is String? ? osmValue : this.osmValue,
osmType: osmType is String? ? osmType : this.osmType,
osmId: osmId is int? ? osmId : this.osmId,
street: street is String? ? street : this.street,
housenumber: housenumber is String? ? housenumber : this.housenumber,
district: district is String? ? district : this.district,
city: city is String? ? city : this.city,
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,93 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A point on the earth, in degrees (WGS 84): what a search's places are
/// nearest to first (ADR 0033, D32).
abstract class PlacePoint
implements _isc.SerializableModel, _isc.ProtocolSerialization {
PlacePoint._({
required this.lat,
required this.lon,
});
factory PlacePoint({
required double lat,
required double lon,
}) = _PlacePointImpl;
factory PlacePoint.fromJson(Map<String, dynamic> jsonSerialization) {
return PlacePoint(
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
double lat;
double lon;
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
PlacePoint copyWith({
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _PlacePointImpl extends PlacePoint {
_PlacePointImpl({
required double lat,
required double lon,
}) : super._(
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
PlacePoint copyWith({
double? lat,
double? lon,
}) {
return PlacePoint(
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,71 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why the instance searches nothing (ADR 0033, D32): `off` where it has no
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
/// `unreachable` where Photon didn't answer.
abstract class SearchRefused
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
SearchRefused._({required this.reason});
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return SearchRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SearchRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
String toString() {
return 'SearchRefused(reason: $reason)';
}
}
class _SearchRefusedImpl extends SearchRefused {
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SearchRefused copyWith({String? reason}) {
return SearchRefused(reason: reason ?? this.reason);
}
}