Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 017EB1123 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 05 Sep 2025 18:49:13 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.217.49; helo=mail-vs1-f49.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=b56h5Y1U Received: from mail-vs1-f49.google.com (mail-vs1-f49.google.com [209.85.217.49]) by mail.netlandish.com (Postfix) with ESMTP id 10D791D817E for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 05 Sep 2025 18:50:13 +0000 (UTC) Received: by mail-vs1-f49.google.com with SMTP id ada2fe7eead31-5330fdb9723so1339684137.0 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 05 Sep 2025 11:50:13 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1757098213; x=1757703013; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=Vb3NAzWoaJyEWy6oxYRXOLqg/W1Dn4feLTggEJf/PgI=; b=b56h5Y1UUzlNuk7ha2r4fCAwkxmSkAHFfGsOl9FcCkvhzDUbblLnQ3HZor4S5mVK8x /9ceSE/uqtm3RbJU/ndsAME5+CPaQbBiWTH8GdmAaWhV5D4m2HlQP6E1pKDbq9YeC2Nz /rE1jZJQsGUJHMWrcmyytYDwMjC0/JXZShbAs= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1757098213; x=1757703013; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=Vb3NAzWoaJyEWy6oxYRXOLqg/W1Dn4feLTggEJf/PgI=; b=rSmFHT1n0gioFLAqnb/FysEjMyo3nkmvcu07EcLdGF7/Dfkwd5k+c0BgLVfwKUKgbc axeQYJuNsECbaupHNZexJhC1+lZBZGM3Mk+RYH+IdH4uwvUCBiA5lfw/x1+6IiBQFm+Z 8qF0ULysNO6u22PC3IDK6Psnq38vTImMt4en1x6ePgTIYSrReP/U7BzFF7bygLhMF2Zu nPVFJpmouWZUBJj8tzfPSPSFCRWDt63BcE0QheHDybQxz8FTKPnhmU9s009n7QnYlaa1 ULkQ8XlZfRPgsVWBS87RE0JUDRnoU5WROgkBcRaps8OqvFbA5gI+WL7BTThAGluUvr5M eViQ== X-Gm-Message-State: AOJu0YwZh0LdQJYKzwLfaDhr4bGyJ5hOU3hKi5sKBWIymvcplHrxxBrt lOsQv9xifUVOeh/e7swvCtZ1s1RCUo1aIxeR/PDMly4kZ28+uGBfPt2Dv/PoECVtYQQP0AXCr8j Fof+eHz8= X-Gm-Gg: ASbGnctkdwRNLhcUQqBrLYUU/+UdbCPUjesGKIqK81QujT9dH/OtMr+MZthywxgI/jB 3MscsvtRCKVp3LgD67prjncriw3dRUSm44M+bUvL0tEWE3a/V8td2pxv6hcJkBbYlnfWw2XCdYz C8Kh1v0AUQZAOr/ktVcXnaYCDOJqOnNNQY2XenW27ZUSHJ9ZWKvSfVV84N4T7K/gETrc2kDUSrV /hNI+d1YQcO8aXyyc86CxwTdAwbO55GpSqwYd9qaogXySGpf8gfznl4yHJdp9HrwxQ1uurc0D4W PQ9VMzIVwHAgLCXhUZ/RghppwSj83E2UgB7Do/VPwYNqiu00GI/OPGmlKNWcilZVy3ruM7NiJqZ Kg5gNTu6IFnAdJ8wley+PkNUzFGc/8Amg X-Google-Smtp-Source: AGHT+IFYHcWtA8ZRk2eS0caN0wr5o9gDXLvUCfBIoTZYwd1PJIDJnVV245MHWBrZXQHzA22AhlgQQQ== X-Received: by 2002:a05:6102:6f07:b0:533:ff66:696d with SMTP id ada2fe7eead31-533ff6675dfmr4665319137.23.1757098212900; Fri, 05 Sep 2025 11:50:12 -0700 (PDT) Received: from localhost ([2803:2d60:1118:5ee:2e6f:b4b:d414:d618]) by smtp.gmail.com with UTF8SMTPSA id ada2fe7eead31-52af1915741sm8183248137.10.2025.09.05.11.50.12 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Fri, 05 Sep 2025 11:50:12 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez Subject: [PATCH links] Stop abusing tag autocomplete Date: Fri, 5 Sep 2025 12:50:08 -0600 Message-ID: <20250905185010.30057-1-peter@netlandish.com> X-Mailer: git-send-email 2.49.1 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit --- core/routes.go | 11 ++- static/js/advancedsearch.js | 133 +++++++++++++++++++++++------------- static/js/autocomplete.js | 85 ++++++++++++++++------- 3 files changed, 155 insertions(+), 74 deletions(-) diff --git a/core/routes.go b/core/routes.go index 5e8fd53..39a13f6 100644 --- a/core/routes.go +++ b/core/routes.go @@ -1,6 +1,7 @@ package core import ( + "context" "database/sql" "errors" "fmt" @@ -30,6 +31,7 @@ import ( "netlandish.com/x/gobwebs/database" "netlandish.com/x/gobwebs/messages" "netlandish.com/x/gobwebs/server" + "netlandish.com/x/gobwebs/timezone" "netlandish.com/x/gobwebs/validate" ) @@ -3447,7 +3449,14 @@ func (s *Service) TagAutocomplete(c echo.Context) error { } else { return c.JSON(http.StatusOK, "{}") } - tags, err = models.GetTags(c.Request().Context(), opts) + + // Create sql.DB context with timezone to avoid DBI connection hoarding + dbi := database.DBIForContext(c.Request().Context()) + tz := timezone.ForContext(c.Request().Context()) + newCtx := database.Context(context.Background(), dbi.GetDB()) + newCtx = timezone.Context(newCtx, tz) + + tags, err = models.GetTags(newCtx, opts) if err != nil { return err } diff --git a/static/js/advancedsearch.js b/static/js/advancedsearch.js index f800ce2..21ff3b6 100644 --- a/static/js/advancedsearch.js +++ b/static/js/advancedsearch.js @@ -8,6 +8,10 @@ var tagInputDiv = document.getElementById("advanced-search-div") // Track highlighted index for each autocomplete dropdown var highlightedIndexes = new WeakMap(); +// Track debounce timers and active requests for each tag selector +var debounceTimers = new WeakMap(); +var activeRequests = new WeakMap(); + // Track if we should allow form submission var allowFormSubmit = true; @@ -92,6 +96,19 @@ function autocomplete(event) { return; } + // Cancel previous request for this tag selector if active + var activeRequest = activeRequests.get(tagSelector); + if (activeRequest) { + activeRequest.abort(); + activeRequests.delete(tagSelector); + } + + // Clear existing debounce timer for this tag selector + var debounceTimer = debounceTimers.get(tagSelector); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + tags = this.value.split(",") tag = tags[tags.length -1].trim() @@ -100,56 +117,78 @@ function autocomplete(event) { autocompleteTags = this.nextElementSibling; } if (tag !== "") { - // Store the query we're searching for - var searchQuery = tag; - - fetch(url + "&q=" + tag) - .then(function (response) { - if (!response.ok) { - console.log("Network response was not ok"); - } - return response.json(); - }) - .then(function (data) { - // Check if the current input still matches what we searched for - var currentTags = tagSelector.value.split(","); - var currentTag = currentTags[currentTags.length - 1].trim(); - - if (autocompleteTags !== undefined && currentTag === searchQuery) { - showSuggestions(autocompleteTags, data); + // Debounce the request for 300ms + var newTimer = setTimeout(function() { + // Store the query we're searching for + var searchQuery = tag; + + // Create AbortController for request cancellation + var controller = new AbortController(); + activeRequests.set(tagSelector, controller); + + fetch(url + "&q=" + tag, { signal: controller.signal }) + .then(function (response) { + if (!response.ok) { + console.log("Network response was not ok"); + } + return response.json(); + }) + .then(function (data) { + // Clear active request on completion + if (activeRequests.get(tagSelector) === controller) { + activeRequests.delete(tagSelector); + } - // Set up click handler for this dropdown - autocompleteTags.onclick = function(event) { - if (event.target.classList.contains("tag-suggested")) { - var tags = tagSelector.value.split(","); - var newTag = event.target.textContent + ", "; - if (tags.length > 1) { - newTag = " " + newTag; + // Check if the current input still matches what we searched for + var currentTags = tagSelector.value.split(","); + var currentTag = currentTags[currentTags.length - 1].trim(); + + if (autocompleteTags !== undefined && currentTag === searchQuery) { + showSuggestions(autocompleteTags, data); + + // Set up click handler for this dropdown + autocompleteTags.onclick = function(event) { + if (event.target.classList.contains("tag-suggested")) { + var tags = tagSelector.value.split(","); + var newTag = event.target.textContent + ", "; + if (tags.length > 1) { + newTag = " " + newTag; + } + tags[tags.length - 1] = newTag; + tagSelector.value = tags.join(","); + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + tagSelector.focus(); } - tags[tags.length - 1] = newTag; - tagSelector.value = tags.join(","); - autocompleteTags.classList.add("d-none"); - highlightedIndexes.set(autocompleteTags, -1); - tagSelector.focus(); - } - }; + }; + + // Set up mouseover handler for this dropdown + autocompleteTags.onmouseover = function(event) { + if (event.target.classList.contains("tag-suggested")) { + var index = parseInt(event.target.getAttribute("data-index")); + highlightSuggestion(autocompleteTags, index); + } + }; + } else if (autocompleteTags !== undefined) { + // Input has changed, hide the dropdown + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + } + }) + .catch(function (error) { + // Clear active request on error (unless aborted) + if (activeRequests.get(tagSelector) === controller) { + activeRequests.delete(tagSelector); + } - // Set up mouseover handler for this dropdown - autocompleteTags.onmouseover = function(event) { - if (event.target.classList.contains("tag-suggested")) { - var index = parseInt(event.target.getAttribute("data-index")); - highlightSuggestion(autocompleteTags, index); - } - }; - } else if (autocompleteTags !== undefined) { - // Input has changed, hide the dropdown - autocompleteTags.classList.add("d-none"); - highlightedIndexes.set(autocompleteTags, -1); - } - }) - .catch(function (error) { - console.log("Fetch error:", error); - }); + // Don't log aborted requests as errors + if (error.name !== 'AbortError') { + console.log("Fetch error:", error); + } + }); + }, 300); + + debounceTimers.set(tagSelector, newTimer); } else { if (autocompleteTags !== undefined) { autocompleteTags.classList.add("d-none"); diff --git a/static/js/autocomplete.js b/static/js/autocomplete.js index 5593795..30b079a 100644 --- a/static/js/autocomplete.js +++ b/static/js/autocomplete.js @@ -3,6 +3,8 @@ var tagSelector = document.getElementById("tag-selector"); var autocompleteTags = document.getElementById("autocomplete-tags"); var highlightedIndex = -1; var suggestions = []; +var debounceTimer; +var activeRequest; // Function to highlight a suggestion function highlightSuggestion(index) { @@ -111,36 +113,67 @@ tagSelector.addEventListener("keyup", function(event) { return; } + // Cancel previous request if active + if (activeRequest) { + activeRequest.abort(); + activeRequest = null; + } + + // Clear existing debounce timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + tags = this.value.split(","); tag = tags[tags.length - 1].trim(); if (tag !== "") { - // Store the query we're searching for - var searchQuery = tag; - - fetch(url + "&q=" + tag) - .then(function (response) { - if (!response.ok) { - console.log("Network response was not ok"); - } - return response.json(); - }) - .then(function (data) { - // Check if the current input still matches what we searched for - var currentTags = tagSelector.value.split(","); - var currentTag = currentTags[currentTags.length - 1].trim(); - - if (currentTag === searchQuery) { - showSuggestions(data); - } else { - // Input has changed, hide the dropdown - autocompleteTags.classList.add("d-none"); - highlightedIndex = -1; - } - }) - .catch(function (error) { - console.log("Fetch error:", error); - }); + // Debounce the request for 300ms + debounceTimer = setTimeout(function() { + // Store the query we're searching for + var searchQuery = tag; + + // Create AbortController for request cancellation + var controller = new AbortController(); + activeRequest = controller; + + fetch(url + "&q=" + tag, { signal: controller.signal }) + .then(function (response) { + if (!response.ok) { + console.log("Network response was not ok"); + } + return response.json(); + }) + .then(function (data) { + // Clear active request on completion + if (activeRequest === controller) { + activeRequest = null; + } + + // Check if the current input still matches what we searched for + var currentTags = tagSelector.value.split(","); + var currentTag = currentTags[currentTags.length - 1].trim(); + + if (currentTag === searchQuery) { + showSuggestions(data); + } else { + // Input has changed, hide the dropdown + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; + } + }) + .catch(function (error) { + // Clear active request on error (unless aborted) + if (activeRequest === controller) { + activeRequest = null; + } + + // Don't log aborted requests as errors + if (error.name !== 'AbortError') { + console.log("Fetch error:", error); + } + }); + }, 300); } else { autocompleteTags.classList.add("d-none"); highlightedIndex = -1; -- 2.49.1