Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Miceli
eb468ba9c8 Bump meili version
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-09 03:39:41 +07:00
36 changed files with 2267 additions and 1421 deletions

View File

@@ -83,18 +83,6 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
meilisearch:
image: getmeili/meilisearch:latest
ports:
- 47700:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
options: >-
--health-cmd "curl -sf http://localhost:7700/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -106,8 +94,6 @@ jobs:
- name: Run tests - name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }} run: make test TEST_DB_TYPE=${{ matrix.database }}
env:
OG_TEST_MEILI_HOST: http://localhost:47700
test: test:
name: Test name: Test

View File

@@ -42,7 +42,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
ghcr.io/thomiceli/opengist ghcr.io/thomiceli/opengist
@@ -54,26 +54,26 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -32,10 +32,6 @@ index.meili.host:
# Set the API key for the Meiliseach server # Set the API key for the Meiliseach server
index.meili.api-key: index.meili.api-key:
# Set the default search fields. Can contain multiple fields (e.g., `content,username`).
# Fields: content,user,title,description,filename,extension,language,topic. Default: content
search.default: content
# Default branch name used by Opengist when initializing Git repositories. # Default branch name used by Opengist when initializing Git repositories.
# If not set, uses the Git default branch name. See https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch # If not set, uses the Git default branch name. See https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch
git.default-branch: git.default-branch:

View File

@@ -15,7 +15,6 @@ aside: false
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). | | index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. | | index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. | | index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) | | git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) | | sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) | | http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |

View File

@@ -25,7 +25,7 @@
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }} {{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }} {{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }} {{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := int (default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql) }} {{- $port := default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql }}
{{- if or (eq $user "") (eq $pass "") (eq $db "") }} {{- if or (eq $user "") (eq $pass "") (eq $db "") }}
{{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }} {{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }} {{- end }}

View File

@@ -84,7 +84,7 @@ spec:
serviceName: {{ include "opengist.fullname" . }}-http serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }} podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy: updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }} {{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }}
selector: selector:
matchLabels: matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }} {{- include "opengist.selectorLabels" . | nindent 6 }}

View File

