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']);