diff --git a/.gitignore b/.gitignore index ce38e5a8b0..2349c53c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,21 @@ yarn-error.log package-lock.json .phpstorm.meta.php +meilisearch +data.ms/instance-uid +data.ms/VERSION +.gitignore +data.ms/auth/data.mdb +.gitignore +data.ms/auth/lock.mdb +data.ms/tasks/data.mdb +data.ms/tasks/lock.mdb +meili_data/data.ms/instance-uid +.gitignore +meili_data/data.ms/VERSION +meili_data/data.ms/auth/data.mdb +.gitignore +meili_data/data.ms/tasks/data.mdb +meili_data/data.ms/auth/lock.mdb +.gitignore +meili_data/data.ms/tasks/lock.mdb diff --git a/app/Console/Commands/SetupMeilisearch.php b/app/Console/Commands/SetupMeilisearch.php new file mode 100644 index 0000000000..7e783100b5 --- /dev/null +++ b/app/Console/Commands/SetupMeilisearch.php @@ -0,0 +1,86 @@ +getKeys(); + $client->index('entities')->resetSeparatorTokens(); + $client->index('entities') + ->updateNonSeparatorTokens([':']); + $models = [ + Attribute::class, + Ability::class, + Calendar::class, + Character::class, + Creature::class, + Event::class, + Family::class, + Item::class, + Journal::class, + Location::class, + Map::class, + Note::class, + Organisation::class, + Quest::class, + Race::class, + Timeline::class, + Tag::class, + Post::class, + QuestElement::class, + TimelineElement::class, + ]; + foreach ($models as $model) { + $object = new $model(); + $object::makeAllSearchable($this->option('chunk')); + $this->info('All [' . $model . '] records have been imported.'); + } + } +} diff --git a/app/Http/Controllers/Api/v1/FullTextSearchApiController.php b/app/Http/Controllers/Api/v1/FullTextSearchApiController.php new file mode 100644 index 0000000000..ea66227d49 --- /dev/null +++ b/app/Http/Controllers/Api/v1/FullTextSearchApiController.php @@ -0,0 +1,36 @@ +service = $service; + } + + /** + * return \Illuminate\Http\Resources\Json\AnonymousResourceCollection + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function index(Campaign $campaign) + { + $this->authorize('access', $campaign); + $term = request()->term; + $entity = Entity::where(['name' => request()->term, 'campaign_id' => $campaign->id])->first(); + if ($entity) { + $term2 = $entity->type() . ':' . $entity->id; + } + + $results = $this->service + ->campaign($campaign) + ->search($term, $term2); + return $results; + } +} diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php index ff47394320..4abd8343aa 100644 --- a/app/Models/Attribute.php +++ b/app/Models/Attribute.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Str; +use Laravel\Scout\Searchable; /** * Class Attribute @@ -37,6 +38,7 @@ class Attribute extends Model use Paginatable; use Pinnable; use Privatable; + use Searchable; public const TYPE_CHECKBOX = 'checkbox'; public const TYPE_SECTION = 'section'; @@ -379,4 +381,41 @@ public function exportFields(): array 'is_hidden', ]; } + + /** + * Get the value used to index the model. + * + */ + public function getScoutKey() + { + return $this->getTable() . '_' . $this->id; + } + + /** + * Get the name of the index associated with the model. + */ + public function searchableAs(): string + { + return 'entities'; + } + + protected function makeAllSearchableUsing($query) + { + return $query + ->select([$this->getTable() . '.*', 'entities.id as entity_id']) + ->leftJoin('entities', $this->getTable() . '.entity_id', '=', 'entities.id') + ->has('entity') + ->with('entity'); + } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->entity->campaign_id, + 'entity_id' => $this->entity_id, + 'name' => $this->name, + 'type' => 'attribute', + 'entry' => $this->value, + ]; + } } diff --git a/app/Models/AttributeTemplate.php b/app/Models/AttributeTemplate.php index 6201802ad1..4a65f3b17b 100644 --- a/app/Models/AttributeTemplate.php +++ b/app/Models/AttributeTemplate.php @@ -258,4 +258,13 @@ public function datagridSortableColumns(): array } return $columns; } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->entity->campaign_id, + 'entity_id' => $this->entity->id, + 'name' => $this->name, + ]; + } } diff --git a/app/Models/MiscModel.php b/app/Models/MiscModel.php index 0f652ef842..447c07d51d 100644 --- a/app/Models/MiscModel.php +++ b/app/Models/MiscModel.php @@ -20,6 +20,7 @@ use Illuminate\Support\Facades\DB; use Exception; use Illuminate\Support\Str; +use Laravel\Scout\Searchable as Scout; /** * Class MiscModel @@ -48,12 +49,14 @@ abstract class MiscModel extends Model use LastSync; use Orderable; use Paginatable; + use Scout; use Searchable; //Tooltip, use Sortable; use SourceCopiable; use SubEntityScopes; + /** @var Entity Performance based entity */ protected Entity $cachedEntity; @@ -624,4 +627,44 @@ public function datagridSortableColumns(): array } return $columns; } + + /** + * Get the value used to index the model. + * + */ + public function getScoutKey() + { + return $this->getTable() . '_' . $this->id; + } + + /** + * Get the name of the index associated with the model. + */ + public function searchableAs(): string + { + return 'entities'; + } + + protected function makeAllSearchableUsing($query) + { + return $query + ->select([$this->getTable() . '.*', 'entities.id as entity_id']) + ->leftJoin('entities', function ($join) { + $join->on('entities.entity_id', $this->getTable() . '.id') + ->where('entities.type_id', $this->entityTypeId()); + }) + ->has('entity') + ->with('entity'); + } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->entity->campaign_id, + 'entity_id' => $this->entity->id, + 'name' => $this->name, + 'type' => $this->type, + 'entry' => $this->entry, + ]; + } } diff --git a/app/Models/Post.php b/app/Models/Post.php index 68694abd4a..b94c0992c4 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Laravel\Scout\Searchable; /** * Class Attribute @@ -47,6 +48,7 @@ class Post extends Model use Blameable; use HasFactory; use Paginatable; + use Searchable; use VisibilityIDTrait; /** @var string[] */ @@ -188,4 +190,41 @@ public function editingUsers() ->using(EntityUser::class) ->withPivot('type_id'); } + + /** + * Get the value used to index the model. + * + */ + public function getScoutKey() + { + return $this->getTable() . '_' . $this->id; + } + + /** + * Get the name of the index associated with the model. + */ + public function searchableAs(): string + { + return 'entities'; + } + + protected function makeAllSearchableUsing($query) + { + return $query + ->select([$this->getTable() . '.*', 'entities.id as entity_id']) + ->leftJoin('entities', $this->getTable() . '.entity_id', '=', 'entities.id') + ->has('entity') + ->with('entity'); + } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->entity->campaign_id, + 'entity_id' => $this->entity_id, + 'name' => $this->name, + 'type' => 'post', + 'entry' => $this->entry, + ]; + } } diff --git a/app/Models/QuestElement.php b/app/Models/QuestElement.php index 600d4c0fc1..7f36f86fe6 100644 --- a/app/Models/QuestElement.php +++ b/app/Models/QuestElement.php @@ -9,6 +9,7 @@ use App\Traits\VisibilityIDTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Laravel\Scout\Searchable; /** * Class QuestCharacter @@ -27,6 +28,7 @@ class QuestElement extends Model { use Blameable; use HasFactory; + use Searchable; /** * Traits */ @@ -122,4 +124,45 @@ public function editingUsers() ->using(EntityUser::class) ->withPivot('type_id'); } + + /** + * Get the value used to index the model. + * + */ + public function getScoutKey() + { + return $this->getTable() . '_' . $this->id; + } + + /** + * Get the name of the index associated with the model. + */ + public function searchableAs(): string + { + return 'entities'; + } + + protected function makeAllSearchableUsing($query) + { + return $query + ->select([$this->getTable() . '.*', 'entities.id as entity_id']) + ->leftJoin('quests', 'quests.id', '=', 'quest_elements.quest_id') + ->leftJoin('entities', function ($join) { + $join->on('entities.entity_id', $this->getTable() . '.id'); + }) + ->has('quest') + ->has('quest.entity') + ->with('quest', 'quest.entity'); + } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->quest->entity->campaign_id, + 'entity_id' => $this->quest->entity->id, + 'name' => $this->name, + 'type' => 'quest_element', + 'entry' => $this->description, + ]; + } } diff --git a/app/Models/TimelineElement.php b/app/Models/TimelineElement.php index e8a4b946bc..31fe092d49 100644 --- a/app/Models/TimelineElement.php +++ b/app/Models/TimelineElement.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Str; +use Laravel\Scout\Searchable; /** * Class TimelineElement @@ -39,6 +40,7 @@ class TimelineElement extends Model { use Blameable; use HasFactory; + use Searchable; use VisibilityIDTrait; /** @var string[] */ @@ -202,4 +204,45 @@ public function visible(): bool } return !empty($this->entity->child); } + + /** + * Get the value used to index the model. + * + */ + public function getScoutKey() + { + return $this->getTable() . '_' . $this->id; + } + + /** + * Get the name of the index associated with the model. + */ + public function searchableAs(): string + { + return 'entities'; + } + + protected function makeAllSearchableUsing($query) + { + return $query + ->select([$this->getTable() . '.*', 'entities.id as entity_id']) + ->leftJoin('timelines', 'timelines.id', '=', 'timeline_elements.timeline_id') + ->leftJoin('entities', function ($join) { + $join->on('entities.entity_id', $this->getTable() . '.id'); + }) + ->has('timeline') + ->has('timeline.entity') + ->with('timeline', 'timeline.entity'); + } + + public function toSearchableArray() + { + return [ + 'campaign_id' => $this->timeline->entity->campaign_id, + 'entity_id' => $this->timeline->entity->id, + 'name' => $this->name, + 'type' => 'timeline_element', + 'entry' => $this->entry, + ]; + } } diff --git a/app/Services/Search/EntitySearchService.php b/app/Services/Search/EntitySearchService.php new file mode 100644 index 0000000000..1534e43e51 --- /dev/null +++ b/app/Services/Search/EntitySearchService.php @@ -0,0 +1,122 @@ +getKeys(); + + $results = $client->multiSearch([ + (new SearchQuery()) + ->setIndexUid('entities') + ->setQuery($term) + ->setAttributesToRetrieve(['id', 'entity_id', 'type']) + ->setLimit(10), + (new SearchQuery()) + ->setIndexUid('entities') + ->setQuery($term2) + ->setAttributesToRetrieve(['id', 'entity_id', 'type']) + ->setLimit(10), + ]); + + $results = array_merge($results['results'][0]['hits'], $results['results'][1]['hits']); + + return $this->process($results)->fetch(); + } + + /** + * Process results to fetch entities from db + * @param array $results Search term + */ + protected function process(array $results = []): self + { + foreach ($results as $result) { + if ($result['type'] == 'quest_element') { + $id = Str::afterLast($result['id'], '_'); + $this->questElementIds[$result['entity_id']] = $id; + //dd($result); + } elseif ($result['type'] == 'timeline_element') { + $id = Str::afterLast($result['id'], '_'); + $this->timelineElementIds[$result['entity_id']] = $id; + //dd($result); + } elseif ($result['type'] == 'post') { + $id = Str::afterLast($result['id'], '_'); + $this->postIds[$result['entity_id']] = $id; + //dd($result); + } elseif ($result['type'] == 'attribute') { + $id = Str::afterLast($result['id'], '_'); + $this->attributeIds[$result['entity_id']] = $id; + //dd($result); + } else { + $this->ids[$result['entity_id']] = $result['entity_id']; + } + } + + //If the search also threw the entity as a possible result don't bother loading the other models + $this->attributeIds = array_diff_key($this->attributeIds, $this->ids); + $this->timelineElementIds = array_diff_key($this->timelineElementIds, $this->ids); + $this->questElementIds = array_diff_key($this->questElementIds, $this->ids); + $this->postIds = array_diff_key($this->postIds, $this->ids); + + return $this; + } + + /** + * Fetch entities from DB + */ + protected function fetch(): array + { + $posts = Post::whereIn('id', $this->postIds)->get(); + $attributes = Attribute::with('entity')->has('entity')->whereIn('id', $this->attributeIds)->get(); + $questElements = QuestElement::with(['quest', 'quest.entity'])->has('quest')->whereIn('id', $this->questElementIds)->get(); + $timelineElements = TimelineElement::with(['timeline', 'timeline.entity'])->has('timeline')->whereIn('id', $this->timelineElementIds)->get(); + + //Get entities from db + $entities = Entity::whereIn('id', $this->ids)->orderBy('name')->get(); + + //Process entities for output + $output = []; + foreach ($entities as $entity) { + $output[$entity->id] = ['id' => $entity->id, 'entity' => $entity->name, 'url' => $entity->url()]; + } + foreach ($attributes as $attribute) { + $output[$attribute->entity->id] = ['id' => $attribute->entity->id, 'entity' => $attribute->entity->name, 'url' => $attribute->entity->url()]; + } + foreach ($posts as $post) { + $output[$post->entity->id] = ['id' => $post->entity->id, 'entity' => $post->entity->name, 'url' => $post->entity->url()]; + } + foreach ($questElements as $questElement) { + $output[$questElement->quest->entity->id] = ['id' => $questElement->quest->entity->id, 'entity' => $questElement->quest->name, 'url' => $questElement->quest->entity->url()]; + } + foreach ($timelineElements as $timelineElement) { + $output[$timelineElement->timeline->entity->id] = ['id' => $timelineElement->timeline->entity->id, 'entity' => $$timelineElement->timeline->entity->name, 'url' => $timelineElement->timeline->entity->url()]; + } + + return $output; + } +} diff --git a/composer.json b/composer.json index 6b074099e2..5a0b06ab1a 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,13 @@ "dompdf/dompdf": "^2.0", "enshrined/svg-sanitize": "^0.16.0", "guzzlehttp/guzzle": "^7.0.1", + "http-interop/http-factory-guzzle": "^1.2", "ilestis/kanka-dnd5e-monster": "^5.0", "intervention/image": "^2.4", "laravel/cashier": "^14.0", "laravel/framework": "^10.0", "laravel/passport": "^11.0", + "laravel/scout": "^10.5", "laravel/socialite": "^5.0", "laravel/ui": "^4.2.1", "laravelcollective/html": "^6.0", @@ -35,6 +37,7 @@ "livewire/livewire": "^3.3", "mailerlite/mailerlite-php": "^1.0", "mcamara/laravel-localization": "^1.7", + "meilisearch/meilisearch-php": "^1.4", "orhanerday/open-ai": "^4.7", "owlchester/laravel-translation-manager": "^10.0", "pragmarx/google2fa-laravel": "^2.0", diff --git a/composer.lock b/composer.lock index 9d4b23241b..0f71bfe548 100644 --- a/composer.lock +++ b/composer.lock @@ -4773,6 +4773,74 @@ ], "time": "2023-02-18T15:43:23+00:00" }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.4.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "d1cea3b8d62dd31324e78fc8396c4c984a4f78dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/d1cea3b8d62dd31324e78fc8396c4c984a4f78dc", + "reference": "d1cea3b8d62dd31324e78fc8396c4c984a4f78dc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/client-common": "^2.0", + "php-http/discovery": "^1.7", + "php-http/httplug": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "guzzlehttp/guzzle": "^7.1", + "http-interop/http-factory-guzzle": "^1.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "1.10.36", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^9.5 || ^10.1" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clementine Urquizar", + "email": "clementine@meilisearch.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.4.1" + }, + "time": "2023-10-25T09:28:44+00:00" + }, { "name": "moneyphp/money", "version": "v4.4.0", diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000000..60bdde2141 --- /dev/null +++ b/config/scout.php @@ -0,0 +1,144 @@ + env('SCOUT_DRIVER', 'algolia'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + //SET TO TRUE TO USE QUEUE + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + ], + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + 'entities' => [ + 'filterableAttributes' => ['id', 'campaign_id'], + 'sortableAttributes' => ['name', 'entry'], + ], + ], + ], + +]; diff --git a/docker-compose.yml b/docker-compose.yml index d005bc130a..99695ff042 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,27 +27,30 @@ services: - mariadb - redis - minio + - meilisearch mariadb: image: 'mariadb:10' ports: - '${FORWARD_DB_PORT:-3306}:3306' environment: MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' - MYSQL_ROOT_HOST: "%" + MYSQL_ROOT_HOST: '%' MYSQL_DATABASE: '${DB_DATABASE}' MYSQL_USER: '${DB_USERNAME}' MYSQL_PASSWORD: '${DB_PASSWORD}' MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' volumes: - #- './.sail/logs/slow-queries.log:/var/lib/mysql/mysql-slow.log:rw' - 'sail-mariadb:/var/lib/mysql' - #- './.sail/mariadb/my.cnf:/etc/mysql/my.cnf:ro' - './.mariadb/10-create-logs-database.sh:/docker-entrypoint-initdb.d/10-create-logs-database.sh' - './docker/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' networks: - sail healthcheck: - test: ["CMD", "mysqladmin", "ping", "-p${DB_PASSWORD}"] + test: + - CMD + - mysqladmin + - ping + - '-p${DB_PASSWORD}' retries: 3 timeout: 5s redis: @@ -59,7 +62,10 @@ services: networks: - sail healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: + - CMD + - redis-cli + - ping retries: 3 timeout: 5s minio: @@ -68,15 +74,19 @@ services: - '${FORWARD_MINIO_PORT:-9000}:9000' - '${FORWARD_MINIO_CONSOLE_PORT:-8900}:8900' environment: - MINIO_ROOT_USER: 'sail' - MINIO_ROOT_PASSWORD: 'password' + MINIO_ROOT_USER: sail + MINIO_ROOT_PASSWORD: password volumes: - 'sail-minio:/data/minio' networks: - sail - command: minio server /data/minio --console-address ":8900" + command: 'minio server /data/minio --console-address ":8900"' healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + test: + - CMD + - curl + - '-f' + - 'http://localhost:9000/minio/health/live' retries: 3 timeout: 5s thumbor: @@ -90,53 +100,61 @@ services: - sail environment: - LOG_LEVEL=info - #- SECURITY_KEY=kanka - - LOADER=thumbor_aws.loader - AWS_LOADER_REGION_NAME=local - AWS_LOADER_BUCKET_NAME=kanka - AWS_LOADER_S3_ACCESS_KEY_ID=sail - AWS_LOADER_S3_SECRET_ACCESS_KEY=password - - AWS_LOADER_S3_ENDPOINT_URL=http://minio:9000/ - + - 'AWS_LOADER_S3_ENDPOINT_URL=http://minio:9000/' - RESULT_STORAGE=thumbor_aws.result_storage - AWS_RESULT_STORAGE_BUCKET_NAME=thumbnails - AWS_RESULT_STORAGE_S3_ACCESS_KEY_ID=sail - AWS_RESULT_STORAGE_S3_SECRET_ACCESS_KEY=password - - AWS_RESULT_STORAGE_S3_ENDPOINT_URL=http://minio:9000/ - - # Result Storage prefix path + - 'AWS_RESULT_STORAGE_S3_ENDPOINT_URL=http://minio:9000/' - AWS_RESULT_STORAGE_ROOT_PATH=rs - - RESULT_STORAGE_STORES_UNSAFE=True - ALLOW_UNSAFE_URL=True - - # Expiration in seconds of generated images in the result storage. (2629746 is a month is seconds) - - 'RESULT_STORAGE_EXPIRATION_SECONDS=2629746' - - 'QUALITY=80' - + - RESULT_STORAGE_EXPIRATION_SECONDS=2629746 + - QUALITY=80 - AUTO_WEBP=True - RESPECT_ORIENTATION=True - MAX_AGE=86400 - HTTP_LOADER_VALIDATE_CERTS=False - - # The image we use doesn't come with numpy, so no face_detector possible - #- DETECTORS=['thumbor.detectors.face_detector'] depends_on: - minio thumbor-nginx: image: 'nginx:1.23' tty: true volumes: - - ./.nginx:/etc/nginx/conf.d/ + - './.nginx:/etc/nginx/conf.d/' ports: - - "8889:80" + - '8889:80' environment: - NGINX_PORT=8889 networks: - sail depends_on: - thumbor + meilisearch: + image: 'getmeili/meilisearch:latest' + ports: + - '${FORWARD_MEILISEARCH_PORT:-7700}:7700' + environment: + MEILI_NO_ANALYTICS: '${MEILISEARCH_NO_ANALYTICS:-false}' + MEILI_MASTER_KEY: '${MEILISEARCH_KEY}' + volumes: + - 'sail-meilisearch:/meili_data' + networks: + - sail + healthcheck: + test: + - CMD + - wget + - '--no-verbose' + - '--spider' + - 'http://meilisearch:7700/health' + retries: 3 + timeout: 5s networks: sail: name: sail @@ -152,3 +170,5 @@ volumes: driver: local sail-thumbor-nginx: driver: local + sail-meilisearch: + driver: local diff --git a/docs/meilisearch.md b/docs/meilisearch.md new file mode 100644 index 0000000000..faee692c18 --- /dev/null +++ b/docs/meilisearch.md @@ -0,0 +1,45 @@ +# Setup + +Before importing entities to meilisearch is important to do some setup, first of all there are some .ENV +parameters to be set. + +`SCOUT_DRIVER` tells scout which search engine to use, it should be set to `meilisearch`. + +`SCOUT_QUEUE` tells scout if it should use the jobs queue if set to true or not if set to false/not set. + +`MEILISEARCH_HOST` is the url of the meilisearch server, where the requests will be sent to, by default its set to: `http://localhost:7700` which is usually the route for a local test setup. + +`MEILISEARCH_KEY` is the key/password which authorizes read/write to the meilisearch database, information on how to set it up can be found on Meilisearch's docs: `https://www.meilisearch.com/docs/learn/security/master_api_keys`. + +Now we can start importing the entities. + +# Importing entities + +To import all entities that are setup to work with meilisearch, run: + +> sail artisan setup:meilisearch + +## Individual models + +To import entities from an individual model, run: + +> sail artisan scout:import "App\Models\ModelName" + +This has to be run for each model type we wish to import to the Meilisearch database, for example if we wish to import TimelineElements and Characters we would do: + +> sail artisan scout:import "App\Models\TimelineElements" + +> sail artisan scout:import "App\Models\Characters" + +# Config change + +It's also important to run this following command the first time meilisearch is set up and whenever any of the index settings on `config/scout.php` are modified: + +> sail artisan scout:sync-index-settings + +# Testing + +Call the following api endpoint with a valid token + +> http://api.kanka.test:8081/1.0/campaigns/xxx/fulltext-search?term=adam + diff --git a/phpunit.xml b/phpunit.xml index 3bc069f8a8..393f2d1120 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,7 +22,6 @@ - diff --git a/routes/api.v1.php b/routes/api.v1.php index 6ec97b80ec..3034908722 100644 --- a/routes/api.v1.php +++ b/routes/api.v1.php @@ -107,6 +107,8 @@ Route::post('campaigns/{campaign}/default-thumbnails', [App\Http\Controllers\Api\v1\DefaultThumbnailApiController::class, 'upload']); Route::delete('campaigns/{campaign}/default-thumbnails', [App\Http\Controllers\Api\v1\DefaultThumbnailApiController::class, 'delete']); +Route::get('campaigns/{campaign}/fulltext-search', [App\Http\Controllers\Api\v1\FullTextSearchApiController::class, 'index']); + Route::get('campaigns/{campaign}/families/{family}/tree', [App\Http\Controllers\Api\v1\FamilyTreeApiController::class, 'show']); Route::post('campaigns/{campaign}/families/{family}/tree', [App\Http\Controllers\Api\v1\FamilyTreeApiController::class, 'store']); Route::put('campaigns/{campaign}/families/{family}/tree', [App\Http\Controllers\Api\v1\FamilyTreeApiController::class, 'store']);