@@ -150,12 +150,7 @@ func resetHooks() {
} }
func indexGists() { func indexGists() {
log.Info().Msg("Rebuilding index from scratch...") log.Info().Msg("Indexing all Gists...")
if err := index.ResetIndex(); err != nil {
log.Error().Err(err).Msg("Cannot reset index")
return
}
gists, err := db.GetAllGistsRows() gists, err := db.GetAllGistsRows()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot get gists") log.Error().Err(err).Msg("Cannot get gists")

View File

@@ -38,12 +38,11 @@ type config struct {
DBUri string `yaml:"db-uri" env:"OG_DB_URI"` DBUri string `yaml:"db-uri" env:"OG_DB_URI"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"` Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"` MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"` MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
SearchDefault string `yaml:"search.default" env:"OG_SEARCH_DEFAULT"`
GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"` GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"`
@@ -112,7 +111,6 @@ func configWithDefaults() (*config, error) {
c.OpengistHome = "" c.OpengistHome = ""
c.DBUri = "opengist.db" c.DBUri = "opengist.db"
c.Index = "bleve" c.Index = "bleve"
c.SearchDefault = "content"
c.SqliteJournalMode = "WAL" c.SqliteJournalMode = "WAL"

View File

@@ -817,19 +817,18 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
} }
indexedGist := &index.Gist{ indexedGist := &index.Gist{
GistID: gist.ID, GistID: gist.ID,
UserID: gist.UserID, UserID: gist.UserID,
Visibility: gist.Private.Uint(), Visibility: gist.Private.Uint(),
Username: gist.User.Username, Username: gist.User.Username,
Description: gist.Description, Title: gist.Title,
Title: gist.Title, Content: wholeContent,
Content: wholeContent, Filenames: fileNames,
Filenames: fileNames, Extensions: exts,
Extensions: exts, Languages: langs,
Languages: langs, Topics: topics,
Topics: topics, CreatedAt: gist.CreatedAt,
CreatedAt: gist.CreatedAt, UpdatedAt: gist.UpdatedAt,
UpdatedAt: gist.UpdatedAt,
} }
return indexedGist, nil return indexedGist, nil

View File

@@ -192,7 +192,7 @@ admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen' admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren' admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren' admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
admin.actions.index-gists: 'Suchindex neu aufbauen' admin.actions.index-gists: 'Alle Gists Indexieren'
admin.id: 'ID' admin.id: 'ID'
admin.user: 'Benutzer' admin.user: 'Benutzer'
admin.delete: 'Löschen' admin.delete: 'Löschen'
@@ -236,7 +236,7 @@ flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
flash.admin.git-gc: 'Sammle Repositories...' flash.admin.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...' flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...' flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...' flash.admin.index-gists: 'Indiziere alle Gists...'
flash.auth.username-exists: 'Benutzername existiert bereits' flash.auth.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen' flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'

View File

@@ -88,12 +88,10 @@ gist.search.found: gists found
gist.search.no-results: No gists found gist.search.no-results: No gists found
gist.search.help.user: gists created by user gist.search.help.user: gists created by user
gist.search.help.title: gists with given title gist.search.help.title: gists with given title
gist.search.help.description: gists with given description
gist.search.help.filename: gists having files with given name gist.search.help.filename: gists having files with given name
gist.search.help.extension: gists having files with given extension gist.search.help.extension: gists having files with given extension
gist.search.help.language: gists having files with given language gist.search.help.language: gists having files with given language
gist.search.help.topic: gists with given topic gist.search.help.topic: gists with given topic
gist.search.help.all: search all fields
gist.search.placeholder.title: Title gist.search.placeholder.title: Title
gist.search.placeholder.visibility: Visibility gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public gist.search.placeholder.public: Public
@@ -294,7 +292,7 @@ admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect all git repositories admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Rebuild search index admin.actions.index-gists: Index all gists
admin.actions.sync-gist-languages: Synchronize all gists languages admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID admin.id: ID
admin.user: User admin.user: User
@@ -340,7 +338,7 @@ flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories... flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews... flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories... flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Rebuilding search index... flash.admin.index-gists: Indexing all gists...
flash.admin.sync-gist-languages: Syncing Gist languages... flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists flash.auth.username-exists: Username already exists

View File

@@ -213,7 +213,7 @@ admin.invitations: 'Invitaciones'
admin.invitations.create: 'Crear invitación' admin.invitations.create: 'Crear invitación'
admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists' admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists'
admin.actions.reset-hooks: 'Resetear los hooks de Git en todos los repositorios' admin.actions.reset-hooks: 'Resetear los hooks de Git en todos los repositorios'
admin.actions.index-gists: 'Reconstruir índice de búsqueda' admin.actions.index-gists: 'Indexar todos los gists'
admin.config-link-overriden: 'sobrescrito' admin.config-link-overriden: 'sobrescrito'
admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.' admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.'
admin.invitations.max_uses: 'Cantidad máxima de usos' admin.invitations.max_uses: 'Cantidad máxima de usos'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
flash.admin.git-gc: 'Recolectando basura en los repositorios...' flash.admin.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...' flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...' flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...' flash.admin.index-gists: 'Indexando todos los gists...'
flash.auth.username-exists: 'El nombre de usuario ya existe' flash.auth.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas' flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s' flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'

View File

@@ -193,7 +193,7 @@ admin.actions.reset-hooks: Réinitialiser les hooks de Git pour tous les dépôt
gist.new.url: URL gist.new.url: URL
gist.search.no-results: Aucun gist trouvé gist.search.no-results: Aucun gist trouvé
settings.unlink-gitlab-account: Détacher le compte GitLab settings.unlink-gitlab-account: Détacher le compte GitLab
admin.actions.index-gists: Reconstruire l'index de recherche admin.actions.index-gists: Indexer tous les gists
gist.new.preview: 'Aperçu' gist.new.preview: 'Aperçu'
gist.new.create-a-new-gist: 'Créer un nouveau gist' gist.new.create-a-new-gist: 'Créer un nouveau gist'
gist.edit.edit-gist: 'Modifier %s' gist.edit.edit-gist: 'Modifier %s'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de donn
flash.admin.git-gc: 'Nettoyage des dépôts...' flash.admin.git-gc: 'Nettoyage des dépôts...'
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...' flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...'
flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...' flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...'
flash.admin.index-gists: 'Reconstruction de l''index de recherche...' flash.admin.index-gists: 'Indexation de tous les gists...'
flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé' flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides' flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s' flash.auth.account-linked-oauth: 'Compte lié à %s'

View File

@@ -170,7 +170,7 @@ admin.actions.sync-db: Gistek szinkronizálása az adatbázissal
admin.actions.git-gc: Használatlan git repository-k eltávolítása admin.actions.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálása admin.actions.sync-previews: Gist előnézetek szinkronizálása
admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál
admin.actions.index-gists: Keresési index újraépítése admin.actions.index-gists: Gistek indexelése
admin.id: Azonosító admin.id: Azonosító
admin.user: Felhasználó admin.user: Felhasználó
admin.delete: Törlés admin.delete: Törlés

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: 'Sincronizza gists dal database'
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories' admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists' admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories' admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
admin.actions.index-gists: 'Ricostruisci indice di ricerca' admin.actions.index-gists: 'Indicizza tutti i gists'
admin.id: 'ID' admin.id: 'ID'
admin.user: 'Utente' admin.user: 'Utente'
admin.delete: 'Elimina' admin.delete: 'Elimina'
@@ -235,7 +235,7 @@ flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...' flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...' flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...' flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
flash.admin.index-gists: 'Ricostruzione indice di ricerca...' flash.admin.index-gists: 'Indicizzando tutti i gists...'
flash.auth.username-exists: 'Il nome utente esiste già' flash.auth.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate' flash.auth.invalid-credentials: 'Credenziali errate'

View File

@@ -227,7 +227,7 @@ admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git' admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów' admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów' admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
admin.actions.index-gists: 'Przebuduj indeks wyszukiwania' admin.actions.index-gists: 'Indeksuj wszystkie Gisty'
admin.id: 'ID' admin.id: 'ID'
admin.user: 'Użytkownik' admin.user: 'Użytkownik'
admin.delete: 'Usuń' admin.delete: 'Usuń'
@@ -271,7 +271,7 @@ flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...' flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...' flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...' flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...' flash.admin.index-gists: 'Indeksowanie wszystkich Gistów...'
flash.auth.username-exists: 'Nazwa użytkownika już istnieje' flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania' flash.auth.invalid-credentials: 'Niepoprawne dane logowania'

View File

@@ -214,7 +214,7 @@ admin.invitations: 'Инвайты'
admin.invitations.create: 'Создать инвайт' admin.invitations.create: 'Создать инвайт'
admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов' admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов'
admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев' admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев'
admin.actions.index-gists: ерестроить поисковый индекс' admin.actions.index-gists: роиндексировать все фрагменты'
validation.should-not-be-empty: 'Поле %s не должно быть пустым' validation.should-not-be-empty: 'Поле %s не должно быть пустым'
admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.' admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.'
admin.invitations.max_uses: 'Максимальное количество использований' admin.invitations.max_uses: 'Максимальное количество использований'
@@ -232,7 +232,7 @@ flash.admin.sync-db: 'Выполняется синхронизация репо
flash.admin.git-gc: 'Сборка мусора в репозиториях…' flash.admin.git-gc: 'Сборка мусора в репозиториях…'
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…' flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…' flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
flash.admin.index-gists: 'Перестроение поискового индекса…' flash.admin.index-gists: 'Выполняется индексация фрагментов…'
flash.auth.username-exists: 'Такое имя пользователя уже занято' flash.auth.username-exists: 'Такое имя пользователя уже занято'
flash.auth.invalid-credentials: 'Некорректные данные для входа' flash.auth.invalid-credentials: 'Некорректные данные для входа'
flash.auth.account-linked-oauth: 'Учётная запись связана с %s' flash.auth.account-linked-oauth: 'Учётная запись связана с %s'

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
admin.actions.index-gists: Arama dizinini yeniden oluştur admin.actions.index-gists: Tüm gistleri indeksle
admin.id: ID admin.id: ID
admin.user: Kullanıcı admin.user: Kullanıcı
admin.delete: Sil admin.delete: Sil
@@ -234,7 +234,7 @@ flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor... flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor... flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor... flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor... flash.admin.index-gists: Tüm gistler indeksleniyor...
flash.auth.username-exists: Kullanıcı adı zaten mevcut flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri flash.auth.invalid-credentials: Geçersiz kimlik bilgileri

View File

@@ -192,7 +192,7 @@ admin.actions.sync-db: Синхронізувати gists з базою дани
admin.actions.git-gc: Збір сміття з репозиторіїв Git admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
admin.actions.index-gists: Перебудувати пошуковий індекс admin.actions.index-gists: Проіндексувати всі gists
admin.id: ID admin.id: ID
admin.user: Користувач admin.user: Користувач
admin.delete: Видалити admin.delete: Видалити
@@ -236,7 +236,7 @@ flash.admin.sync-db: Синхронізація репозиторіїв за б
flash.admin.git-gc: Збір сміття з репозиторіїв... flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів... flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв... flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Перебудова пошукового індексу... flash.admin.index-gists: Індексація всіх gists...
flash.auth.username-exists: Це ім'я користувача вже існує flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані flash.auth.invalid-credentials: Недійсні облікові дані

View File

@@ -214,7 +214,7 @@ admin.invitations: '邀请'
admin.invitations.create: '创建邀请' admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '同步所有 Gists 预览' admin.actions.sync-previews: '同步所有 Gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks' admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks'
admin.actions.index-gists: '重建搜索索引' admin.actions.index-gists: '索引所有 Gists'
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。' admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '最多使用次数' admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '过期时间' admin.invitations.expires_at: '过期时间'
@@ -231,7 +231,7 @@ flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...' flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...' flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...' flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '正在重建搜索索引...' flash.admin.index-gists: '正在索引所有 Gists...'
flash.auth.username-exists: '用户名已存在' flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证' flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s' flash.auth.account-linked-oauth: '帐户已关联到 %s'

View File

@@ -190,7 +190,7 @@ gist.search.no-results: 沒有找到任何 Gists
gist.search.help.title: Gists 的標題 gist.search.help.title: Gists 的標題
gist.search.help.filename: Gists 的檔案名稱 gist.search.help.filename: Gists 的檔案名稱
gist.search.help.language: Gists 的程式語言 gist.search.help.language: Gists 的程式語言
admin.actions.index-gists: 重建搜尋索引 admin.actions.index-gists: 索引所有的 Gists
gist.search.help.user: 由使用者建立的 Gists gist.search.help.user: 由使用者建立的 Gists
gist.search.found: 已找到 Gists gist.search.found: 已找到 Gists
gist.search.help.extension: Gists 的副檔名 gist.search.help.extension: Gists 的副檔名

View File

@@ -2,21 +2,16 @@ package index
import ( import (
"errors" "errors"
"fmt"
"os"
"strconv" "strconv"
"strings"
"github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase" "github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/length"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase" "github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query" "github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
) )
type BleveIndexer struct { type BleveIndexer struct {
@@ -57,9 +52,14 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err return nil, err
} }
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping())
mapping := bleve.NewIndexMapping() mapping := bleve.NewIndexMapping()
// Token filters
if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{ if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{
"type": unicodenorm.Name, "type": unicodenorm.Name,
"form": unicodenorm.NFC, "form": unicodenorm.NFC,
@@ -67,88 +67,21 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err return nil, err
} }
if err = mapping.AddCustomTokenFilter("lengthMin2", map[string]interface{}{ if err = mapping.AddCustomAnalyzer("gistAnalyser", map[string]interface{}{
"type": length.Name,
"min": 2.0,
}); err != nil {
return nil, err
}
// Analyzer: split mode (camelCase splitting for partial search)
// "CPUCard" -> ["cpu", "card"]
if err = mapping.AddCustomAnalyzer("codeSplit", map[string]interface{}{
"type": custom.Name, "type": custom.Name,
"char_filters": []string{}, "char_filters": []string{},
"tokenizer": unicode.Name, "tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name, "lengthMin2"}, "token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name},
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
// Analyzer: exact mode (no camelCase splitting for full-word search) docMapping.DefaultAnalyzer = "gistAnalyser"
// "CPUCard" -> ["cpucard"]
if err = mapping.AddCustomAnalyzer("codeExact", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", lowercase.Name},
}); err != nil {
return nil, err
}
// Analyzer: keyword with lowercase (for Languages - single token, no splitting)
if err = mapping.AddCustomAnalyzer("lowercaseKeyword", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": "single",
"token_filters": []string{lowercase.Name},
}); err != nil {
return nil, err
}
// Document mapping
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
// Content: dual indexing (exact + split)
// "Content" uses the property name so Bleve resolves its analyzer correctly
contentExact := bleve.NewTextFieldMapping()
contentExact.Name = "Content"
contentExact.Analyzer = "codeExact"
contentExact.Store = false
contentExact.IncludeTermVectors = true
contentSplit := bleve.NewTextFieldMapping()
contentSplit.Name = "ContentSplit"
contentSplit.Analyzer = "codeSplit"
contentSplit.Store = false
contentSplit.IncludeTermVectors = true
docMapping.AddFieldMappingsAt("Content", contentExact, contentSplit)
// Languages: keyword analyzer (preserves as single token)
languageFieldMapping := bleve.NewTextFieldMapping()
languageFieldMapping.Analyzer = "lowercaseKeyword"
docMapping.AddFieldMappingsAt("Languages", languageFieldMapping)
// All other text fields use codeSplit as default
docMapping.DefaultAnalyzer = "codeSplit"
mapping.DefaultMapping = docMapping mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping) return bleve.New(i.path, mapping)
} }
func (i *BleveIndexer) Reset() error {
i.Close()
if err := os.RemoveAll(i.path); err != nil {
return fmt.Errorf("failed to remove Bleve index directory: %w", err)
}
log.Info().Msg("Bleve index directory removed, re-creating index")
return i.Init()
}
func (i *BleveIndexer) Close() { func (i *BleveIndexer) Close() {
if i == nil || i.index == nil { if i == nil || i.index == nil {
return return
@@ -172,111 +105,18 @@ func (i *BleveIndexer) Remove(gistID uint) error {
return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID))) return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID)))
} }
// Search returns a list of Gist IDs that match the given search metadata. func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
// The method returns an error if any.
//
// The queryMetadata parameter is used to filter the search results.
// For example, passing a non-empty Username will search for gists whose
// username matches the given string.
//
// If the "All" field in queryMetadata is non-empty, the method will
// search across all metadata fields with OR logic. Otherwise, the method
// will add each metadata field with AND logic.
//
// The page parameter is used to paginate the search results.
// The method returns the total number of search results in the second return
// value.
//
// The third return value is a map of language counts for the search results.
// The language counts are computed by asking Bleve to return the top 10
// facets for the "Languages" field.
func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
var err error var err error
var indexerQuery query.Query = bleve.NewMatchAllQuery() var indexerQuery query.Query
if queryStr != "" {
// Query factory // Use match query with fuzzy matching for more flexible content search
factoryQuery := func(field, value string) query.Query { contentQuery := bleve.NewMatchQuery(queryStr)
query := bleve.NewMatchPhraseQuery(value) contentQuery.SetField("Content")
query.SetField(field) contentQuery.SetFuzziness(2)
return query indexerQuery = contentQuery
} } else {
contentQuery := bleve.NewMatchAllQuery()
// Exact search indexerQuery = contentQuery
addQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryQuery(field, value))
}
}
// Query factory for text fields: exact match boosted + match query + prefix
factoryTextQuery := func(field, value string) query.Query {
exact := bleve.NewMatchPhraseQuery(value)
exact.SetField(field)
exact.SetBoost(2.0)
fuzzy := bleve.NewMatchQuery(value)
fuzzy.SetField(field)
fuzzy.SetFuzziness(1)
fuzzy.SetOperator(query.MatchQueryOperatorAnd)
queries := []query.Query{exact, fuzzy}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField(field)
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField(field)
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Query factory for Content: searches both exact (Content) and split (ContentSplit) fields
factoryContentQuery := func(value string) query.Query {
// Exact field (no camelCase split): matches "cpucard"
exactMatch := bleve.NewMatchQuery(value)
exactMatch.SetField("Content")
exactMatch.SetOperator(query.MatchQueryOperatorAnd)
exactMatch.SetBoost(2.0)
// Split field (camelCase split): matches "cpu", "card"
splitMatch := bleve.NewMatchQuery(value)
splitMatch.SetField("ContentSplit")
splitMatch.SetFuzziness(1)
splitMatch.SetOperator(query.MatchQueryOperatorAnd)
splitMatch.SetBoost(1.0)
queries := []query.Query{exactMatch, splitMatch}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField("Content")
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField("Content")
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Text field search
addTextQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryTextQuery(field, value))
}
} }
// Visibility filtering: show public gists (Visibility=0) OR user's own gists // Visibility filtering: show public gists (Visibility=0) OR user's own gists
@@ -292,62 +132,48 @@ func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery) accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery) indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
buildFieldQuery := func(field, value string) query.Query {
switch field {
case "Content":
return factoryContentQuery(value)
case "Title", "Description", "Filenames":
return factoryTextQuery(field, value)
case "Extensions":
return factoryQuery(field, "."+value)
default: // Username, Languages, Topics
return factoryQuery(field, value)
}
}
// Handle "All" field - search across all metadata fields with OR logic // Handle "All" field - search across all metadata fields with OR logic
if metadata.All != "" { if queryMetadata.All != "" {
allQueries := make([]query.Query, 0, len(AllSearchFields)) allQueries := make([]query.Query, 0)
for _, field := range AllSearchFields {
allQueries = append(allQueries, buildFieldQuery(field, metadata.All)) // Create match phrase queries for each field
fields := []struct {
field string
value string
}{
{"Username", queryMetadata.All},
{"Title", queryMetadata.All},
{"Extensions", "." + queryMetadata.All},
{"Filenames", queryMetadata.All},
{"Languages", queryMetadata.All},
{"Topics", queryMetadata.All},
} }
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(allQueries...))
for _, f := range fields {
q := bleve.NewMatchPhraseQuery(f.value)
q.FieldVal = f.field
allQueries = append(allQueries, q)
}
// Combine all field queries with OR (disjunction)
allDisjunction := bleve.NewDisjunctionQuery(allQueries...)
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, allDisjunction)
} else { } else {
// Original behavior: add each metadata field with AND logic // Original behavior: add each metadata field with AND logic
addQuery("Username", metadata.Username) addQuery := func(field, value string) {
addTextQuery("Title", metadata.Title) if value != "" && value != "." {
addTextQuery("Description", metadata.Description) q := bleve.NewMatchPhraseQuery(value)
addQuery("Extensions", "."+metadata.Extension) q.FieldVal = field
addTextQuery("Filenames", metadata.Filename) indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
addQuery("Languages", metadata.Language) }
addQuery("Topics", metadata.Topic)
if metadata.Content != "" {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryContentQuery(metadata.Content))
} }
// Handle default search fields from config with OR logic addQuery("Username", queryMetadata.Username)
if metadata.Default != "" { addQuery("Title", queryMetadata.Title)
var fields []string addQuery("Extensions", "."+queryMetadata.Extension)
for _, f := range strings.Split(config.C.SearchDefault, ",") { addQuery("Filenames", queryMetadata.Filename)
f = strings.TrimSpace(f) addQuery("Languages", queryMetadata.Language)
if f == "all" { addQuery("Topics", queryMetadata.Topic)
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) == 1 {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, buildFieldQuery(fields[0], metadata.Default))
} else if len(fields) > 1 {
defaultQueries := make([]query.Query, 0, len(fields))
for _, field := range fields {
defaultQueries = append(defaultQueries, buildFieldQuery(field, metadata.Default))
}
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(defaultQueries...))
}
}
} }
languageFacet := bleve.NewFacetRequest("Languages", 10) languageFacet := bleve.NewFacetRequest("Languages", 10)
@@ -360,8 +186,6 @@ func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int
s.Fields = []string{"GistID"} s.Fields = []string{"GistID"}
s.IncludeLocations = false s.IncludeLocations = false
log.Debug().Interface("searchRequest", s).Msg("Bleve search request")
results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s) results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s)
if err != nil { if err != nil {
return nil, 0, nil, err return nil, 0, nil, err

View File

@@ -4,31 +4,33 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
) )
// setupBleveIndexer creates a new BleveIndexer for testing // setupBleveIndexer creates a new BleveIndexer for testing
func setupBleveIndexer(t *testing.T) (Indexer, func()) { func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
t.Helper() t.Helper()
// Create a temporary directory for the test index
tmpDir, err := os.MkdirTemp("", "bleve-test-*") tmpDir, err := os.MkdirTemp("", "bleve-test-*")
require.NoError(t, err) if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
indexPath := filepath.Join(tmpDir, "test.index") indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath) indexer := NewBleveIndexer(indexPath)
// Initialize the indexer
err = indexer.Init() err = indexer.Init()
if err != nil { if err != nil {
os.RemoveAll(tmpDir) os.RemoveAll(tmpDir)
t.Fatalf("Failed to initialize BleveIndexer: %v", err) t.Fatalf("Failed to initialize BleveIndexer: %v", err)
} }
// Store in the global atomicIndexer since Add/Remove use it
var idx Indexer = indexer var idx Indexer = indexer
atomicIndexer.Store(&idx) atomicIndexer.Store(&idx)
// Return cleanup function
cleanup := func() { cleanup := func() {
atomicIndexer.Store(nil) atomicIndexer.Store(nil)
indexer.Close() indexer.Close()
@@ -38,50 +40,123 @@ func setupBleveIndexer(t *testing.T) (Indexer, func()) {
return indexer, cleanup return indexer, cleanup
} }
func TestBleveAddAndSearch(t *testing.T) { testAddAndSearch(t, setupBleveIndexer) } func TestBleveIndexerAddGist(t *testing.T) {
func TestBleveAccessControl(t *testing.T) { testAccessControl(t, setupBleveIndexer) } indexer, cleanup := setupBleveIndexer(t)
func TestBleveMetadataFilters(t *testing.T) { testMetadataFilters(t, setupBleveIndexer) } defer cleanup()
func TestBleveAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupBleveIndexer) }
func TestBleveFuzzySearch(t *testing.T) { testFuzzySearch(t, setupBleveIndexer) }
func TestBleveContentSearch(t *testing.T) { testContentSearch(t, setupBleveIndexer) }
func TestBlevePagination(t *testing.T) { testPagination(t, setupBleveIndexer) }
func TestBleveLanguageFacets(t *testing.T) { testLanguageFacets(t, setupBleveIndexer) }
func TestBleveWildcardSearch(t *testing.T) { testWildcardSearch(t, setupBleveIndexer) }
func TestBleveMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupBleveIndexer) }
func TestBleveTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupBleveIndexer) }
func TestBleveMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupBleveIndexer) }
func TestBlevePersistence(t *testing.T) { testIndexerAddGist(t, indexer)
tmpDir, err := os.MkdirTemp("", "bleve-persist-test-*") }
require.NoError(t, err)
func TestBleveIndexerAllFieldSearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerAllFieldSearch(t, indexer)
}
func TestBleveIndexerFuzzySearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerFuzzySearch(t, indexer)
}
func TestBleveIndexerSearchBasic(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerSearchBasic(t, indexer)
}
func TestBleveIndexerPagination(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerPagination(t, indexer)
}
// TestBleveIndexerInitAndClose tests Bleve-specific initialization and closing
func TestBleveIndexerInitAndClose(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bleve-init-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
indexPath := filepath.Join(tmpDir, "test.index") indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Create and populate index // Test initialization
indexer1 := NewBleveIndexer(indexPath) err = indexer.Init()
require.NoError(t, indexer1.Init()) if err != nil {
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
var idx Indexer = indexer1 if indexer.index == nil {
atomicIndexer.Store(&idx) t.Fatal("Expected index to be initialized, got nil")
}
g := newGist(1, 1, 0, "persistent data survives restart") // Test closing
require.NoError(t, indexer1.Add(g)) indexer.Close()
indexer1.Close() // Test reopening the same index
atomicIndexer.Store(nil)
// Reopen at same path
indexer2 := NewBleveIndexer(indexPath) indexer2 := NewBleveIndexer(indexPath)
require.NoError(t, indexer2.Init()) err = indexer2.Init()
if err != nil {
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
}
defer indexer2.Close() defer indexer2.Close()
idx = indexer2 if indexer2.index == nil {
atomicIndexer.Store(&idx) t.Fatal("Expected reopened index to be initialized, got nil")
defer atomicIndexer.Store(nil) }
}
ids, total, _, err := indexer2.Search(SearchGistMetadata{Content: "persistent"}, 1, 1)
require.NoError(t, err) // TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched
require.Equal(t, uint64(1), total, "data should survive close+reopen") func TestBleveIndexerUnicodeSearch(t *testing.T) {
require.Equal(t, uint(1), ids[0]) indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
// Add a gist with Unicode content
gist := &Gist{
GistID: 100,
UserID: 100,
Visibility: 0,
Username: "testuser",
Title: "Unicode Test",
Content: "Hello world with unicode characters: café résumé naïve",
Filenames: []string{"test.txt"},
Extensions: []string{".txt"},
Languages: []string{"Text"},
Topics: []string{"unicode"},
CreatedAt: 1234567890,
UpdatedAt: 1234567890,
}
err := indexer.Add(gist)
if err != nil {
t.Fatalf("Failed to add gist: %v", err)
}
// Search for unicode content
gistIDs, total, _, err := indexer.Search("café", SearchGistMetadata{}, 100, 1)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if total == 0 {
t.Skip("Unicode search may require specific index configuration")
return
}
found := false
for _, id := range gistIDs {
if id == 100 {
found = true
break
}
}
if !found {
t.Log("Unicode gist not found in search results, but other results were returned")
}
} }

