repository = $repository; } /** * Hangt zichzelf op aan `rest_api_init`. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Registreert alle routes. * * Elke route heeft een permission_callback — verplicht sinds WP 5.5, en * `__return_true` is hier nergens het juiste antwoord. */ public function register_routes(): void { $id_arg = array( 'id' => array( 'description' => __( 'Attachment ID.', 'yinga-focus' ), 'type' => 'integer', 'required' => true, 'validate_callback' => static fn( $value ) => is_numeric( $value ) && (int) $value > 0, 'sanitize_callback' => 'absint', ), ); register_rest_route( Naming::REST_NAMESPACE, '/attachment/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( Permissions::class, 'can_read' ), 'args' => $id_arg, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( Permissions::class, 'can_edit' ), 'args' => $id_arg + array( 'focus' => array( 'description' => __( 'Focal point, normalised 0..1.', 'yinga-focus' ), 'type' => array( 'object', 'null' ), ), 'safeArea' => array( 'description' => __( 'Safe area, normalised 0..1.', 'yinga-focus' ), 'type' => array( 'object', 'null' ), ), 'textZone' => array( 'description' => __( 'Text zone in container fractions: where the theme lays text over the photo; the subject is placed beside it.', 'yinga-focus' ), 'type' => array( 'object', 'null' ), ), 'points' => array( 'description' => __( 'Secondary focal points.', 'yinga-focus' ), 'type' => 'array', ), 'breakpoints' => array( 'description' => __( 'Per-breakpoint overrides.', 'yinga-focus' ), 'type' => 'object', ), 'source' => array( 'description' => __( 'Where the focal data came from.', 'yinga-focus' ), 'type' => 'object', ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( Permissions::class, 'can_edit' ), 'args' => $id_arg, ), ) ); register_rest_route( Naming::REST_NAMESPACE, '/attachment/(?P\d+)/resolve', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'resolve_item' ), 'permission_callback' => array( Permissions::class, 'can_read' ), 'args' => $id_arg + array( 'width' => array( 'description' => __( 'Container width.', 'yinga-focus' ), 'type' => 'number', 'required' => true, 'validate_callback' => static fn( $v ) => is_numeric( $v ) && (float) $v > 0, ), 'height' => array( 'description' => __( 'Container height.', 'yinga-focus' ), 'type' => 'number', 'required' => true, 'validate_callback' => static fn( $v ) => is_numeric( $v ) && (float) $v > 0, ), ), ) ); } /** * GET /attachment/{id} * * @param WP_REST_Request $request Het request. * @return WP_REST_Response|WP_Error */ public function get_item( WP_REST_Request $request ) { $id = (int) $request['id']; $error = $this->guard_attachment( $id ); if ( $error ) { return $error; } return new WP_REST_Response( $this->prepare( $id ), 200 ); } /** * POST/PUT/PATCH /attachment/{id} * * Partiële update: alleen meegestuurde sleutels worden gewijzigd. * * @param WP_REST_Request $request Het request. * @return WP_REST_Response|WP_Error */ public function update_item( WP_REST_Request $request ) { $id = (int) $request['id']; $error = $this->guard_attachment( $id ); if ( $error ) { return $error; } $patch = array(); foreach ( array( 'focus', 'safeArea', 'textZone', 'points', 'breakpoints', 'source' ) as $key ) { if ( null !== $request->get_param( $key ) || $request->has_param( $key ) ) { $patch[ $key ] = $request->get_param( $key ); } } if ( array() === $patch ) { return new WP_Error( 'yinga_focus_empty_update', __( 'No fields to update.', 'yinga-focus' ), array( 'status' => 400 ) ); } $this->repository->patch( $id, $patch ); return new WP_REST_Response( $this->prepare( $id ), 200 ); } /** * DELETE /attachment/{id} * * @param WP_REST_Request $request Het request. * @return WP_REST_Response|WP_Error */ public function delete_item( WP_REST_Request $request ) { $id = (int) $request['id']; $error = $this->guard_attachment( $id ); if ( $error ) { return $error; } $this->repository->delete( $id ); return new WP_REST_Response( array( 'id' => $id, 'deleted' => true, 'focus' => null, ), 200 ); } /** * GET /attachment/{id}/resolve?width=&height= * * Geeft de uitkomst van de crop-engine voor een concrete verhouding, zodat * editor, builder-preview en frontend gegarandeerd hetzelfde antwoord * gebruiken. * * @param WP_REST_Request $request Het request. * @return WP_REST_Response|WP_Error */ public function resolve_item( WP_REST_Request $request ) { $id = (int) $request['id']; $error = $this->guard_attachment( $id ); if ( $error ) { return $error; } $result = $this->repository->resolve( $id, (float) $request['width'], (float) $request['height'] ); if ( null === $result ) { return new WP_Error( 'yinga_focus_not_resolvable', __( 'This image has no focal data or no known dimensions.', 'yinga-focus' ), array( 'status' => 404 ) ); } $result['css'] = Crop::to_css( $result['x'], $result['y'] ); return new WP_REST_Response( $result, 200 ); } /** * Bouwt de responsvorm voor één attachment. * * @param int $id Attachment-ID. * @return array */ private function prepare( int $id ): array { $data = $this->repository->get( $id ); $dims = $this->repository->dimensions( $id ); return array( 'id' => $id, 'title' => get_the_title( $id ), 'filename' => wp_basename( (string) get_attached_file( $id ) ), 'hasFocus' => null !== $data, 'dimensions' => $dims, 'url' => wp_get_attachment_image_url( $id, 'full' ), 'schemaVersion' => Schema::VERSION, 'focus' => $data['focus'] ?? null, 'safeArea' => $data['safeArea'] ?? null, 'textZone' => $data['textZone'] ?? null, 'points' => $data['points'] ?? array(), 'breakpoints' => $data['breakpoints'] ?? Schema::blank()['breakpoints'], 'source' => $data['source'] ?? Schema::blank()['source'], // Tier 1: aspectratio-onafhankelijk, direct bruikbaar in CSS. 'cssPosition' => null !== $data && null !== $data['focus'] ? Crop::focus_to_css( $data['focus'] ) : null, // Voor de bewerkmodus op de voorkant: is het bestand zelf rond het // focuspunt gesneden, en met welke versie? Dan ververst de browser // na een save de échte pixels in plaats van een positietruc toe te // passen die na herladen niet bestaat. 'fileCropped' => class_exists( \Yinga\Focus\Media\FocalCrops::class ) && \Yinga\Focus\Media\FocalCrops::is_cropped( $id ), 'fileVersion' => class_exists( \Yinga\Focus\Media\FocalCrops::class ) ? \Yinga\Focus\Media\FocalCrops::version( $id ) : '', ); } /** * Controleert dat de ID daadwerkelijk een attachment is. * * @param int $id Attachment-ID. * @return WP_Error|null */ private function guard_attachment( int $id ): ?WP_Error { if ( 'attachment' !== get_post_type( $id ) ) { return new WP_Error( 'yinga_focus_not_found', __( 'This ID does not belong to a media file.', 'yinga-focus' ), array( 'status' => 404 ) ); } return null; } }