From f7e5655e05d4ef642a90b18d2a3afc4e20d46afd Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 8 Oct 2026 17:40:46 -0400 Subject: [PATCH] Rebuild global search as a live search palette - Press / or Ctrl+K (or click the top bar box) for results as you type, grouped by type, with keyboard navigation and recent clients when empty - Ticket/invoice/quote numbers jump to the top; Enter opens immediately - Phone, IP (any interface) and MAC matching; app pages offered by name - Full results page rebuilt on the same search: totals, links into each list page, ticket conversation and document text matches - Passwords no longer rendered into the results page; reveal/copy go through the audited endpoint - Fix stray }); breaking inline scripts on asset and contact pages --- agent/ajax.php | 31 ++ agent/asset.php | 1 - agent/contact.php | 1 - agent/global_search.php | 1080 +++++++-------------------------------- css/itflow_custom.css | 225 ++++++++ functions.php | 1 + functions/search.php | 602 ++++++++++++++++++++++ includes/footer.php | 1 + includes/top_nav.php | 7 +- js/global_search.js | 640 +++++++++++++++++++++++ 10 files changed, 1681 insertions(+), 908 deletions(-) create mode 100644 functions/search.php create mode 100644 js/global_search.js diff --git a/agent/ajax.php b/agent/ajax.php index 558a8daa9..012a60e46 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -382,6 +382,37 @@ if (isset($_GET['get_active_clients'])) { echo json_encode($response); } +/* + * Live results for the search palette in the top bar (js/global_search.js). + * + * Same search as the full results page - functions/search.php - so the two can + * never disagree about what this user may see. Five per group plus any app page + * whose name matches; with nothing typed yet, the recently opened clients. + * Values are raw: the palette renders them as text, never as HTML. + */ +if (isset($_GET['global_search'])) { + + $global_search_query = (string) ($_GET['q'] ?? ''); + + $response = [ + 'query' => $global_search_query, + 'pages' => [], + 'groups' => [], + 'recent' => [], + ]; + + if (trim($global_search_query) === '') { + $response['recent'] = globalSearchRecentClients(); + } else { + $response['pages'] = globalSearchPages($global_search_query); + $response['groups'] = globalSearch($global_search_query, 5); + } + + header('Content-Type: application/json'); + echo json_encode($response, JSON_INVALID_UTF8_SUBSTITUTE); + exit; +} + /* * Returns the body of one canned response, for the picker on the ticket reply form. * Fetched on demand rather than rendered into every ticket page, because the bodies are diff --git a/agent/asset.php b/agent/asset.php index 8ca78929c..cb6e8b647 100644 --- a/agent/asset.php +++ b/agent/asset.php @@ -1365,7 +1365,6 @@ if (isset($_GET['asset_id'])) { syncAuthForm(select); }); }); - }); diff --git a/agent/contact.php b/agent/contact.php index d3846cc5f..e32cd499a 100644 --- a/agent/contact.php +++ b/agent/contact.php @@ -1241,7 +1241,6 @@ if (isset($_GET['contact_id'])) { syncAuthForm(select); }); }); - }); diff --git a/agent/global_search.php b/agent/global_search.php index 8586aca7b..54fa73b4a 100644 --- a/agent/global_search.php +++ b/agent/global_search.php @@ -1,958 +1,230 @@ set('URI.AllowedSchemes', ['data' => true, 'src' => true, 'http' => true, 'https' => true]); -$purifier = new HTMLPurifier($purifier_config); +$search_results = []; +if (mb_strlen($search_query) >= 2) { + $search_results = globalSearch($search_query, 25, true, true); +} -if (isset($_GET['query'])) { +$search_total = array_sum(array_column($search_results, 'total')); - $query = escapeSql($_GET['query']); +// Usernames for the credentials shown - decrypted here, the same as the +// credentials list does, and only for rows that already passed the search's gates +$search_has_credentials = false; +$credential_usernames = []; +foreach ($search_results as $search_group) { + if ($search_group['key'] === 'credentials') { + $search_has_credentials = true; + $credential_ids = implode(',', array_map('intval', array_column($search_group['items'], 'id'))); + $sql_credential_usernames = mysqli_query($mysqli, "SELECT credential_id, credential_username FROM credentials WHERE credential_id IN ($credential_ids)"); + while ($row = mysqli_fetch_assoc($sql_credential_usernames)) { + $credential_usernames[intval($row['credential_id'])] = decryptCredentialEntry($row['credential_username']); + } + } +} - $phone_query = preg_replace("/[^0-9]/", '', $query); - if (empty($phone_query)) { - $phone_query = $query; +/** Escaped text with every occurrence of the query wrapped in . */ +function searchResultHighlight($text, $query) { + $text = (string) $text; + if ($query === '' || $text === '') { + return escapeHtml($text); } - $ticket_num_query = str_replace("$config_ticket_prefix", "", "$query"); + $output = ''; + $offset = 0; + $length = mb_strlen($query); + while (($position = mb_stripos($text, $query, $offset)) !== false) { + $output .= escapeHtml(mb_substr($text, $offset, $position - $offset)); + $output .= '' . escapeHtml(mb_substr($text, $position, $length)) . ''; + $offset = $position + $length; + } - // Every dedicated page gates on its module, so search must too - otherwise this - // page hands a role results it has no access to read anywhere else (see the - // credentials panel, which renders plaintext usernames and passwords) - $can_client = lookupUserPermission('module_client') >= 1; - $can_support = lookupUserPermission('module_support') >= 1; - $can_sales = lookupUserPermission('module_sales') >= 1; - $can_credential = lookupUserPermission('module_credential') >= 1; + return $output . escapeHtml(mb_substr($text, $offset)); +} - $sql_clients = !$can_client ? false : mysqli_query($mysqli, "SELECT client_id, client_name, client_website, location_phone, location_phone_country_code - FROM clients - LEFT JOIN locations ON clients.client_id = locations.location_client_id AND location_primary = 1 - WHERE client_archived_at IS NULL - AND (client_name LIKE '%$query%' OR client_abbreviation LIKE '%$query%') - " . clientScopeSql('clients.client_id') . " - ORDER BY client_id DESC LIMIT 5" - ); +function searchResultBadge($badge) { + if (empty($badge['text'])) { + return ''; + } - $sql_contacts = !$can_client ? false : mysqli_query($mysqli, "SELECT client_id, client_name, contact_department, contact_email, contact_extension, contact_id, - contact_mobile, contact_mobile_country_code, contact_name, contact_phone, - contact_phone_country_code, contact_title - FROM contacts - LEFT JOIN clients ON client_id = contact_client_id - WHERE contact_archived_at IS NULL - AND (contact_name LIKE '%$query%' - OR contact_title LIKE '%$query%' - OR contact_email LIKE '%$query%' - OR contact_phone LIKE '%$phone_query%' - OR contact_mobile LIKE '%$phone_query%') - " . clientScopeSql('contact_client_id') . " - ORDER BY contact_id DESC LIMIT 5" - ); + $text = escapeHtml($badge['text']); - $sql_vendors = !$can_client ? false : mysqli_query($mysqli, "SELECT client_id, client_name, vendor_description, vendor_name, vendor_phone, - vendor_phone_country_code - FROM vendors - LEFT JOIN clients ON vendor_client_id = client_id - WHERE vendor_archived_at IS NULL - AND (vendor_name LIKE '%$query%' OR vendor_phone LIKE '%$phone_query%') - " . clientScopeSql('vendor_client_id') . " - ORDER BY vendor_id DESC LIMIT 5" - ); + if (!empty($badge['variant']) && in_array($badge['variant'], ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'], true)) { + return "$text"; + } - $sql_domains = !$can_support ? false : mysqli_query($mysqli, "SELECT client_id, client_name, domain_expire, domain_id, domain_name - FROM domains - LEFT JOIN clients ON domain_client_id = client_id - WHERE domain_archived_at IS NULL - AND domain_name LIKE '%$query%' - " . clientScopeSql('domain_client_id') . " - ORDER BY domain_id DESC LIMIT 5" - ); + if (!empty($badge['hex']) && preg_match('/^#[0-9a-f]{3,8}$/i', $badge['hex'])) { + return "$text"; + } - $sql_products = !$can_sales ? false : mysqli_query($mysqli, "SELECT product_description, product_name - FROM products - WHERE product_archived_at IS NULL - AND product_name LIKE '%$query%' - ORDER BY product_id DESC LIMIT 5" - ); + return "$text"; +} - $sql_documents = !$can_support ? false : mysqli_query($mysqli, "SELECT client_name, document_client_id, document_id, document_name - FROM documents - LEFT JOIN clients on document_client_id = clients.client_id - WHERE document_archived_at IS NULL - AND MATCH(document_content_raw) AGAINST ('$query') - " . clientScopeSql('document_client_id') . " - ORDER BY document_id DESC LIMIT 5" - ); +?> - $sql_files = !$can_support ? false : mysqli_query($mysqli, "SELECT client_name, file_client_id, file_description, file_id, file_name, folder_id, folder_name - FROM files - LEFT JOIN clients ON file_client_id = client_id - LEFT JOIN folders ON folder_id = file_folder_id - WHERE file_archived_at IS NULL - AND (file_name LIKE '%$query%' - OR file_description LIKE '%$query%') - " . clientScopeSql('file_client_id') . " - ORDER BY file_id DESC LIMIT 5" - ); +
+
+

Search

+
+
+
+
+ + +
+
- $sql_tickets = !$can_support ? false : mysqli_query($mysqli, "SELECT client_name, ticket_client_id, ticket_id, ticket_number, ticket_prefix, ticket_status_name, - ticket_subject - FROM tickets - LEFT JOIN clients on tickets.ticket_client_id = clients.client_id - LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id - WHERE ticket_archived_at IS NULL - AND (ticket_subject LIKE '%$query%' - OR ticket_details LIKE '%$query%' - OR CONCAT(ticket_prefix,ticket_number) LIKE '%$query%' - OR ticket_number = '$ticket_num_query') - " . clientScopeSql('tickets.ticket_client_id') . " - ORDER BY ticket_id DESC LIMIT 5" - ); - - $sql_recurring_tickets = !$can_support ? false : mysqli_query($mysqli, "SELECT client_id, client_name, recurring_ticket_frequency, recurring_ticket_id, - recurring_ticket_next_run, recurring_ticket_subject - FROM recurring_tickets - LEFT JOIN clients ON recurring_ticket_client_id = client_id - WHERE (recurring_ticket_subject LIKE '%$query%' - OR recurring_ticket_details LIKE '%$query%') - " . clientScopeSql('recurring_ticket_client_id') . " - ORDER BY recurring_ticket_id DESC LIMIT 5" - ); - - $sql_credentials = !$can_credential ? false : mysqli_query($mysqli, "SELECT client_id, client_name, credential_client_id, credential_description, credential_name, - credential_password, credential_username - FROM credentials - LEFT JOIN contacts ON credential_contact_id = contact_id - LEFT JOIN clients ON credential_client_id = client_id - WHERE credential_archived_at IS NULL - AND (credential_name LIKE '%$query%' OR credential_description LIKE '%$query%') - " . clientScopeSql('credential_client_id') . " - ORDER BY credential_id DESC LIMIT 5" - ); - - $sql_quotes = !$can_sales ? false : mysqli_query($mysqli, "SELECT client_id, client_name, quote_amount, quote_currency_code, quote_id, quote_number, - quote_prefix, quote_status - FROM quotes - LEFT JOIN clients ON quote_client_id = client_id - LEFT JOIN categories ON quote_category_id = category_id - WHERE quote_archived_at IS NULL - AND (CONCAT(quote_prefix,quote_number) LIKE '%$query%' OR quote_number LIKE '%$query%' OR quote_scope LIKE '%$query%') - " . clientScopeSql('quote_client_id') . " - ORDER BY quote_number DESC LIMIT 5" - ); - - $sql_invoices = !$can_sales ? false : mysqli_query($mysqli, "SELECT client_id, client_name, invoice_amount, invoice_currency_code, invoice_id, invoice_number, - invoice_prefix, invoice_status - FROM invoices - LEFT JOIN clients ON invoice_client_id = client_id - LEFT JOIN categories ON invoice_category_id = category_id - WHERE invoice_archived_at IS NULL - AND (CONCAT(invoice_prefix,invoice_number) LIKE '%$query%' OR invoice_number LIKE '%$query%' OR invoice_scope LIKE '%$query%') - " . clientScopeSql('invoice_client_id') . " - ORDER BY invoice_number DESC LIMIT 5" - ); - - $sql_assets = !$can_support ? false : mysqli_query($mysqli,"SELECT asset_client_id, asset_contact_id, asset_created_at, asset_description, asset_id, - asset_location_id, asset_make, asset_model, asset_name, asset_serial, asset_status, asset_type, - asset_uri, client_name, contact_archived_at, contact_id, contact_name - FROM assets - LEFT JOIN contacts ON asset_contact_id = contact_id - LEFT JOIN locations ON asset_location_id = location_id - LEFT JOIN clients ON asset_client_id = client_id - LEFT JOIN asset_interfaces ON interface_asset_id = asset_id AND interface_primary = 1 - WHERE asset_archived_at IS NULL - AND (asset_name LIKE '%$query%' OR asset_description LIKE '%$query%' OR asset_type LIKE '%$query%' OR asset_make LIKE '%$query%' OR asset_model LIKE '%$query%' OR asset_serial LIKE '%$query%' OR asset_os LIKE '%$query%' OR interface_ip LIKE '%$query%' OR interface_nat_ip LIKE '%$query%' OR interface_mac LIKE '%$query%' OR asset_status LIKE '%$query%') - " . clientScopeSql('asset_client_id') . " - ORDER BY asset_name DESC LIMIT 5" - ); - - $sql_ticket_replies = !$can_support ? false : mysqli_query($mysqli,"SELECT client_name, ticket_client_id, ticket_id, ticket_number, ticket_prefix, ticket_reply, - ticket_subject - FROM ticket_replies - LEFT JOIN tickets ON ticket_reply_ticket_id = ticket_id - LEFT JOIN clients ON ticket_client_id = client_id - WHERE ticket_reply_archived_at IS NULL - AND (ticket_reply LIKE '%$query%') - " . clientScopeSql('ticket_client_id') . " - ORDER BY ticket_id DESC, ticket_reply_id ASC LIMIT 20" - ); - - $q = escapeHtml($_GET['query']); - - ?> - -
-
-

Global Search

+ 1) { ?> + +
-
- 0) { ?> + - - -
-
-
-
Clients
-
-
- - - - - - - - - - - - - - - - - -
NamePhone
-
-
+
+ +
Search everything in one place
+

+ Names, emails, phone numbers, IP and MAC addresses, serials, ticket, invoice and quote numbers - + plus ticket conversations and document text.
+ Press / on any page to search without leaving it. +

- + - 0) { ?> - - - -
-
-
-
Contacts
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
NameEmailPhoneCellClient
-
-
-
-
+
+ +
Type at least two characters
- + - 0) { ?> - - -
-
-
-
Vendors
-
-
- - - - - - - - - - - - - - - - - - - - - - -
NameDescriptionPhoneClient
-
-
+
+ +
Nothing matches ""
+

Check the spelling, or try part of a name, a number or an address.

- + - 0) { ?> +

+ result for "" +

- -
-
-
-
Domains
+ + +
+
+

+ + +

+ + + Open in + +
-
- - - - - - - - - - - - - - - - - - -
NameExpiryClient
-
-
-
-
- - - - 0) { ?> - - -
-
-
-
Products
-
-
- - - - - - - - - - - - - - - - - - -
NameDescription
-
-
-
- - - - 0) { ?> - - -
-
-
-
Documents
-
-
- - - - - - - - - - - - - - - - - - -
DocumentClient
- -
-
-
-
- - - - 0) { ?> - - -
-
-
-
Files
-
-
- - - - - - - - - - - - - - - - - - - - -
File NameDescriptionClient
- - - - - -
-
-
-
- - - - 0) { ?> - - -
-
-
-
Tickets
-
-
- - - - - - - - - - - - - - - - - - - - - - -
Ticket IDDescriptionStatusClient
-
-
-
- - - - - 0) { ?> - - -
-
-
-
Recurring Tickets
-
-
- - - - - - - - - - - - - - - - - - - - - - -
SubjectFrequencyNextClient
-
-
-
- - - - - 0) { ?> - - -
-
-
-

Credentials

-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
NameDescriptionUsernamePasswordClient
-
-
-
-
- - - - 0) { ?> - - - -
-
-
-
Quotes
-
-
- - - - - - - - - - - - - - - - - - - - - - -
NumberStatusAmountClient
-
-
-
- - - - 0) { ?> - - - -
-
-
-
Invoices
-
-
- - - - - - - - - - - - - - - - - - - - - - -
NumberStatusAmountClient
-
-
-
- - - - 0) { ?> - - - -
-
-
-
Assets
-
-
- - - - - - - - - - - - $contact_name"; - } - $contact_archived_at = escapeHtml($row['contact_archived_at']); - if (empty($contact_archived_at)) { - $contact_archived_display = ""; - } else { - $contact_archived_display = "Archived - "; - } - - ?> - - - - - - - - - - - - -
AssetTypeSerialClientAssigned
- - - - -
-
-
-
- - - - 0) { ?> - - - -
- -
-
-
Ticket Replies
-
-
- -
'; - } - - $ticket_prefix = escapeHtml($row['ticket_prefix']); - $ticket_number = intval($row['ticket_number']); - $ticket_subject = escapeHtml($row['ticket_subject']); - $client_id = intval($row['ticket_client_id']); - $client_name = escapeHtml($row['client_name']); - - // Output the ticket header +
+ -
-
-

- -

-
- Open -
+
+
+ + + + + + + +
+ + +
+
-
- purify($row['ticket_reply']); + +
+ + + +
+
+ +
+ - // Output the ticket reply - ?> -
- -
- + +
+ + + +
+
-
-
-
'; - } - ?> - +
+ $group_shown) { ?> + +
-
-
+
-
-
+ + + - - diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 234c01847..26b2b0381 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -1568,3 +1568,228 @@ html { .client-header-toggle:hover .client-header-chevron { color: var(--bs-body-color); } + +/* Search palette - js/global_search.js + + Opens over the page on / or Ctrl+K. Sits high rather than centred, so the + results grow downwards from the box instead of the box jumping as they + arrive. Everything takes its colour from the Bootstrap variables, so dark + mode needs nothing extra; the highlighted row uses the theme accent. */ +.itflow-search .modal-dialog { + max-width: 44rem; + margin-top: 8vh; +} +.itflow-search .modal-content { + overflow: hidden; + border: 0; + box-shadow: 0 1rem 3rem rgba(0, 0, 0, .35); +} +.itflow-search-bar { + display: flex; + align-items: center; + gap: .75rem; + padding: .75rem 1rem; + border-bottom: 1px solid var(--bs-border-color); +} +.itflow-search-icon, +.itflow-search-spinner { + flex: 0 0 auto; + color: var(--bs-secondary-color); +} +.itflow-search-input { + flex: 1 1 auto; + min-width: 0; + padding: .25rem 0; + border: 0; + outline: 0; + background: transparent; + color: var(--bs-body-color); + font-size: 1.15rem; +} +.itflow-search-input::placeholder { + color: var(--bs-secondary-color); +} +.itflow-search-results { + max-height: min(60vh, 34rem); + overflow-y: auto; + padding: .25rem 0 .5rem; +} +.itflow-search-group { + padding: .65rem 1rem .25rem; + font-size: .7rem; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} +.itflow-search-group .fa-fw { + margin-right: .35rem; +} +.itflow-search-item { + display: flex; + align-items: center; + gap: .75rem; + padding: .45rem 1rem .45rem calc(1rem - 3px); + border-left: 3px solid transparent; + color: var(--bs-body-color); + text-decoration: none; + cursor: pointer; +} +.itflow-search-item:hover, +.itflow-search-item:focus { + color: var(--bs-body-color); + text-decoration: none; +} +.itflow-search-item.active { + border-left-color: var(--itflow-accent, #007bff); + background-color: rgba(var(--bs-emphasis-color-rgb), .06); +} +.itflow-search-item-icon { + flex: 0 0 auto; + color: var(--bs-secondary-color); +} +.itflow-search-item.active .itflow-search-item-icon { + color: var(--itflow-accent, #007bff); +} +.itflow-search-item-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; +} +.itflow-search-item-title, +.itflow-search-item-sub, +.itflow-search-item-context { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.itflow-search-item-sub, +.itflow-search-item-context { + font-size: .8rem; + color: var(--bs-secondary-color); +} +.itflow-search-item-aside { + display: flex; + flex: 0 1 auto; + align-items: center; + gap: .5rem; + max-width: 40%; +} +.itflow-search-ref { + margin-right: .5rem; + font-family: var(--bs-font-monospace); + font-size: .8rem; + color: var(--bs-secondary-color); +} +.itflow-search mark { + padding: 0; + background: transparent; + color: inherit; + font-weight: 700; +} +.itflow-search-more .itflow-search-item-title, +.itflow-search-all .itflow-search-item-title { + color: var(--bs-secondary-color); +} +.itflow-search-more.active .itflow-search-item-title, +.itflow-search-all.active .itflow-search-item-title { + color: var(--bs-body-color); +} +.itflow-search-all { + margin-top: .25rem; + border-top: 1px solid var(--bs-border-color-translucent); + padding-top: .6rem; +} +.itflow-search-empty, +.itflow-search-hint { + padding: 1.25rem 1rem; + text-align: center; + color: var(--bs-secondary-color); +} +.itflow-search-empty .fa-fw { + font-size: 1.5rem; + margin-bottom: .5rem; +} +.itflow-search-hint { + padding: .75rem 1rem; + font-size: .85rem; +} +.itflow-search-footer { + gap: 1rem; + align-items: center; + padding: .45rem 1rem; + border-top: 1px solid var(--bs-border-color); + background-color: var(--bs-tertiary-bg); + font-size: .75rem; + color: var(--bs-secondary-color); +} +.itflow-search kbd, +.navbar-search-kbd { + padding: .05rem .35rem; + border: 1px solid var(--bs-border-color); + border-radius: .25rem; + background-color: var(--bs-body-bg); + color: var(--bs-secondary-color); + font-family: inherit; + font-size: .7rem; + box-shadow: none; +} +@media (max-width: 575.98px) { + .itflow-search .modal-dialog { + margin-top: 0; + } + .itflow-search .modal-content { + display: flex; + flex-direction: column; + } + .itflow-search-results { + flex: 1 1 auto; + max-height: none; + } + /* No room beside the title on a phone: client and status drop under it */ + .itflow-search-item { + flex-wrap: wrap; + row-gap: .15rem; + } + .itflow-search-item-body { + flex: 1 1 0; + } + .itflow-search-item-aside { + order: 3; + max-width: none; + width: 100%; + padding-left: calc(1.25em + .75rem); + } +} + +/* The / hint inside the top bar search box. Pointer events off so a click on + it still lands on the box underneath and opens the palette. */ +.navbar-search .navbar-search-field .navbar-search-kbd { + position: absolute; + top: 50%; + right: .6rem; + transform: translateY(-50%); + pointer-events: none; + background-color: transparent; + border-color: rgba(var(--bs-emphasis-color-rgb), .25); +} +.navbar-search .navbar-search-field .form-control { + cursor: pointer; +} + +/* Full search results - agent/global_search.php. Same bold-not-yellow match + marking as the palette, and the client column capped so a long client name + cannot push the title off the row. */ +.global-search-group mark { + padding: 0; + background: transparent; + color: inherit; + font-weight: 700; +} +.global-search-context { + max-width: 14rem; +} +.global-search-group { + scroll-margin-top: 4rem; +} diff --git a/functions.php b/functions.php index 1a753892a..a4a307efe 100644 --- a/functions.php +++ b/functions.php @@ -27,3 +27,4 @@ require_once __DIR__ . '/functions/ai.php'; require_once __DIR__ . '/functions/export.php'; require_once __DIR__ . '/functions/calendar.php'; require_once __DIR__ . '/functions/backup.php'; +require_once __DIR__ . '/functions/search.php'; diff --git a/functions/search.php b/functions/search.php new file mode 100644 index 000000000..38a7f84e6 --- /dev/null +++ b/functions/search.php @@ -0,0 +1,602 @@ + $raw, + 'eq' => escapeSql($raw), + 'like' => escapeSql(addcslashes($raw, '%_\\')), + 'phone' => (strlen($digits) >= 3 && preg_match('/^[\d\s().+-]+$/', $raw)) ? $digits : null, + ]; +} + +/** + * A record number typed the way people actually type them: "1042", "#1042", + * "TCK-1042", "tck1042". The letters only count when they are this record type's + * own prefix, otherwise "srv01" would match ticket 1. + */ +function globalSearchNumber($raw, $prefix) { + $prefix = rtrim((string) $prefix, '-'); + $prefix_pattern = $prefix === '' ? '' : '|' . preg_quote($prefix, '/') . '-?'; + + if (preg_match('/^(?:#' . $prefix_pattern . ')?\s*0*(\d{1,9})$/i', $raw, $match)) { + return intval($match[1]); + } + + return null; +} + +/** Plain text around the first match in a long HTML field, for the results page. */ +function globalSearchSnippet($html, $raw, $radius = 80) { + $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) $html), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + $position = mb_stripos($text, $raw); + + if ($position === false) { + return mb_substr($text, 0, $radius * 2) . (mb_strlen($text) > $radius * 2 ? '…' : ''); + } + + $start = max(0, $position - $radius); + $snippet = mb_substr($text, $start, mb_strlen($raw) + $radius * 2); + + return ($start > 0 ? '…' : '') . $snippet . ($start + mb_strlen($snippet) < mb_strlen($text) ? '…' : ''); +} + +function globalSearchMoney($amount, $currency_code) { + global $currency_format; + + if (!$currency_format) { + return number_format(floatval($amount), 2); + } + + return numfmt_format_currency($currency_format, floatval($amount), $currency_code ?: 'USD'); +} + +/** Where a client-scoped record lives: the client's own copy of a page, or the global one. */ +function globalSearchClientUrl($page, $client_id, $params = []) { + if (intval($client_id) > 0) { + $params = array_merge(['client_id' => intval($client_id)], $params); + } + + return "/agent/$page" . ($params ? '?' . http_build_query($params) : ''); +} + +/** + * Every searchable record type, in display order. A group whose module the + * user cannot read, or whose feature is switched off, is left out entirely. + */ +function globalSearchGroups($terms, $deep) { + global $config_ticket_prefix, $config_invoice_prefix, $config_quote_prefix; + global $config_module_enable_ticketing, $config_module_enable_accounting; + + $eq = $terms['eq']; + $like = $terms['like']; + $phone = $terms['phone']; + $q = $terms['raw']; + + $ticketing = intval($config_module_enable_ticketing) === 1; + $accounting = intval($config_module_enable_accounting) === 1; + + $groups = []; + + $groups[] = [ + 'key' => 'clients', 'label' => 'Clients', 'icon' => 'users', 'module' => 'module_client', + 'select' => 'client_id, client_name, client_type, client_abbreviation', + 'from' => 'clients', + 'where' => "client_archived_at IS NULL + AND (client_name LIKE '%$like%' OR client_abbreviation LIKE '%$like%' OR client_website LIKE '%$like%')", + 'scope' => 'clients.client_id', + 'order' => "(client_name = '$eq') DESC, (client_name LIKE '$like%') DESC, client_accessed_at DESC, client_name", + 'list_url' => '/agent/clients.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['client_name'], + 'subtitle' => $row['client_type'], + 'url' => '/agent/client_overview.php?client_id=' . intval($row['client_id']), + ]; + }, + ]; + + $ticket_number = globalSearchNumber($q, $config_ticket_prefix); + $ticket_number_match = $ticket_number !== null ? "OR ticket_number = $ticket_number" : ''; + $ticket_deep_match = $deep ? "OR ticket_details LIKE '%$like%'" : ''; + $groups[] = [ + 'key' => 'tickets', 'label' => 'Tickets', 'icon' => 'life-ring', 'module' => 'module_support', 'enabled' => $ticketing, + 'select' => 'ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_closed_at, + client_name, ticket_status_name, ticket_status_color', + 'from' => 'tickets + LEFT JOIN clients ON ticket_client_id = client_id + LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id', + 'where' => "ticket_archived_at IS NULL + AND (ticket_subject LIKE '%$like%' OR CONCAT(ticket_prefix, ticket_number) LIKE '%$like%' $ticket_number_match $ticket_deep_match)", + 'scope' => 'ticket_client_id', + 'order' => ($ticket_number !== null ? "(ticket_number = $ticket_number) DESC, " : '') . "(ticket_closed_at IS NULL) DESC, ticket_id DESC", + 'exact' => $ticket_number !== null ? "ticket_number = $ticket_number" : null, + 'list_url' => '/agent/tickets.php?' . http_build_query(['state' => 'all', 'q' => $q]), + 'item' => function ($row) { + return [ + 'ref' => $row['ticket_prefix'] . $row['ticket_number'], + 'title' => $row['ticket_subject'], + 'context' => $row['client_name'], + 'badge' => $row['ticket_status_name'] ? ['text' => $row['ticket_status_name'], 'hex' => $row['ticket_status_color']] : null, + 'url' => globalSearchClientUrl('ticket.php', $row['ticket_client_id'], ['ticket_id' => intval($row['ticket_id'])]), + ]; + }, + ]; + + // Stored numbers may or may not carry punctuation, so compare digits to digits + $contact_phone_match = $phone + ? "OR REGEXP_REPLACE(contact_phone, '[^0-9]', '') LIKE '%$phone%' OR REGEXP_REPLACE(contact_mobile, '[^0-9]', '') LIKE '%$phone%'" + : ''; + $groups[] = [ + 'key' => 'contacts', 'label' => 'Contacts', 'icon' => 'address-book', 'module' => 'module_client', + 'select' => 'contact_id, contact_name, contact_title, contact_email, contact_client_id, client_name', + 'from' => 'contacts LEFT JOIN clients ON contact_client_id = client_id', + 'where' => "contact_archived_at IS NULL + AND (contact_name LIKE '%$like%' OR contact_email LIKE '%$like%' OR contact_title LIKE '%$like%' $contact_phone_match)", + 'scope' => 'contact_client_id', + 'order' => "(contact_name = '$eq') DESC, (contact_name LIKE '$like%') DESC, contact_name", + 'list_url' => '/agent/contacts.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['contact_name'], + 'subtitle' => implode(' · ', array_filter([$row['contact_title'], $row['contact_email']])), + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('contact.php', $row['contact_client_id'], ['contact_id' => intval($row['contact_id'])]), + ]; + }, + ]; + + // Any interface counts, not just the primary one - the address someone has + // in hand is as likely to be the iDRAC as the OS NIC. + $groups[] = [ + 'key' => 'assets', 'label' => 'Assets', 'icon' => 'desktop', 'module' => 'module_support', + 'select' => 'asset_id, asset_name, asset_type, asset_make, asset_model, asset_client_id, client_name, + primary_interface.interface_ip AS asset_ip', + 'from' => 'assets + LEFT JOIN clients ON asset_client_id = client_id + LEFT JOIN asset_interfaces AS primary_interface + ON primary_interface.interface_asset_id = asset_id AND primary_interface.interface_primary = 1', + 'where' => "asset_archived_at IS NULL + AND (asset_name LIKE '%$like%' OR asset_serial LIKE '%$like%' OR asset_type LIKE '%$like%' + OR asset_make LIKE '%$like%' OR asset_model LIKE '%$like%' OR asset_os LIKE '%$like%' + OR asset_description LIKE '%$like%' + OR EXISTS (SELECT 1 FROM asset_interfaces + WHERE interface_asset_id = asset_id AND interface_archived_at IS NULL + AND (interface_ip LIKE '%$like%' OR interface_nat_ip LIKE '%$like%' OR interface_ipv6 LIKE '%$like%' OR interface_mac LIKE '%$like%')))", + 'scope' => 'asset_client_id', + // An exact address beats every partial one: 192.168.10.1 before 192.168.10.14 + 'order' => "EXISTS (SELECT 1 FROM asset_interfaces WHERE interface_asset_id = asset_id + AND (interface_ip = '$eq' OR interface_nat_ip = '$eq')) DESC, + (asset_name = '$eq') DESC, (asset_name LIKE '$like%') DESC, asset_name", + 'list_url' => '/agent/assets.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['asset_name'], + 'subtitle' => implode(' · ', array_filter([$row['asset_type'], trim($row['asset_make'] . ' ' . $row['asset_model']), $row['asset_ip']])), + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('asset.php', $row['asset_client_id'], ['asset_id' => intval($row['asset_id'])]), + ]; + }, + ]; + + // Name and description only. Usernames and passwords are encrypted at rest, + // and the results page reveals passwords through the audited endpoint, never + // in the markup. + $groups[] = [ + 'key' => 'credentials', 'label' => 'Credentials', 'icon' => 'key', 'module' => 'module_credential', + 'select' => 'credential_id, credential_name, credential_description, credential_client_id, client_name', + 'from' => 'credentials LEFT JOIN clients ON credential_client_id = client_id', + 'where' => "credential_archived_at IS NULL + AND (credential_name LIKE '%$like%' OR credential_description LIKE '%$like%' OR credential_uri LIKE '%$like%')", + 'scope' => 'credential_client_id', + 'order' => "(credential_name = '$eq') DESC, (credential_name LIKE '$like%') DESC, credential_name", + 'list_url' => '/agent/credentials.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'id' => intval($row['credential_id']), + 'title' => $row['credential_name'], + 'subtitle' => $row['credential_description'], + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('credentials.php', $row['credential_client_id'], ['q' => $row['credential_name']]), + ]; + }, + ]; + + $groups[] = [ + 'key' => 'domains', 'label' => 'Domains', 'icon' => 'globe', 'module' => 'module_support', + 'select' => 'domain_id, domain_name, domain_expire, domain_client_id, client_name', + 'from' => 'domains LEFT JOIN clients ON domain_client_id = client_id', + 'where' => "domain_archived_at IS NULL AND domain_name LIKE '%$like%'", + 'scope' => 'domain_client_id', + 'order' => "(domain_name = '$eq') DESC, (domain_name LIKE '$like%') DESC, domain_name", + 'list_url' => '/agent/domains.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['domain_name'], + 'subtitle' => $row['domain_expire'] ? 'Expires ' . $row['domain_expire'] : '', + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('domains.php', $row['domain_client_id'], ['q' => $row['domain_name']]), + ]; + }, + ]; + + $document_deep_match = $deep ? "OR MATCH(document_content_raw) AGAINST ('$eq')" : ''; + $groups[] = [ + 'key' => 'documents', 'label' => 'Documents', 'icon' => 'file-alt', 'module' => 'module_support', + 'select' => 'document_id, document_name, document_description, document_client_id, client_name', + 'from' => 'documents LEFT JOIN clients ON document_client_id = client_id', + 'where' => "document_archived_at IS NULL + AND (document_name LIKE '%$like%' OR document_description LIKE '%$like%' $document_deep_match)", + 'scope' => 'document_client_id', + 'order' => "(document_name LIKE '$like%') DESC, document_updated_at DESC", + 'list_url' => null, + 'item' => function ($row) { + return [ + 'title' => $row['document_name'], + 'subtitle' => $row['document_description'], + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('document.php', $row['document_client_id'], ['document_id' => intval($row['document_id'])]), + ]; + }, + ]; + + $groups[] = [ + 'key' => 'files', 'label' => 'Files', 'icon' => 'paperclip', 'module' => 'module_support', + 'select' => 'file_id, file_name, file_description, file_client_id, file_folder_id, client_name, folder_name', + 'from' => 'files + LEFT JOIN clients ON file_client_id = client_id + LEFT JOIN folders ON file_folder_id = folder_id', + 'where' => "file_archived_at IS NULL AND (file_name LIKE '%$like%' OR file_description LIKE '%$like%')", + 'scope' => 'file_client_id', + 'order' => "(file_name LIKE '$like%') DESC, file_id DESC", + 'list_url' => null, + 'item' => function ($row) { + return [ + 'title' => $row['file_name'], + 'subtitle' => implode(' · ', array_filter([$row['folder_name'], $row['file_description']])), + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('files.php', $row['file_client_id'], ['folder_id' => intval($row['file_folder_id'])]), + ]; + }, + ]; + + $invoice_number = globalSearchNumber($q, $config_invoice_prefix); + $invoice_number_match = $invoice_number !== null ? "OR invoice_number = $invoice_number" : ''; + $groups[] = [ + 'key' => 'invoices', 'label' => 'Invoices', 'icon' => 'file-invoice-dollar', 'module' => 'module_sales', 'enabled' => $accounting, + 'select' => 'invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_status, invoice_amount, + invoice_currency_code, invoice_client_id, client_name', + 'from' => 'invoices LEFT JOIN clients ON invoice_client_id = client_id', + 'where' => "invoice_archived_at IS NULL + AND (CONCAT(invoice_prefix, invoice_number) LIKE '%$like%' OR invoice_scope LIKE '%$like%' $invoice_number_match)", + 'scope' => 'invoice_client_id', + 'order' => ($invoice_number !== null ? "(invoice_number = $invoice_number) DESC, " : '') . 'invoice_number DESC', + 'exact' => $invoice_number !== null ? "invoice_number = $invoice_number" : null, + 'list_url' => '/agent/invoices.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'ref' => $row['invoice_prefix'] . $row['invoice_number'], + 'title' => $row['invoice_scope'] ?: 'Invoice', + 'subtitle' => globalSearchMoney($row['invoice_amount'], $row['invoice_currency_code']), + 'context' => $row['client_name'], + 'badge' => ['text' => $row['invoice_status'], 'variant' => getInvoiceBadgeColor($row['invoice_status'])], + 'url' => globalSearchClientUrl('invoice.php', $row['invoice_client_id'], ['invoice_id' => intval($row['invoice_id'])]), + ]; + }, + ]; + + $quote_number = globalSearchNumber($q, $config_quote_prefix); + $quote_number_match = $quote_number !== null ? "OR quote_number = $quote_number" : ''; + $groups[] = [ + 'key' => 'quotes', 'label' => 'Quotes', 'icon' => 'comment-dollar', 'module' => 'module_sales', 'enabled' => $accounting, + 'select' => 'quote_id, quote_prefix, quote_number, quote_scope, quote_status, quote_amount, + quote_currency_code, quote_client_id, client_name', + 'from' => 'quotes LEFT JOIN clients ON quote_client_id = client_id', + 'where' => "quote_archived_at IS NULL + AND (CONCAT(quote_prefix, quote_number) LIKE '%$like%' OR quote_scope LIKE '%$like%' $quote_number_match)", + 'scope' => 'quote_client_id', + 'order' => ($quote_number !== null ? "(quote_number = $quote_number) DESC, " : '') . 'quote_number DESC', + 'exact' => $quote_number !== null ? "quote_number = $quote_number" : null, + 'list_url' => '/agent/quotes.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + $quote_badge_variants = ['Sent' => 'warning', 'Viewed' => 'primary', 'Accepted' => 'success', 'Declined' => 'danger', 'Invoiced' => 'info']; + return [ + 'ref' => $row['quote_prefix'] . $row['quote_number'], + 'title' => $row['quote_scope'] ?: 'Quote', + 'subtitle' => globalSearchMoney($row['quote_amount'], $row['quote_currency_code']), + 'context' => $row['client_name'], + 'badge' => ['text' => $row['quote_status'], 'variant' => $quote_badge_variants[$row['quote_status']] ?? 'secondary'], + 'url' => globalSearchClientUrl('quote.php', $row['quote_client_id'], ['quote_id' => intval($row['quote_id'])]), + ]; + }, + ]; + + $vendor_phone_match = $phone ? "OR REGEXP_REPLACE(vendor_phone, '[^0-9]', '') LIKE '%$phone%'" : ''; + $groups[] = [ + 'key' => 'vendors', 'label' => 'Vendors', 'icon' => 'building', 'module' => 'module_client', + 'select' => 'vendor_id, vendor_name, vendor_description, vendor_client_id, client_name', + 'from' => 'vendors LEFT JOIN clients ON vendor_client_id = client_id', + 'where' => "vendor_archived_at IS NULL + AND (vendor_name LIKE '%$like%' OR vendor_description LIKE '%$like%' OR vendor_account_number LIKE '%$like%' $vendor_phone_match)", + 'scope' => 'vendor_client_id', + 'order' => "(vendor_name = '$eq') DESC, (vendor_name LIKE '$like%') DESC, vendor_name", + 'list_url' => '/agent/vendors.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['vendor_name'], + 'subtitle' => $row['vendor_description'], + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('vendors.php', $row['vendor_client_id'], ['q' => $row['vendor_name']]), + ]; + }, + ]; + + $recurring_deep_match = $deep ? "OR recurring_ticket_details LIKE '%$like%'" : ''; + $groups[] = [ + 'key' => 'recurring_tickets', 'label' => 'Recurring Tickets', 'icon' => 'redo-alt', 'module' => 'module_support', 'enabled' => $ticketing, + 'select' => 'recurring_ticket_id, recurring_ticket_subject, recurring_ticket_frequency, recurring_ticket_next_run, + recurring_ticket_client_id, client_name', + 'from' => 'recurring_tickets LEFT JOIN clients ON recurring_ticket_client_id = client_id', + 'where' => "(recurring_ticket_subject LIKE '%$like%' $recurring_deep_match)", + 'scope' => 'recurring_ticket_client_id', + 'order' => "(recurring_ticket_subject LIKE '$like%') DESC, recurring_ticket_next_run", + 'list_url' => '/agent/recurring_tickets.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['recurring_ticket_subject'], + 'subtitle' => implode(' · ', array_filter([$row['recurring_ticket_frequency'], $row['recurring_ticket_next_run'] ? 'Next ' . $row['recurring_ticket_next_run'] : ''])), + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('recurring_tickets.php', $row['recurring_ticket_client_id'], ['q' => $row['recurring_ticket_subject']]), + ]; + }, + ]; + + $groups[] = [ + 'key' => 'products', 'label' => 'Products', 'icon' => 'box-open', 'module' => 'module_sales', 'enabled' => $accounting, + 'select' => 'product_id, product_name, product_code, product_price, product_currency_code', + 'from' => 'products', + 'where' => "product_archived_at IS NULL + AND (product_name LIKE '%$like%' OR product_code LIKE '%$like%' OR product_description LIKE '%$like%')", + 'scope' => null, + 'order' => "(product_name = '$eq') DESC, (product_name LIKE '$like%') DESC, product_name", + 'list_url' => '/agent/products.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + return [ + 'title' => $row['product_name'], + 'subtitle' => implode(' · ', array_filter([$row['product_code'], globalSearchMoney($row['product_price'], $row['product_currency_code'])])), + 'url' => '/agent/products.php?' . http_build_query(['q' => $row['product_name']]), + ]; + }, + ]; + + // Ticket conversations: matched inside reply bodies, so results page only. + if ($deep) { + $groups[] = [ + 'key' => 'ticket_replies', 'label' => 'Ticket Conversations', 'icon' => 'comments', 'module' => 'module_support', 'enabled' => $ticketing, + 'select' => 'ticket_reply_id, ticket_reply, ticket_reply_created_at, ticket_id, ticket_prefix, ticket_number, + ticket_subject, ticket_client_id, client_name', + 'from' => 'ticket_replies + JOIN tickets ON ticket_reply_ticket_id = ticket_id + LEFT JOIN clients ON ticket_client_id = client_id', + 'where' => "ticket_reply_archived_at IS NULL AND ticket_archived_at IS NULL AND ticket_reply LIKE '%$like%'", + 'scope' => 'ticket_client_id', + 'order' => 'ticket_reply_id DESC', + 'list_url' => null, + 'item' => function ($row) use ($q) { + return [ + 'ref' => $row['ticket_prefix'] . $row['ticket_number'], + 'title' => $row['ticket_subject'], + 'snippet' => globalSearchSnippet($row['ticket_reply'], $q), + 'context' => $row['client_name'], + 'url' => globalSearchClientUrl('ticket.php', $row['ticket_client_id'], ['ticket_id' => intval($row['ticket_id'])]), + ]; + }, + ]; + } + + return $groups; +} + +/** + * Run the search. + * + * Returns the groups that matched, in display order, each as: + * key, label, icon, items[], more (bool), total (int, only with $with_totals), list_url + * A group with an exact record-number hit (ticket 1042 for "TCK-1042") moves to + * the front, since that is unambiguously what was asked for. + */ +function globalSearch($raw_query, $limit = 5, $deep = false, $with_totals = false) { + global $mysqli; + + $terms = globalSearchTerms($raw_query); + if (mb_strlen($terms['raw']) < 2) { + return []; + } + + $limit = max(1, intval($limit)); + $results = []; + + foreach (globalSearchGroups($terms, $deep) as $group) { + if (array_key_exists('enabled', $group) && !$group['enabled']) { + continue; + } + if (lookupUserPermission($group['module']) < 1) { + continue; + } + + $scope = $group['scope'] ? clientScopeSql($group['scope']) : ''; + $where = "{$group['where']} $scope"; + + $sql = mysqli_query( + $mysqli, + "SELECT {$group['select']} FROM {$group['from']} WHERE $where ORDER BY {$group['order']} LIMIT " . ($limit + 1) + ); + + $items = []; + while ($row = mysqli_fetch_assoc($sql)) { + $items[] = $group['item']($row); + } + + if (!$items) { + continue; + } + + $more = count($items) > $limit; + $items = array_slice($items, 0, $limit); + + $exact = false; + if (!empty($group['exact'])) { + $exact_row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM {$group['from']} WHERE $where AND {$group['exact']} LIMIT 1")); + $exact = (bool) $exact_row; + } + + $total = null; + if ($with_totals) { + $total = $more + ? intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM {$group['from']} WHERE $where"))[0]) + : count($items); + } + + $results[] = [ + 'key' => $group['key'], + 'label' => $group['label'], + 'icon' => $group['icon'], + 'items' => $items, + 'more' => $more, + 'total' => $total, + 'list_url' => $group['list_url'], + 'exact' => $exact, + ]; + } + + // Exact record-number hits first; otherwise keep display order (usort is stable in PHP 8) + usort($results, function ($a, $b) { + return intval($b['exact']) - intval($a['exact']); + }); + + return $results; +} + +/** Recently opened clients, shown in the palette before anything is typed. */ +function globalSearchRecentClients($limit = 6) { + global $mysqli; + + if (lookupUserPermission('module_client') < 1) { + return []; + } + + $limit = max(1, intval($limit)); + + $sql = mysqli_query( + $mysqli, + "SELECT client_id, client_name, client_type FROM clients + WHERE client_archived_at IS NULL AND client_accessed_at IS NOT NULL + " . clientScopeSql('clients.client_id') . " + ORDER BY client_accessed_at DESC LIMIT $limit" + ); + + $items = []; + while ($row = mysqli_fetch_assoc($sql)) { + $items[] = [ + 'title' => $row['client_name'], + 'subtitle' => $row['client_type'], + 'url' => '/agent/client_overview.php?client_id=' . intval($row['client_id']), + ]; + } + + return $items; +} + +/** + * The app's own pages, so "inv" can also take you to Invoices. Same gates as + * the side nav, which is the only other place these links appear. + */ +function globalSearchPages($raw_query) { + global $config_module_enable_ticketing, $config_module_enable_accounting, $session_is_admin; + + $query = mb_strtolower(trim((string) $raw_query)); + if (mb_strlen($query) < 2) { + return []; + } + + $ticketing = intval($config_module_enable_ticketing) === 1; + $accounting = intval($config_module_enable_accounting) === 1; + $support = lookupUserPermission('module_support') >= 1; + $client = lookupUserPermission('module_client') >= 1; + $sales = lookupUserPermission('module_sales') >= 1; + $financial = lookupUserPermission('module_financial') >= 1; + + $pages = [ + ['Dashboard', '/agent/dashboard.php', true], + ['Clients', '/agent/clients.php', $client], + ['All Client Docs', '/agent/contacts.php', $client], + ['Vendors', '/agent/vendors.php', true], + ['Calendar', '/agent/calendar.php', true], + ['Tickets', '/agent/tickets.php', $support && $ticketing], + ['Recurring Tickets', '/agent/recurring_tickets.php', $support && $ticketing], + ['Projects', '/agent/projects.php', $support && $ticketing], + ['Quotes', '/agent/quotes.php', $sales && $accounting], + ['Invoices', '/agent/invoices.php', $sales && $accounting], + ['Recurring Invoices', '/agent/recurring_invoices.php', $sales && $accounting], + ['Products', '/agent/products.php', $sales && $accounting], + ['Income', '/agent/income.php', $financial && $accounting], + ['Expenses', '/agent/expenses.php', $financial && $accounting], + ['Recurring Expenses', '/agent/recurring_expenses.php', $financial && $accounting], + ['Trips', '/agent/trips.php', $financial && $accounting], + ['Accounts', '/agent/accounts.php', $financial && $accounting], + ['Transactions', '/agent/transactions.php', $financial && $accounting], + ['Transfers', '/agent/transfers.php', $financial && $accounting], + ['Reports', '/agent/reports/', lookupUserPermission('module_reporting') >= 1], + ['Administration', '/admin/', (bool) $session_is_admin], + ['My Account', '/agent/user/user_details.php', true], + ]; + + $items = []; + foreach ($pages as [$label, $url, $allowed]) { + if (!$allowed) { + continue; + } + // Match the start of any word: "inv" finds Invoices and Recurring Invoices + foreach (preg_split('/\s+/', mb_strtolower($label)) as $word) { + if (strpos($word, $query) === 0 || strpos(mb_strtolower($label), $query) === 0) { + $items[] = ['title' => $label, 'url' => $url]; + break; + } + } + } + + return array_slice($items, 0, 4); +} diff --git a/includes/footer.php b/includes/footer.php index df0cb7380..0cde3a220 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -81,6 +81,7 @@ + diff --git a/includes/top_nav.php b/includes/top_nav.php index 54a88e008..684836006 100644 --- a/includes/top_nav.php +++ b/includes/top_nav.php @@ -8,16 +8,19 @@ - + diff --git a/js/global_search.js b/js/global_search.js new file mode 100644 index 000000000..2a5016f4c --- /dev/null +++ b/js/global_search.js @@ -0,0 +1,640 @@ +/* + * ITFlow - the search palette. + * + * Press / (or Ctrl+K / Cmd+K) anywhere, or click the search box in the top bar, + * and a search window opens over the page. Results arrive as you type, grouped by + * record type, from agent/ajax.php?global_search - the same search the full + * results page runs (functions/search.php), so neither can show a record the + * other would hide. + * + * Up / Down move through results + * Enter open the highlighted result + * Ctrl/Cmd+Enter open it in a new tab + * Esc close + * + * With nothing typed, it lists the clients you opened most recently. The last + * row is always "Search everything", which goes to the full results page and + * also searches ticket conversations and document text. + * + * Everything the server sends is rendered with textContent, never innerHTML: + * client names, ticket subjects and the like are user-entered. + * + * Loaded once per page from includes/footer.php. It is never re-run by ajax + * modals, but the window flag keeps a second copy from binding twice anyway. + */ +(function () { + 'use strict'; + + if (window.itflowGlobalSearch || typeof bootstrap === 'undefined') { + return; + } + + const ENDPOINT = '/agent/ajax.php'; + const RESULTS_PAGE = '/agent/global_search.php'; + const DEBOUNCE_MS = 150; + const MIN_LENGTH = 2; + const CACHE_SIZE = 50; + const BADGE_VARIANTS = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']; + const MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '') ? '⌘' : 'Ctrl'; + + let modalEl = null; + let modal = null; + let input = null; + let list = null; + let spinner = null; + let announcer = null; + + let options = []; + let active = -1; + let renderedQuery = null; + let lastQuery = ''; + let debounceTimer = null; + let spinnerTimer = null; + let controller = null; + let requestSeq = 0; + let pendingOpen = null; // Enter pressed before the results for the current text arrived + let optionSeq = 0; + let returnFocus = null; // what had focus before the palette opened + let navbarBox = null; + + const cache = new Map(); + + /* ---------- building blocks ---------- */ + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined && text !== null) { + node.textContent = text; + } + return node; + } + + function icon(name, extraClass) { + const i = el('i', 'fas fa-fw fa-' + (/^[a-z0-9-]+$/.test(name || '') ? name : 'circle') + (extraClass ? ' ' + extraClass : '')); + i.setAttribute('aria-hidden', 'true'); + return i; + } + + // Only same-origin paths. The server builds every URL itself, so anything + // else means something is wrong - fall back to a dead link, not a javascript: one. + function safeUrl(url) { + if (typeof url === 'string' && url.charAt(0) === '/' && url.charAt(1) !== '/' && url.charAt(1) !== '\\') { + return url; + } + return '#'; + } + + function resultsPageUrl(query, groupKey) { + return RESULTS_PAGE + '?query=' + encodeURIComponent(query) + (groupKey ? '#search-' + groupKey : ''); + } + + // The matched text in bold, built from text nodes so nothing is ever parsed as HTML + function highlighted(text, query) { + const frag = document.createDocumentFragment(); + const value = text === null || text === undefined ? '' : String(text); + const needle = query.trim().toLowerCase(); + const hay = value.toLowerCase(); + + // toLowerCase can change the length of some characters; then offsets + // would not line up, so show the text plain rather than mark the wrong bit + if (!needle || hay.length !== value.length) { + frag.append(value); + return frag; + } + + let from = 0; + let at; + while ((at = hay.indexOf(needle, from)) !== -1) { + frag.append(value.slice(from, at)); + frag.append(el('mark', null, value.slice(at, at + needle.length))); + from = at + needle.length; + } + frag.append(value.slice(from)); + return frag; + } + + function badge(spec) { + if (!spec || !spec.text) { + return null; + } + const node = el('span', 'badge rounded-pill', spec.text); + if (spec.variant && BADGE_VARIANTS.indexOf(spec.variant) !== -1) { + node.classList.add('text-bg-' + spec.variant); + } else if (spec.hex && /^#[0-9a-f]{3,8}$/i.test(spec.hex)) { + node.style.backgroundColor = spec.hex; + node.classList.add('text-white'); + } else { + node.classList.add('text-bg-secondary'); + } + return node; + } + + function option(url, iconName, query, parts, extraClass) { + const link = el('a', 'itflow-search-item' + (extraClass ? ' ' + extraClass : '')); + link.href = safeUrl(url); + link.id = 'itflow-search-opt-' + (++optionSeq); + link.tabIndex = -1; + link.setAttribute('role', 'option'); + link.setAttribute('aria-selected', 'false'); + + const body = el('span', 'itflow-search-item-body'); + const title = el('span', 'itflow-search-item-title'); + if (parts.ref) { + const ref = el('span', 'itflow-search-ref'); + ref.append(highlighted(parts.ref, query)); + title.append(ref); + } + title.append(highlighted(parts.title, query)); + body.append(title); + + if (parts.subtitle) { + const sub = el('span', 'itflow-search-item-sub'); + sub.append(highlighted(parts.subtitle, query)); + body.append(sub); + } + + link.append(icon(iconName, 'itflow-search-item-icon'), body); + + const status = badge(parts.badge); + if (status || parts.context) { + const aside = el('span', 'itflow-search-item-aside'); + if (status) { + aside.append(status); + } + if (parts.context) { + aside.append(el('span', 'itflow-search-item-context', parts.context)); + } + link.append(aside); + } + + return link; + } + + function heading(label, iconName) { + const node = el('div', 'itflow-search-group'); + node.setAttribute('role', 'presentation'); + node.append(icon(iconName), document.createTextNode(label)); + return node; + } + + /* ---------- the window ---------- */ + + function build() { + if (modalEl) { + return; + } + + // Static skeleton only - no data goes through this string. + // No .fade: a search box should be there the instant the key is pressed. + modalEl = el('div', 'modal itflow-search'); + modalEl.id = 'itflowSearch'; + modalEl.tabIndex = -1; + modalEl.setAttribute('aria-label', 'Search'); + modalEl.innerHTML = + ''; + + modalEl.querySelectorAll('.itflow-search-mod').forEach(function (k) { + k.textContent = MOD_KEY; + }); + + document.body.appendChild(modalEl); + + input = modalEl.querySelector('.itflow-search-input'); + list = modalEl.querySelector('.itflow-search-results'); + spinner = modalEl.querySelector('.itflow-search-spinner'); + announcer = modalEl.querySelector('[aria-live]'); + + modal = new bootstrap.Modal(modalEl); + + try { + lastQuery = sessionStorage.getItem('itflowSearchLast') || ''; + } catch (e) { + lastQuery = ''; + } + + // Bootstrap's focus trap parks focus on the dialog once it is shown; + // the cursor belongs in the box + modalEl.addEventListener('shown.bs.modal', function () { + input.focus(); + }); + + modalEl.addEventListener('hidden.bs.modal', function () { + // Chrome can leave focus on the now-hidden box for a moment, and the + // next / would count as typing in it. Hand focus back to where it was - + // except the top bar box, which would just open the palette again. + input.blur(); + if (returnFocus && returnFocus !== navbarBox && document.contains(returnFocus) && typeof returnFocus.focus === 'function') { + returnFocus.focus({ preventScroll: true }); + } + returnFocus = null; + + lastQuery = input.value; + // Kept for the tab, so the next page reopens on the same search + try { + sessionStorage.setItem('itflowSearchLast', lastQuery); + } catch (e) { + // storage blocked - the search just starts empty next page + } + clearTimeout(debounceTimer); + if (controller) { + controller.abort(); + } + pendingOpen = null; + setBusy(false); + }); + + input.addEventListener('input', function () { + pendingOpen = null; + schedule(input.value, false); + }); + + input.addEventListener('keydown', onInputKeydown); + + // Follow the pointer, but only when it actually moves - a list scrolling + // under a still mouse must not steal the keyboard's place + list.addEventListener('mousemove', function (e) { + const item = e.target.closest('.itflow-search-item'); + if (item) { + const index = options.indexOf(item); + if (index !== -1 && index !== active) { + setActive(index, false); + } + } + }); + } + + function isOpen() { + return !!modalEl && modalEl.classList.contains('show'); + } + + function open(text) { + build(); + const value = typeof text === 'string' ? text : lastQuery; + input.value = value; + if (!isOpen()) { + returnFocus = document.activeElement; + modal.show(); + } + input.focus(); + input.select(); + if (value.trim() !== renderedQuery) { + schedule(value, true); + } + } + + function go(url, newTab) { + if (url === '#') { + return; + } + if (newTab) { + window.open(url, '_blank', 'noopener'); + } else { + window.location.href = url; + } + } + + /* ---------- keyboard ---------- */ + + function setActive(index, scroll) { + if (active !== -1 && options[active]) { + options[active].classList.remove('active'); + options[active].setAttribute('aria-selected', 'false'); + } + + active = options.length ? (index + options.length) % options.length : -1; + + if (active === -1) { + input.removeAttribute('aria-activedescendant'); + return; + } + + const current = options[active]; + current.classList.add('active'); + current.setAttribute('aria-selected', 'true'); + input.setAttribute('aria-activedescendant', current.id); + if (scroll) { + current.scrollIntoView({ block: 'nearest' }); + } + } + + function onInputKeydown(e) { + if (e.isComposing) { + return; + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setActive(active + (e.key === 'ArrowDown' ? 1 : -1), true); + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + const newTab = e.ctrlKey || e.metaKey; + + // Typed "TCK-1042" and hit Enter straight away: the results for that + // text are not back yet, so open the top one as soon as they are + if (input.value.trim() !== renderedQuery) { + pendingOpen = { newTab: newTab }; + schedule(input.value, true); + return; + } + + if (active !== -1 && options[active]) { + go(options[active].getAttribute('href'), newTab); + } + } + } + + function isTyping(target) { + if (!target || !(target instanceof Element) || (modalEl && modalEl.contains(target))) { + return false; + } + if (target.isContentEditable || target.closest('.tox, [contenteditable="true"], [contenteditable=""]')) { + return true; + } + if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { + return true; + } + if (target.tagName === 'INPUT') { + return ['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].indexOf(target.type) === -1; + } + return false; + } + + function anotherModalOpen() { + return !!document.querySelector('.modal.show:not(#itflowSearch)'); + } + + document.addEventListener('keydown', function (e) { + if (e.defaultPrevented) { + return; + } + + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) { + if (isOpen()) { + e.preventDefault(); + input.focus(); + input.select(); + } else if (!anotherModalOpen()) { + e.preventDefault(); + open(); + } + return; + } + + if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isOpen() && !anotherModalOpen() && !isTyping(e.target)) { + e.preventDefault(); + open(); + } + }); + + /* ---------- fetching ---------- */ + + function setBusy(busy) { + clearTimeout(spinnerTimer); + if (!spinner) { + return; + } + if (busy) { + // Only for a slow answer; a spinner that flashes for 20ms is noise + spinnerTimer = setTimeout(function () { + spinner.hidden = false; + }, 150); + } else { + spinner.hidden = true; + } + } + + function schedule(text, immediate) { + clearTimeout(debounceTimer); + if (immediate) { + run(text); + } else { + debounceTimer = setTimeout(function () { + run(text); + }, DEBOUNCE_MS); + } + } + + function run(text) { + const query = text.trim(); + + // One character matches half the database; show the starting view until there is a second + const key = query.length < MIN_LENGTH ? '' : query; + + if (cache.has(key)) { + requestSeq++; + if (controller) { + controller.abort(); + } + setBusy(false); + render(query, key, cache.get(key)); + return; + } + + if (controller) { + controller.abort(); + } + controller = new AbortController(); + const mine = ++requestSeq; + setBusy(true); + + fetch(ENDPOINT + '?global_search=1&q=' + encodeURIComponent(key), { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + signal: controller.signal + }) + .then(function (res) { + if (!res.ok) { + throw new Error('HTTP ' + res.status); + } + // An expired session is redirected to the login page, which is HTML + if ((res.headers.get('Content-Type') || '').indexOf('application/json') === -1) { + throw new Error('session'); + } + return res.json(); + }) + .then(function (data) { + if (mine !== requestSeq) { + return; + } + remember(key, data); + render(query, key, data); + }) + .catch(function (err) { + if (err.name === 'AbortError' || mine !== requestSeq) { + return; + } + renderMessage(err.message === 'session' + ? 'Your session has ended. Reload the page to sign in again.' + : 'Search is not responding. Try again in a moment.', 'exclamation-triangle', query); + }) + .finally(function () { + if (mine === requestSeq) { + setBusy(false); + } + }); + } + + function remember(key, data) { + if (cache.size >= CACHE_SIZE) { + cache.delete(cache.keys().next().value); + } + cache.set(key, data); + } + + /* ---------- rendering ---------- */ + + function finish(query, count) { + options = Array.prototype.slice.call(list.querySelectorAll('.itflow-search-item')); + active = -1; + setActive(0, false); + list.scrollTop = 0; + renderedQuery = query; + + if (announcer) { + announcer.textContent = query ? (count ? count + ' results' : 'No results') : ''; + } + + if (pendingOpen && options.length) { + const newTab = pendingOpen.newTab; + pendingOpen = null; + go(options[0].getAttribute('href'), newTab); + } + pendingOpen = null; + } + + function searchEverything(query, hasResults) { + return option(resultsPageUrl(query), 'search', '', { + title: 'Search everything for "' + query + '"', + subtitle: hasResults + ? 'See every match, including ticket conversations and document text' + : 'Also searches ticket conversations and document text' + }, 'itflow-search-all'); + } + + function renderMessage(text, iconName, query) { + list.replaceChildren(); + const message = el('div', 'itflow-search-empty'); + message.append(icon(iconName), el('div', null, text)); + list.append(message); + if (query && query.length >= MIN_LENGTH) { + list.append(searchEverything(query, false)); + } + finish(query, 0); + } + + function render(query, key, data) { + list.replaceChildren(); + let count = 0; + + // Nothing typed yet (or a single character): where you have been lately + if (key === '') { + const recent = (data && data.recent) || []; + if (recent.length) { + list.append(heading('Recent clients', 'history')); + recent.forEach(function (item) { + list.append(option(item.url, 'user-friends', '', item)); + }); + } + const hint = el('div', 'itflow-search-hint', + 'Type a name, email, phone number, IP, serial, or a ticket, invoice or quote number.'); + list.append(hint); + finish('', recent.length); + renderedQuery = query; + return; + } + + const pages = (data && data.pages) || []; + if (pages.length) { + list.append(heading('Go to', 'compass')); + pages.forEach(function (page) { + list.append(option(page.url, 'arrow-right', key, page)); + }); + } + + ((data && data.groups) || []).forEach(function (group) { + list.append(heading(group.label, group.icon)); + group.items.forEach(function (item) { + list.append(option(item.url, group.icon, key, item)); + count++; + }); + if (group.more) { + list.append(option(resultsPageUrl(key, group.key), 'ellipsis-h', '', { + title: 'See all matching ' + group.label.toLowerCase() + }, 'itflow-search-more')); + } + }); + + if (!count && !pages.length) { + const message = el('div', 'itflow-search-empty'); + message.append(icon('search'), el('div', null, 'No quick matches for "' + key + '".')); + list.append(message); + } + + list.append(searchEverything(key, count > 0)); + finish(key, count); + } + + /* ---------- the top bar box ---------- */ + + // The box in the top bar becomes the way in: focusing it opens the window + // with whatever it holds (the current query, on the results page). The form + // still submits to the results page if this script never loads. + function bindNavbar() { + const box = document.getElementById('navbar-search-input'); + if (!box) { + return; + } + navbarBox = box; + const form = box.form; + + box.addEventListener('mousedown', function (e) { + e.preventDefault(); + open(box.value || undefined); + }); + box.addEventListener('focus', function () { + box.blur(); + open(box.value || undefined); + }); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + open(box.value || undefined); + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindNavbar); + } else { + bindNavbar(); + } + + window.itflowGlobalSearch = { open: open }; +})();