View File

@@ -1,43 +1,26 @@
package index package index
var AllSearchFields = []string{"Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics", "Content"}
var SearchFieldMap = map[string]string{
"user": "Username",
"title": "Title",
"description": "Description",
"filename": "Filenames",
"extension": "Extensions",
"language": "Languages",
"topic": "Topics",
"content": "Content",
}
type Gist struct { type Gist struct {
GistID uint GistID uint
UserID uint UserID uint
Visibility uint Visibility uint
Username string Username string
Description string Title string
Title string Content string
Content string Filenames []string
Filenames []string Extensions []string
Extensions []string Languages []string
Languages []string Topics []string
Topics []string CreatedAt int64
CreatedAt int64 UpdatedAt int64
UpdatedAt int64
} }
type SearchGistMetadata struct { type SearchGistMetadata struct {
Username string Username string
Title string Title string
Description string Filename string
Content string Extension string
Filename string Language string
Extension string Topic string
Language string All string
Topic string
All string
Default string
} }

View File

@@ -2,11 +2,10 @@ package index
import ( import (
"fmt" "fmt"
"path/filepath"
"sync/atomic"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"path/filepath"
"sync/atomic"
) )
var atomicIndexer atomic.Pointer[Indexer] var atomicIndexer atomic.Pointer[Indexer]
@@ -14,10 +13,9 @@ var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface { type Indexer interface {
Init() error Init() error
Close() Close()
Reset() error
Add(gist *Gist) error Add(gist *Gist) error
Remove(gistID uint) error Remove(gistID uint) error
Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) Search(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
} }
type IndexerType string type IndexerType string
@@ -86,19 +84,6 @@ func Close() {
atomicIndexer.Store(nil) atomicIndexer.Store(nil)
} }
func ResetIndex() error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Reset()
}
func AddInIndex(gist *Gist) error { func AddInIndex(gist *Gist) error {
if !IndexEnabled() { if !IndexEnabled() {
return nil return nil
@@ -125,11 +110,7 @@ func RemoveFromIndex(gistID uint) error {
return (*idx).Remove(gistID) return (*idx).Remove(gistID)
} }
// SearchGists returns a list of Gist IDs that match the given search metadata. func SearchGists(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
// If the indexer is not enabled, it returns nil, 0, nil, nil.
// If the indexer is not initialized, it returns nil, 0, nil, fmt.Errorf("indexer is not initialized").
// The function returns an error if any.
func SearchGists(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
if !IndexEnabled() { if !IndexEnabled() {
return nil, 0, nil, nil return nil, 0, nil, nil
} }
@@ -139,7 +120,7 @@ func SearchGists(metadata SearchGistMetadata, userId uint, page int) ([]uint, ui
return nil, 0, nil, fmt.Errorf("indexer is not initialized") return nil, 0, nil, fmt.Errorf("indexer is not initialized")
} }
return (*idx).Search(metadata, userId, page) return (*idx).Search(query, metadata, userId, page)
} }
func DepreactionIndexDirname() { func DepreactionIndexDirname() {

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,9 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"unicode"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
) )
type MeiliIndexer struct { type MeiliIndexer struct {
@@ -52,45 +50,28 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey)) i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey))
indexResult, err := i.client.GetIndex(i.indexName) indexResult, err := i.client.GetIndex(i.indexName)
if indexResult == nil || err != nil { if indexResult != nil && err == nil {
_, err = i.client.CreateIndex(&meilisearch.IndexConfig{ return indexResult.IndexManager, nil
Uid: i.indexName, }
PrimaryKey: "GistID",
}) _, err = i.client.CreateIndex(&meilisearch.IndexConfig{
if err != nil { Uid: i.indexName,
return nil, err PrimaryKey: "GistID",
} })
if err != nil {
return nil, err
} }
_, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{ _, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Extensions", "Languages", "Topics"}, FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
SearchableAttributes: []string{"Content", "ContentSplit", "Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics"}, DisplayedAttributes: []string{"GistID"},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, SearchableAttributes: []string{"Content", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
TypoTolerance: &meilisearch.TypoTolerance{ RankingRules: []string{"words"},
Enabled: true,
DisableOnNumbers: true,
MinWordSizeForTypos: meilisearch.MinWordSizeForTypos{OneTypo: 4, TwoTypos: 10},
},
}) })
return i.client.Index(i.indexName), nil return i.client.Index(i.indexName), nil
} }
func (i *MeiliIndexer) Reset() error {
if i.client != nil {
taskInfo, err := i.client.DeleteIndex(i.indexName)
if err != nil {
return fmt.Errorf("failed to delete Meilisearch index: %w", err)
}
_, err = i.client.WaitForTask(taskInfo.TaskUID, 0)
if err != nil {
return fmt.Errorf("failed to wait for Meilisearch index deletion: %w", err)
}
log.Info().Msg("Meilisearch index deleted, re-creating index")
}
return i.Init()
}
func (i *MeiliIndexer) Close() { func (i *MeiliIndexer) Close() {
if i.client != nil { if i.client != nil {
i.client.Close() i.client.Close()
@@ -99,21 +80,12 @@ func (i *MeiliIndexer) Close() {
i.client = nil i.client = nil
} }
type meiliGist struct {
Gist
ContentSplit string
}
func (i *MeiliIndexer) Add(gist *Gist) error { func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil { if gist == nil {
return errors.New("failed to add nil gist to index") return errors.New("failed to add nil gist to index")
} }
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID" primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey}) _, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
return err return err
} }
@@ -122,14 +94,13 @@ func (i *MeiliIndexer) Remove(gistID uint) error {
return err return err
} }
func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) { func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
searchRequest := &meilisearch.SearchRequest{ searchRequest := &meilisearch.SearchRequest{
Offset: int64((page - 1) * 10), Offset: int64((page - 1) * 10),
Limit: 11, Limit: 11,
AttributesToRetrieve: []string{"GistID", "Languages"}, AttributesToRetrieve: []string{"GistID", "Languages"},
Facets: []string{"Languages"}, Facets: []string{"Languages"},
AttributesToSearchOn: []string{"Content", "ContentSplit"}, AttributesToSearchOn: []string{"Content"},
MatchingStrategy: meilisearch.All,
} }
var filters []string var filters []string
@@ -140,83 +111,23 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value))) filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value)))
} }
} }
var query string addFilter("Username", queryMetadata.Username)
if queryMetadata.All != "" { addFilter("Title", queryMetadata.Title)
query = queryMetadata.All addFilter("Filenames", queryMetadata.Filename)
searchRequest.AttributesToSearchOn = append(AllSearchFields, "ContentSplit") addFilter("Extensions", queryMetadata.Extension)
} else { addFilter("Languages", queryMetadata.Language)
// Exact-match fields stay as filters addFilter("Topics", queryMetadata.Topic)
addFilter("Username", queryMetadata.Username)
if queryMetadata.Extension != "" {
ext := queryMetadata.Extension
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
addFilter("Extensions", ext)
}
addFilter("Languages", queryMetadata.Language)
addFilter("Topics", queryMetadata.Topic)
if queryMetadata.Default != "" {
query = queryMetadata.Default
var fields []string
for _, f := range strings.Split(config.C.SearchDefault, ",") {
f = strings.TrimSpace(f)
if f == "all" {
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) > 0 {
for _, f := range fields {
if f == "Content" {
fields = append(fields, "ContentSplit")
break
}
}
searchRequest.AttributesToSearchOn = fields
}
} else {
// Fuzzy-matchable fields become part of the query
var queryParts []string
var searchFields []string
if queryMetadata.Content != "" {
queryParts = append(queryParts, queryMetadata.Content)
searchFields = append(searchFields, "Content", "ContentSplit")
}
if queryMetadata.Title != "" {
queryParts = append(queryParts, queryMetadata.Title)
searchFields = append(searchFields, "Title")
}
if queryMetadata.Description != "" {
queryParts = append(queryParts, queryMetadata.Description)
searchFields = append(searchFields, "Description")
}
if queryMetadata.Filename != "" {
queryParts = append(queryParts, queryMetadata.Filename)
searchFields = append(searchFields, "Filenames")
}
query = strings.Join(queryParts, " ")
if len(searchFields) > 0 {
searchRequest.AttributesToSearchOn = searchFields
}
}
}
if len(filters) > 0 { if len(filters) > 0 {
searchRequest.Filter = strings.Join(filters, " AND ") searchRequest.Filter = strings.Join(filters, " AND ")
} }
response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(query, searchRequest) response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(queryStr, searchRequest)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to search Meilisearch index") log.Error().Err(err).Msg("Failed to search Meilisearch index")
return nil, 0, nil, err return nil, 0, nil, err
} }
gistIds := make([]uint, 0, len(response.Hits)) gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits { for _, hit := range response.Hits {
if gistIDRaw, ok := hit["GistID"]; ok { if gistIDRaw, ok := hit["GistID"]; ok {
@@ -232,9 +143,7 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
var facetDist map[string]map[string]int var facetDist map[string]map[string]int
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil { if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
if facets, ok := facetDist["Languages"]; ok { if facets, ok := facetDist["Languages"]; ok {
for lang, count := range facets { languageCounts = facets
languageCounts[strings.ToLower(lang)] += count
}
} }
} }
} }
@@ -242,30 +151,6 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
return gistIds, uint64(response.EstimatedTotalHits), languageCounts, nil return gistIds, uint64(response.EstimatedTotalHits), languageCounts, nil
} }
func splitCamelCase(text string) string {
var result strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
r := runes[i]
if i > 0 {
prev := runes[i-1]
if unicode.IsUpper(r) {
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
result.WriteRune(' ')
}
} else if unicode.IsDigit(r) && !unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if !unicode.IsDigit(r) && unicode.IsDigit(prev) {
result.WriteRune(' ')
}
}
result.WriteRune(r)
}
return result.String()
}
func escapeFilterValue(value string) string { func escapeFilterValue(value string) string {
escaped := strings.ReplaceAll(value, "\\", "\\\\") escaped := strings.ReplaceAll(value, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"") escaped = strings.ReplaceAll(escaped, "\"", "\\\"")

View File

@@ -1,88 +0,0 @@
package index
import (
"fmt"
"os"
"strconv"
"testing"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog"
)
// syncMeiliIndexer wraps MeiliIndexer to make Add/Remove synchronous for tests.
type syncMeiliIndexer struct {
*MeiliIndexer
}
func (s *syncMeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return fmt.Errorf("failed to add nil gist to index")
}
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID"
taskInfo, err := s.index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func (s *syncMeiliIndexer) Remove(gistID uint) error {
taskInfo, err := s.index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func setupMeiliIndexer(t *testing.T) (Indexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
t.Helper()
host := os.Getenv("OG_TEST_MEILI_HOST")
if host == "" {
host = "http://localhost:47700"
}
apiKey := os.Getenv("OG_TEST_MEILI_API_KEY")
indexName := fmt.Sprintf("test_%d", os.Getpid())
inner := NewMeiliIndexer(host, apiKey, indexName)
err := inner.Init()
if err != nil {
t.Skipf("MeiliSearch not available at %s: %v", host, err)
}
wrapped := &syncMeiliIndexer{MeiliIndexer: inner}
// Store the inner MeiliIndexer in atomicIndexer, because MeiliIndexer.Search
// type-asserts the global to *MeiliIndexer.
var idx Indexer = inner
atomicIndexer.Store(&idx)
cleanup := func() {
atomicIndexer.Store(nil)
inner.Reset()
inner.Close()
}
return wrapped, cleanup
}
func TestMeiliAddAndSearch(t *testing.T) { testAddAndSearch(t, setupMeiliIndexer) }
func TestMeiliAccessControl(t *testing.T) { testAccessControl(t, setupMeiliIndexer) }
func TestMeiliMetadataFilters(t *testing.T) { testMetadataFilters(t, setupMeiliIndexer) }
func TestMeiliAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupMeiliIndexer) }
func TestMeiliFuzzySearch(t *testing.T) { testFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliContentSearch(t *testing.T) { testContentSearch(t, setupMeiliIndexer) }
func TestMeiliPagination(t *testing.T) { testPagination(t, setupMeiliIndexer) }
func TestMeiliLanguageFacets(t *testing.T) { testLanguageFacets(t, setupMeiliIndexer) }
func TestMeiliMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupMeiliIndexer) }
func TestMeiliTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupMeiliIndexer) }

