memo ) { $this->memo = array(); foreach ( Db::rows( 'SELECT * FROM %i ORDER BY position, id', Db::table( $this->table() ) ) as $row ) { $item = Lookup::from_row( $row ); $this->memo[ $item->id ] = $item; } } return $this->memo; } public function active(): array { return array_values( array_filter( $this->all(), static fn( Lookup $l ): bool => $l->is_active() ) ); } public function get( int $id ): ?Lookup { return $this->all()[ $id ] ?? null; } public function by_slug( string $slug ): ?Lookup { foreach ( $this->all() as $item ) { if ( $item->slug === $slug ) { return $item; } } return null; } public function labels( array $items, string $lang = '' ): array { $own = array(); $out = array(); $builtins = $this->builtin_labels(); foreach ( $items as $item ) { if ( '' === $item->label ) { $out[ $item->id ] = $builtins[ $item->slug ] ?? $item->slug; } else { $own[ $item->id ] = $item->label; } } if ( array() !== $own ) { $out = Services::strings()->translate_many( $this->text_object(), $own, '' !== $lang ? $lang : Names::display_language(), 'label' ) + $out; } return $out; } public function label( Lookup $item, string $lang = '' ): string { return $this->labels( array( $item ), $lang )[ $item->id ] ?? $item->slug; } public function create( string $label, Actor $actor, string $slug = '', string $lang = '', string $color = '', string $icon = '' ): Lookup { self::authorize( $actor ); $label = $this->clean_label( $label ); if ( '' === $label ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'label' ) ); } $color = self::clean_color( $color ); $icon = self::clean_icon( $icon ); $typed = '' !== trim( $slug ); $table = Db::table( $this->table() ); $style = $this->has_style(); $id = (int) Transaction::run( static function () use ( $table, $label, $slug, $typed, $color, $icon, $style ): int { $taken = array_map( 'strval', Db::col( 'SELECT slug FROM %i FOR UPDATE', $table ) ); $base = Slug::from_name( $typed ? $slug : $label, self::SLUG_MAX, '-' ); $base = '' !== $base ? $base : 'item'; if ( $typed && in_array( $base, $taken, true ) ) { DomainError::raise( ErrorCode::SLUG_TAKEN ); } $final = Slug::unique( $base, $taken, self::SLUG_MAX, '-' ); $pos = (int) Db::value( 'SELECT COALESCE(MAX(position), 0) FROM %i', $table ) + 1; if ( $style ) { Db::query( 'INSERT INTO %i (slug, label, color, icon, position, state, builtin) VALUES (%s, %s, %s, %s, %d, %d, 0)', $table, $final, $label, $color, $icon, $pos, Lookup::ACTIVE ); } else { Db::query( 'INSERT INTO %i (slug, label, position, state, builtin) VALUES (%s, %s, %d, %d, 0)', $table, $final, $label, $pos, Lookup::ACTIVE ); } return Db::insert_id(); } ); $this->forget(); Services::strings()->register( $this->text_object(), $id, $label, 'label' ); Services::strings()->created_in( $this->text_object(), $id, $label, $lang, Actor::system(), 'label' ); return $this->get( $id ); } public function update( int $id, ?string $label, Actor $actor, string $lang = '', ?string $slug = null, ?string $color = null, ?string $icon = null ): Lookup { self::authorize( $actor ); $old = $this->get( $id ); if ( null === $old ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $stored = $old->label; if ( null !== $label ) { $label = $this->clean_label( $label ); if ( ! Strings::writes_stored( $lang ) && '' !== $old->label ) { Services::strings()->save_translation( $this->text_object(), $id, $old->label, $lang, $label, Actor::system(), 'label' ); } else { if ( '' === $label && ! $old->builtin ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'label' ) ); } $stored = $label; } } $slugv = $old->slug; if ( ! $old->builtin ) { if ( null !== $slug && '' !== trim( $slug ) ) { $slugv = Slug::clean( $slug, self::SLUG_MAX, '-' ); } elseif ( $stored !== $old->label && '' !== $stored && Slug::follows( $old->slug, $old->label, self::SLUG_MAX, '-' ) ) { $slugv = Slug::from_name( $stored, self::SLUG_MAX, '-' ); } if ( '' === $slugv ) { $slugv = $old->slug; } } $colorv = null === $color ? $old->color : self::clean_color( $color ); $iconv = null === $icon ? $old->icon : self::clean_icon( $icon ); $table = Db::table( $this->table() ); $style = $this->has_style(); Transaction::run( static function () use ( $table, $id, $stored, $slugv, $old, $colorv, $iconv, $style ): void { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', $table, $id ); if ( $slugv !== $old->slug ) { $taken = array_map( 'strval', Db::col( 'SELECT slug FROM %i WHERE id <> %d FOR UPDATE', $table, $id ) ); $slugv = Slug::unique( $slugv, $taken, self::SLUG_MAX, '-' ); } if ( $style ) { Db::query( 'UPDATE %i SET label = %s, slug = %s, color = %s, icon = %s WHERE id = %d', $table, $stored, $slugv, $colorv, $iconv, $id ); } else { Db::query( 'UPDATE %i SET label = %s, slug = %s WHERE id = %d', $table, $stored, $slugv, $id ); } } ); $this->forget(); if ( $stored !== $old->label ) { if ( '' === $stored ) { Services::strings()->unregister( $this->text_object(), array( $id ) ); } else { Services::strings()->register( $this->text_object(), $id, $stored, 'label', $old->label ); } } return $this->get( $id ); } public function set_archived( int $id, bool $archived, Actor $actor ): Lookup { self::authorize( $actor ); if ( null === $this->get( $id ) ) { DomainError::raise( ErrorCode::NOT_FOUND ); } Db::query( 'UPDATE %i SET state = %d WHERE id = %d', Db::table( $this->table() ), $archived ? Lookup::ARCHIVED : Lookup::ACTIVE, $id ); $this->forget(); return $this->get( $id ); } public function uses( int $id ): int { return (int) Db::value( 'SELECT COUNT(*) FROM %i WHERE %i = %d', Db::table( 'courses' ), $this->column(), $id ); } public function uses_all(): array { $out = array(); foreach ( Db::rows( 'SELECT %i AS id, COUNT(*) AS n FROM %i WHERE %i IS NOT NULL GROUP BY %i', $this->column(), Db::table( 'courses' ), $this->column(), $this->column() ) as $row ) { $out[ (int) $row['id'] ] = (int) $row['n']; } return $out; } public function delete( int $id, Actor $actor ): string { self::authorize( $actor ); $old = $this->get( $id ); if ( null === $old ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $table = Db::table( $this->table() ); $column = $this->column(); $text = $this->text_object(); $import = $this->import_object(); $done = (string) Transaction::run( static function () use ( $table, $column, $id, $old, $text, $import ): string { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', $table, $id ); $used = (int) Db::value( 'SELECT COUNT(*) FROM %i WHERE %i = %d', Db::table( 'courses' ), $column, $id ); if ( $used > 0 || $old->builtin ) { Db::query( 'UPDATE %i SET state = %d WHERE id = %d', $table, Lookup::ARCHIVED, $id ); return 'archived'; } Db::query( 'DELETE FROM %i WHERE id = %d', $table, $id ); Services::strings()->delete_objects( $text, array( $id ) ); Db::query( 'DELETE FROM %i WHERE object_type = %d AND object_id = %d', Db::table( 'import_map' ), $import->value, $id ); return 'deleted'; } ); $this->forget(); return $done; } public function reorder( array $ids, Actor $actor ): void { self::authorize( $actor ); $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); $table = Db::table( $this->table() ); Transaction::run( static function () use ( $table, $ids ): void { $have = array_map( 'intval', Db::col( 'SELECT id FROM %i ORDER BY position, id FOR UPDATE', $table ) ); $order = array_merge( array_values( array_intersect( $ids, $have ) ), array_values( array_diff( $have, $ids ) ) ); foreach ( $order as $i => $id ) { Db::query( 'UPDATE %i SET position = %d WHERE id = %d', $table, $i + 1, $id ); } } ); $this->forget(); } public static function can_write( int $user_id ): bool { return $user_id > 0 && Users::can( $user_id, self::CAP ); } public static function authorize( Actor $actor ): void { if ( $actor->is_trusted() ) { return; } if ( $actor->is_learner() || ! self::can_write( $actor->user_id ) ) { DomainError::raise( ErrorCode::FORBIDDEN ); } } public function forget(): void { $this->memo = null; } protected function has_style(): bool { return false; } private function clean_label( string $label ): string { return mb_substr( trim( sanitize_text_field( $label ) ), 0, $this->label_max() ); } public static function clean_color( string $color ): string { $color = (string) sanitize_hex_color( strtolower( trim( $color ) ) ); return 7 === strlen( $color ) ? $color : ''; } public static function clean_icon( string $icon ): string { return substr( (string) preg_replace( '/[^a-z0-9-]/', '', strtolower( $icon ) ), 0, 64 ); } }