893 lines
18 KiB
Go
893 lines
18 KiB
Go
// backend\search.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type globalSearchResponse struct {
|
|
Query string `json:"query"`
|
|
Groups []globalSearchGroup `json:"groups"`
|
|
}
|
|
|
|
type globalSearchGroup struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Items []globalSearchItem `json:"items"`
|
|
}
|
|
|
|
type globalSearchItem struct {
|
|
ID string `json:"id"`
|
|
EntityID string `json:"entityId"`
|
|
EntityType string `json:"entityType"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
Href string `json:"href"`
|
|
Data map[string]any `json:"data,omitempty"`
|
|
}
|
|
|
|
type globalSearchOptions struct {
|
|
Query string
|
|
Limit int
|
|
ExactPhrase bool
|
|
CaseSensitive bool
|
|
}
|
|
|
|
func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
|
|
options := globalSearchOptions{
|
|
Query: query,
|
|
Limit: parseSearchLimit(r.URL.Query().Get("limit"), 8),
|
|
ExactPhrase: parseSearchBool(r.URL.Query().Get("exact")),
|
|
CaseSensitive: parseSearchBool(r.URL.Query().Get("caseSensitive")),
|
|
}
|
|
|
|
if !strings.Contains(query, " ") {
|
|
options.ExactPhrase = false
|
|
}
|
|
|
|
if len([]rune(query)) < 2 {
|
|
writeJSON(w, http.StatusOK, globalSearchResponse{
|
|
Query: query,
|
|
Groups: []globalSearchGroup{},
|
|
})
|
|
return
|
|
}
|
|
|
|
groups := make([]globalSearchGroup, 0, 6)
|
|
|
|
if userCanSearch(user, "operations:read") {
|
|
items, err := s.searchOperations(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "operations",
|
|
Title: "Einsätze",
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
if userCanSearch(user, "operations:read") {
|
|
items, err := s.searchOperationJournalEntries(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Einsatz-Journale konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "operation-journals",
|
|
Title: "Einsatz-Journale",
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
if userCanSearch(user, "devices:read") {
|
|
items, err := s.searchDeviceJournalEntries(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Geräte-Journale konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "device-journals",
|
|
Title: "Geräte-Journale",
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
if userCanSearch(user, "devices:read") {
|
|
items, err := s.searchDevices(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "devices",
|
|
Title: "Geräte",
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
if userCanSearch(user, "users:read") {
|
|
items, err := s.searchUsers(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "users",
|
|
Title: "Benutzer",
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
items, err := s.searchPublicChatMessages(r.Context(), options)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Öffentliche Chats konnten nicht durchsucht werden")
|
|
return
|
|
}
|
|
|
|
if len(items) > 0 {
|
|
groups = append(groups, globalSearchGroup{
|
|
ID: "public-chat-messages",
|
|
Title: "Öffentliche Chats",
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, globalSearchResponse{
|
|
Query: query,
|
|
Groups: groups,
|
|
})
|
|
}
|
|
|
|
func (s *Server) searchOperations(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
id::TEXT,
|
|
operation_number,
|
|
operation_name,
|
|
offense,
|
|
kw_address,
|
|
operation_leader,
|
|
operation_team
|
|
FROM operations
|
|
WHERE (
|
|
(
|
|
$3 = false
|
|
AND CONCAT_WS(
|
|
' ',
|
|
operation_number,
|
|
operation_name,
|
|
offense,
|
|
case_worker,
|
|
target_object,
|
|
kw_address,
|
|
operation_leader,
|
|
operation_team,
|
|
legend,
|
|
remark
|
|
) ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND CONCAT_WS(
|
|
' ',
|
|
operation_number,
|
|
operation_name,
|
|
offense,
|
|
case_worker,
|
|
target_object,
|
|
kw_address,
|
|
operation_leader,
|
|
operation_team,
|
|
legend,
|
|
remark
|
|
) LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY updated_at DESC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
var operationNumber string
|
|
var operationName string
|
|
var offense string
|
|
var kwAddress string
|
|
var operationLeader string
|
|
var operationTeam string
|
|
|
|
if err := rows.Scan(
|
|
&id,
|
|
&operationNumber,
|
|
&operationName,
|
|
&offense,
|
|
&kwAddress,
|
|
&operationLeader,
|
|
&operationTeam,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := operationName
|
|
if strings.TrimSpace(title) == "" {
|
|
title = operationNumber
|
|
}
|
|
|
|
subtitle := joinSearchSubtitle(
|
|
operationNumber,
|
|
offense,
|
|
kwAddress,
|
|
operationLeader,
|
|
operationTeam,
|
|
)
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "operation-" + id,
|
|
EntityID: id,
|
|
EntityType: "operation",
|
|
Title: title,
|
|
Subtitle: subtitle,
|
|
Href: "/einsaetze",
|
|
Data: map[string]any{
|
|
"operationNumber": operationNumber,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) searchDevices(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
id::TEXT,
|
|
inventory_number,
|
|
manufacturer,
|
|
model,
|
|
serial_number,
|
|
mac_address,
|
|
COALESCE(ip_address, ''),
|
|
location,
|
|
loan_status,
|
|
COALESCE(firmware_version, '')
|
|
FROM devices
|
|
WHERE (
|
|
(
|
|
$3 = false
|
|
AND CONCAT_WS(
|
|
' ',
|
|
inventory_number,
|
|
manufacturer,
|
|
model,
|
|
serial_number,
|
|
mac_address,
|
|
COALESCE(ip_address, ''),
|
|
location,
|
|
loan_status,
|
|
comment,
|
|
COALESCE(firmware_version, '')
|
|
) ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND CONCAT_WS(
|
|
' ',
|
|
inventory_number,
|
|
manufacturer,
|
|
model,
|
|
serial_number,
|
|
mac_address,
|
|
COALESCE(ip_address, ''),
|
|
location,
|
|
loan_status,
|
|
comment,
|
|
COALESCE(firmware_version, '')
|
|
) LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY updated_at DESC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
var inventoryNumber string
|
|
var manufacturer string
|
|
var model string
|
|
var serialNumber string
|
|
var macAddress string
|
|
var ipAddress string
|
|
var location string
|
|
var loanStatus string
|
|
var firmwareVersion string
|
|
|
|
if err := rows.Scan(
|
|
&id,
|
|
&inventoryNumber,
|
|
&manufacturer,
|
|
&model,
|
|
&serialNumber,
|
|
&macAddress,
|
|
&ipAddress,
|
|
&location,
|
|
&loanStatus,
|
|
&firmwareVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "device-" + id,
|
|
EntityID: id,
|
|
EntityType: "device",
|
|
Title: inventoryNumber,
|
|
Subtitle: joinSearchSubtitle(
|
|
manufacturer,
|
|
model,
|
|
serialNumber,
|
|
macAddress,
|
|
ipAddress,
|
|
location,
|
|
loanStatus,
|
|
firmwareVersion,
|
|
),
|
|
Href: "/geraete",
|
|
Data: map[string]any{
|
|
"inventoryNumber": inventoryNumber,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) searchOperationJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
operation_history.id::TEXT,
|
|
operation_history.operation_id::TEXT,
|
|
operations.operation_number,
|
|
operations.operation_name,
|
|
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
|
|
COALESCE(journal_change.change->>'newValue', ''),
|
|
operation_history.created_at
|
|
FROM operation_history
|
|
INNER JOIN operations
|
|
ON operations.id = operation_history.operation_id
|
|
LEFT JOIN users
|
|
ON users.id = operation_history.user_id
|
|
CROSS JOIN LATERAL jsonb_array_elements(operation_history.changes) AS journal_change(change)
|
|
WHERE
|
|
journal_change.change->>'field' = 'journal'
|
|
AND (
|
|
(
|
|
$3 = false
|
|
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY operation_history.created_at DESC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
var operationID string
|
|
var operationNumber string
|
|
var operationName string
|
|
var userDisplayName string
|
|
var content string
|
|
var createdAt time.Time
|
|
|
|
if err := rows.Scan(
|
|
&id,
|
|
&operationID,
|
|
&operationNumber,
|
|
&operationName,
|
|
&userDisplayName,
|
|
&content,
|
|
&createdAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := operationName
|
|
if strings.TrimSpace(title) == "" {
|
|
title = operationNumber
|
|
}
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "operation-journal-" + id,
|
|
EntityID: operationID,
|
|
EntityType: "operationJournal",
|
|
Title: title,
|
|
Subtitle: joinSearchSubtitle(
|
|
formatSearchDate(createdAt),
|
|
userDisplayName,
|
|
truncateSearchText(content, 140),
|
|
),
|
|
Href: "/einsaetze",
|
|
Data: map[string]any{
|
|
"historyId": id,
|
|
"operationId": operationID,
|
|
"operationNumber": operationNumber,
|
|
"journalCreatedAt": createdAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) searchDeviceJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
device_history.id::TEXT,
|
|
device_history.device_id::TEXT,
|
|
devices.inventory_number,
|
|
devices.manufacturer,
|
|
devices.model,
|
|
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
|
|
COALESCE(journal_change.change->>'newValue', ''),
|
|
device_history.created_at
|
|
FROM device_history
|
|
INNER JOIN devices
|
|
ON devices.id = device_history.device_id
|
|
LEFT JOIN users
|
|
ON users.id = device_history.user_id
|
|
CROSS JOIN LATERAL jsonb_array_elements(device_history.changes) AS journal_change(change)
|
|
WHERE
|
|
journal_change.change->>'field' = 'journal'
|
|
AND (
|
|
(
|
|
$3 = false
|
|
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY device_history.created_at DESC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
var deviceID string
|
|
var inventoryNumber string
|
|
var manufacturer string
|
|
var model string
|
|
var userDisplayName string
|
|
var content string
|
|
var createdAt time.Time
|
|
|
|
if err := rows.Scan(
|
|
&id,
|
|
&deviceID,
|
|
&inventoryNumber,
|
|
&manufacturer,
|
|
&model,
|
|
&userDisplayName,
|
|
&content,
|
|
&createdAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deviceName := joinSearchSubtitle(manufacturer, model)
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "device-journal-" + id,
|
|
EntityID: deviceID,
|
|
EntityType: "deviceJournal",
|
|
Title: inventoryNumber,
|
|
Subtitle: joinSearchSubtitle(
|
|
deviceName,
|
|
formatSearchDate(createdAt),
|
|
userDisplayName,
|
|
truncateSearchText(content, 140),
|
|
),
|
|
Href: "/geraete",
|
|
Data: map[string]any{
|
|
"historyId": id,
|
|
"deviceId": deviceID,
|
|
"inventoryNumber": inventoryNumber,
|
|
"journalCreatedAt": createdAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
id::TEXT,
|
|
COALESCE(username, ''),
|
|
COALESCE(display_name, ''),
|
|
COALESCE(email, ''),
|
|
COALESCE(unit, ''),
|
|
COALESCE(user_group, '')
|
|
FROM users
|
|
WHERE (
|
|
(
|
|
$3 = false
|
|
AND CONCAT_WS(
|
|
' ',
|
|
username,
|
|
display_name,
|
|
email,
|
|
unit,
|
|
user_group
|
|
) ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND CONCAT_WS(
|
|
' ',
|
|
username,
|
|
display_name,
|
|
email,
|
|
unit,
|
|
user_group
|
|
) LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY display_name ASC, username ASC, email ASC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
var username string
|
|
var displayName string
|
|
var email string
|
|
var unit string
|
|
var group string
|
|
|
|
if err := rows.Scan(
|
|
&id,
|
|
&username,
|
|
&displayName,
|
|
&email,
|
|
&unit,
|
|
&group,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := displayName
|
|
if strings.TrimSpace(title) == "" {
|
|
title = username
|
|
}
|
|
if strings.TrimSpace(title) == "" {
|
|
title = email
|
|
}
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "user-" + id,
|
|
EntityID: id,
|
|
EntityType: "user",
|
|
Title: title,
|
|
Subtitle: joinSearchSubtitle(email, unit, group),
|
|
Href: "/administration",
|
|
Data: map[string]any{
|
|
"username": username,
|
|
"email": email,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) searchPublicChatMessages(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
|
patterns := buildSearchPatterns(options)
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT
|
|
m.id::TEXT,
|
|
m.conversation_id::TEXT,
|
|
COALESCE(
|
|
NULLIF(c.name, ''),
|
|
(
|
|
SELECT string_agg(
|
|
COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email),
|
|
' & '
|
|
ORDER BY lower(COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email))
|
|
)
|
|
FROM conversation_members cm
|
|
JOIN users member ON member.id = cm.user_id
|
|
WHERE cm.conversation_id = c.id
|
|
),
|
|
'Chat'
|
|
),
|
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
|
m.body,
|
|
m.created_at
|
|
FROM messages m
|
|
JOIN conversations c ON c.id = m.conversation_id
|
|
LEFT JOIN users u ON u.id = m.sender_id
|
|
WHERE c.type <> 'channel'
|
|
AND c.is_public = true
|
|
AND m.deleted_at IS NULL
|
|
AND m.message_type = 'user'
|
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
|
AND (
|
|
(
|
|
$3 = false
|
|
AND CONCAT_WS(' ', c.name, m.body) ILIKE ALL($1::TEXT[])
|
|
)
|
|
OR
|
|
(
|
|
$3 = true
|
|
AND CONCAT_WS(' ', c.name, m.body) LIKE ALL($1::TEXT[])
|
|
)
|
|
)
|
|
ORDER BY m.created_at DESC
|
|
LIMIT $2
|
|
`,
|
|
patterns,
|
|
options.Limit,
|
|
options.CaseSensitive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []globalSearchItem{}
|
|
|
|
for rows.Next() {
|
|
var messageID string
|
|
var conversationID string
|
|
var conversationName string
|
|
var senderDisplayName string
|
|
var body string
|
|
var createdAt time.Time
|
|
|
|
if err := rows.Scan(
|
|
&messageID,
|
|
&conversationID,
|
|
&conversationName,
|
|
&senderDisplayName,
|
|
&body,
|
|
&createdAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items = append(items, globalSearchItem{
|
|
ID: "public-chat-message-" + messageID,
|
|
EntityID: conversationID,
|
|
EntityType: "publicChatMessage",
|
|
Title: conversationName,
|
|
Subtitle: joinSearchSubtitle(
|
|
formatSearchDate(createdAt),
|
|
senderDisplayName,
|
|
truncateSearchText(body, 140),
|
|
),
|
|
Href: "/chat/" + conversationID,
|
|
Data: map[string]any{
|
|
"messageId": messageID,
|
|
"conversationId": conversationID,
|
|
"createdAt": createdAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func parseSearchLimit(value string, fallback int) int {
|
|
limit, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
|
|
if limit < 1 {
|
|
return fallback
|
|
}
|
|
|
|
if limit > 25 {
|
|
return 25
|
|
}
|
|
|
|
return limit
|
|
}
|
|
|
|
func parseSearchBool(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
|
|
return value == "1" ||
|
|
value == "true" ||
|
|
value == "yes" ||
|
|
value == "on"
|
|
}
|
|
|
|
func buildSearchPatterns(options globalSearchOptions) []string {
|
|
query := strings.TrimSpace(options.Query)
|
|
|
|
if query == "" {
|
|
return []string{"%%"}
|
|
}
|
|
|
|
if options.ExactPhrase && strings.Contains(query, " ") {
|
|
return []string{"%" + query + "%"}
|
|
}
|
|
|
|
parts := strings.Fields(query)
|
|
patterns := make([]string, 0, len(parts))
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
patterns = append(patterns, "%"+part+"%")
|
|
}
|
|
}
|
|
|
|
if len(patterns) == 0 {
|
|
return []string{"%" + query + "%"}
|
|
}
|
|
|
|
return patterns
|
|
}
|
|
|
|
func userCanSearch(user User, right string) bool {
|
|
for _, userRight := range user.Rights {
|
|
if userRight == "admin" || userRight == right {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func joinSearchSubtitle(values ...string) string {
|
|
parts := make([]string, 0, len(values))
|
|
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
parts = append(parts, value)
|
|
}
|
|
}
|
|
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
func formatSearchDate(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
|
|
return value.Format("02.01.2006 15:04")
|
|
}
|
|
|
|
func truncateSearchText(value string, maxLength int) string {
|
|
value = strings.TrimSpace(value)
|
|
|
|
if maxLength <= 0 {
|
|
return value
|
|
}
|
|
|
|
runes := []rune(value)
|
|
if len(runes) <= maxLength {
|
|
return value
|
|
}
|
|
|
|
return strings.TrimSpace(string(runes[:maxLength])) + "…"
|
|
}
|