View File

@@ -164,18 +164,6 @@ func AllGists(ctx *context.Context) error {
return ctx.Html("all.html") return ctx.Html("all.html")
} }
// Search handles the search page for gists.
//
// It takes a query parameter "q" which is a search query in the format:
// "user:username title:title description:description filename:filename language:language topic:topic"
//
// It also takes a page parameter "page" which is the page number to display.
//
// It returns an error if the search query is invalid or if the page number is invalid.
//
// It returns the search results as a list of rendered gists, along with the total number of results, the languages found, and the search query.
//
// The search results are paginated, with 10 results per page.
func Search(ctx *context.Context) error { func Search(ctx *context.Context) error {
var err error var err error
@@ -183,7 +171,7 @@ func Search(ctx *context.Context) error {
Query: ctx.QueryParam("q"), Query: ctx.QueryParam("q"),
} }
metadata := handlers.ParseSearchQueryStr(ctx.QueryParam("q")) content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx) pageInt := handlers.GetPage(ctx)
var currentUserId uint var currentUserId uint
@@ -194,18 +182,14 @@ func Search(ctx *context.Context) error {
currentUserId = 0 currentUserId = 0
} }
// Search gists in the index and fetch the gists IDs from the database gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{
gistsIds, nbHits, langs, err := index.SearchGists(index.SearchGistMetadata{ Username: meta["user"],
Username: metadata["user"], Title: meta["title"],
Title: metadata["title"], Filename: meta["filename"],
Description: metadata["description"], Extension: meta["extension"],
Filename: metadata["filename"], Language: meta["language"],
Extension: metadata["extension"], Topic: meta["topic"],
Language: metadata["language"], All: meta["all"],
Topic: metadata["topic"],
Content: metadata["content"],
All: metadata["all"],
Default: metadata["default"],
}, currentUserId, pageInt) }, currentUserId, pageInt)
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error searching gists", err) return ctx.ErrorRes(500, "Error searching gists", err)

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"github.com/gorilla/schema" "github.com/gorilla/schema"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
) )
@@ -119,16 +119,10 @@ func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int,
return nil return nil
} }
// ParseSearchQueryStr parses a search query string and returns a map of metadata. func ParseSearchQueryStr(query string) (string, map[string]string) {
// The query string is split into words and each word is checked if it contains a colon (:).
// If a word contains a colon, it is split into a key-value pair and added to the metadata map.
// If a word does not contain a colon, it is added to an "all" key in the metadata map.
// The "all" key is used to search all fields in the index.
// The function returns the metadata map.
func ParseSearchQueryStr(query string) map[string]string {
words := strings.Fields(query) words := strings.Fields(query)
metadata := make(map[string]string) metadata := make(map[string]string)
var allFieldsBuilder strings.Builder var contentBuilder strings.Builder
for _, word := range words { for _, word := range words {
if strings.Contains(word, ":") { if strings.Contains(word, ":") {
@@ -139,18 +133,10 @@ func ParseSearchQueryStr(query string) map[string]string {
metadata[key] = value metadata[key] = value
} }
} else { } else {
// Add to content search by default contentBuilder.WriteString(word + " ")
allFieldsBuilder.WriteString(word + " ")
} }
} }
// Set the default search field content := strings.TrimSpace(contentBuilder.String())
allContent := strings.TrimSpace(allFieldsBuilder.String()) return content, metadata
if allContent != "" {
metadata["default"] = allContent
}
log.Debug().Msgf("Metadata: %v", metadata)
return metadata
} }

View File

@@ -147,10 +147,7 @@ func (s *Server) setFuncMap() {
return dict, nil return dict, nil
}, },
"addMetadataToSearchQuery": func(input, key, value string) string { "addMetadataToSearchQuery": func(input, key, value string) string {
metadata := handlers.ParseSearchQueryStr(input) content, metadata := handlers.ParseSearchQueryStr(input)
// extract free-text content (stored under "all") and remove it from metadata
content := metadata["all"]
delete(metadata, "all")
metadata[key] = value metadata[key] = value

40
package-lock.json generated
View File

@@ -8,20 +8,20 @@
"devDependencies": { "devDependencies": {
"@catppuccin/highlightjs": "^1.0.1", "@catppuccin/highlightjs": "^1.0.1",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.2", "@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/text": "^0.19.6", "@codemirror/text": "^0.19.6",
"@codemirror/view": "^6.39.16", "@codemirror/view": "^6.39.11",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"katex": "^0.16.38", "katex": "^0.16.33",
"marked": "^17.0.4", "marked": "^17.0.3",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"pdfobject": "^2.3.1", "pdfobject": "^2.3.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
@@ -78,9 +78,9 @@
} }
}, },
"node_modules/@codemirror/lang-javascript": { "node_modules/@codemirror/lang-javascript": {
"version": "6.2.5", "version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -150,9 +150,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.39.16", "version": "6.39.11",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz",
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1636,9 +1636,9 @@
} }
}, },
"node_modules/github-markdown-css": { "node_modules/github-markdown-css": {
"version": "5.9.0", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.9.0.tgz", "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz",
"integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==", "integrity": "sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1768,9 +1768,9 @@
} }
}, },
"node_modules/katex": { "node_modules/katex": {
"version": "0.16.38", "version": "0.16.33",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz",
"integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://opencollective.com/katex", "https://opencollective.com/katex",
@@ -2066,9 +2066,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "17.0.4", "version": "17.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View File

@@ -13,20 +13,20 @@
"devDependencies": { "devDependencies": {
"@catppuccin/highlightjs": "^1.0.1", "@catppuccin/highlightjs": "^1.0.1",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.2", "@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/text": "^0.19.6", "@codemirror/text": "^0.19.6",
"@codemirror/view": "^6.39.16", "@codemirror/view": "^6.39.11",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"katex": "^0.16.38", "katex": "^0.16.33",
"marked": "^17.0.4", "marked": "^17.0.3",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"pdfobject": "^2.3.1", "pdfobject": "^2.3.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",

View File

@@ -116,12 +116,10 @@
<div class="p-4 text-xs space-y-1"> <div class="p-4 text-xs space-y-1">
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">description:sync</code> {{ .locale.Tr "gist.search.help.description" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">all:systemctl</code> {{ .locale.Tr "gist.search.help.all" }}</p>
</div> </div>
</div> </div>
</div> </div>

277
test.md Normal file
View File

@@ -0,0 +1,277 @@
---
description: Testing handler and middleware
slug: /testing
sidebar_position: 13
---
# Testing
## Testing Handler
`GET` `/users/:id`
Handler below retrieves user by id from the database. If user is not found it returns
`404` error with a message.
### CreateUser
`POST` `/users`
- Accepts JSON payload
- On success `201 - Created`
- On error `500 - Internal Server Error`
### GetUser
`GET` `/users/:email`
- On success `200 - OK`
- On error `404 - Not Found` if user is not found otherwise `500 - Internal Server Error`
`handler.go`
```go
package handler
import (
"net/http"
"github.com/labstack/echo/v5"
)
type (
User struct {
Name string `json:"name" form:"name"`
Email string `json:"email" form:"email"`
}
handler struct {
db map[string]*User
}
)
func (h *handler) createUser(c *echo.Context) error {
u := new(User)
if err := c.Bind(u); err != nil {
return err
}
return c.JSON(http.StatusCreated, u)
}
func (h *handler) getUser(c *echo.Context) error {
email := c.Param("email")
user := h.db[email]
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}
return c.JSON(http.StatusOK, user)
}
```
`handler_test.go`
```go
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/echotest"
"github.com/stretchr/testify/assert"
)
var (
mockDB = map[string]*User{
"jon@labstack.com": &User{"Jon Snow", "jon@labstack.com"},
}
userJSON = `{"name":"Jon Snow","email":"jon@labstack.com"}`
)
func TestCreateUser(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := &controller{mockDB}
// Assertions
if assert.NoError(t, h.createUser(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, userJSON, rec.Body.String())
}
}
// Same test as above but using `echotest` package helpers
func TestCreateUserWithEchoTest(t *testing.T) {
c, rec := echotest.ContextConfig{
Headers: map[string][]string{
echo.HeaderContentType: {echo.MIMEApplicationJSON},
},
JSONBody: []byte(`{"name":"Jon Snow","email":"jon@labstack.com"}`),
}.ToContextRecorder(t)
h := &controller{mockDB}
// Assertions
if assert.NoError(t, h.createUser(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, userJSON+"\n", rec.Body.String())
}
}
// Same test as above but even shorter
func TestCreateUserWithEchoTest2(t *testing.T) {
h := &controller{mockDB}
rec := echotest.ContextConfig{
Headers: map[string][]string{
echo.HeaderContentType: {echo.MIMEApplicationJSON},
},
JSONBody: []byte(`{"name":"Jon Snow","email":"jon@labstack.com"}`),
}.ServeWithHandler(t, h.createUser)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, userJSON+"\n", rec.Body.String())
}
func TestGetUser(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/users/:email")
c.SetPathValues(echo.PathValues{
{Name: "email", Value: "jon@labstack.com"},
})
h := &controller{mockDB}
// Assertions
if assert.NoError(t, h.getUser(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, userJSON, rec.Body.String())
}
}
func TestGetUserWithEchoTest(t *testing.T) {
c, rec := echotest.ContextConfig{
PathValues: echo.PathValues{
{Name: "email", Value: "jon@labstack.com"},
},
Headers: map[string][]string{
echo.HeaderContentType: {echo.MIMEApplicationJSON},
},
JSONBody: []byte(userJSON),
}.ToContextRecorder(t)
h := &controller{mockDB}
// Assertions
if assert.NoError(t, h.getUser(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, userJSON+"\n", rec.Body.String())
}
}
```
### Using Form Payload
```go
// import "net/url"
f := make(url.Values)
f.Set("name", "Jon Snow")
f.Set("email", "jon@labstack.com")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(f.Encode()))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
```
Multipart form payload:
```go
func TestContext_MultipartForm(t *testing.T) {
testConf := echotest.ContextConfig{
MultipartForm: &echotest.MultipartForm{
Fields: map[string]string{
"key": "value",
},
Files: []echotest.MultipartFormFile{
{
Fieldname: "file",
Filename: "test.json",
Content: echotest.LoadBytes(t, "testdata/test.json"),
},
},
},
}
c := testConf.ToContext(t)
assert.Equal(t, "value", c.FormValue("key"))
assert.Equal(t, http.MethodPost, c.Request().Method)
assert.Equal(t, true, strings.HasPrefix(c.Request().Header.Get(echo.HeaderContentType), "multipart/form-data; boundary="))
fv, err := c.FormFile("file")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "test.json", fv.Filename)
}
```
### Setting Path Params
```go
c.SetPathValues(echo.PathValues{
{Name: "id", Value: "1"},
{Name: "email", Value: "jon@labstack.com"},
})
```
### Setting Query Params
```go
// import "net/url"
q := make(url.Values)
q.Set("email", "jon@labstack.com")
req := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil)
```
## Testing Middleware
```go
func TestCreateUserWithEchoTest2(t *testing.T) {
handler := func(c *echo.Context) error {
return c.JSON(http.StatusTeapot, fmt.Sprintf("email: %s", c.Param("email")))
}
middleware := func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
c.Set("user_id", int64(1234))
return next(c)
}
}
c, rec := echotest.ContextConfig{
PathValues: echo.PathValues{{Name: "email", Value: "jon@labstack.com"}},
}.ToContextRecorder(t)
err := middleware(handler)(c)
if err != nil {
t.Fatal(err)
}
// check that middleware set the value
userID, err := echo.ContextGet[int64](c, "user_id")
assert.NoError(t, err)
assert.Equal(t, int64(1234), userID)
// check that handler returned the correct response
assert.Equal(t, http.StatusTeapot, rec.Code)
}
```
For now you can look into built-in middleware [test cases](https://github.com/labstack/echo/tree/master/middleware).

158
test2.md Normal file
View File

@@ -0,0 +1,158 @@
---
description: Testing handler and middleware
slug: /testing
sidebar_position: 13
---
# Testing
## Testing Handler
`GET` `/users/:id`
Handler below retrieves user by id from the database. If user is not found it returns
`404` error with a message.
### CreateUser
`POST` `/users`
- Accepts JSON payload
- On success `201 - Created`
- On error `500 - Internal Server Error`
### GetUser
`GET` `/users/:email`
- On success `200 - OK`
- On error `404 - Not Found` if user is not found otherwise `500 - Internal Server Error`
`handler.go`
```go
package handler
import (
"net/http"
"github.com/labstack/echo/v4"
)
type (
User struct {
Name string `json:"name" form:"name"`
Email string `json:"email" form:"email"`
}
handler struct {
db map[string]*User
}
)
func (h *handler) createUser(c echo.Context) error {
u := new(User)
if err := c.Bind(u); err != nil {
return err
}
return c.JSON(http.StatusCreated, u)
}
func (h *handler) getUser(c echo.Context) error {
email := c.Param("email")
user := h.db[email]
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}
return c.JSON(http.StatusOK, user)
}
```
`handler_test.go`
```go
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
var (
mockDB = map[string]*User{
"jon@labstack.com": &User{"Jon Snow", "jon@labstack.com"},
}
userJSON = `{"name":"Jon Snow","email":"jon@labstack.com"}`
)
func TestCreateUser(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := &handler{mockDB}
// Assertions
if assert.NoError(t, h.createUser(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, userJSON, rec.Body.String())
}
}
func TestGetUser(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/users/:email")
c.SetParamNames("email")
c.SetParamValues("jon@labstack.com")
h := &handler{mockDB}
// Assertions
if assert.NoError(t, h.getUser(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, userJSON, rec.Body.String())
}
}
```
### Using Form Payload
```go
// import "net/url"
f := make(url.Values)
f.Set("name", "Jon Snow")
f.Set("email", "jon@labstack.com")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(f.Encode()))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
```
### Setting Path Params
```go
c.SetParamNames("id", "email")
c.SetParamValues("1", "jon@labstack.com")
```
### Setting Query Params
```go
// import "net/url"
q := make(url.Values)
q.Set("email", "jon@labstack.com")
req := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil)
```
## Testing Middleware
*TBD*
For now you can look into built-in middleware [test cases](https://github.com/labstack/echo/tree/master/middleware).