From a3c29ebd0ebbb1b60cf3d1075cf599adf48e19e9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 10:26:26 +0100 Subject: Dynamically update URL with search query. --- pydis_site/static/js/resources/resources.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 508849e1..c4d01f9d 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -51,6 +51,13 @@ function noFilters() { function deserializeURLParams() { let searchParams = new window.URLSearchParams(window.location.search); + // Add the search query to the search bar. + if (searchParams.has("search")) { + let searchQuery = searchParams.get("search"); + console.log("Adding query to search box! Query is ${searchQuery}"); + $("#resource-search input").val(searchQuery); + } + // Work through the parameters and add them to the filter object $.each(Object.keys(activeFilters), function(_, filterType) { let paramFilterContent = searchParams.get(filterType); @@ -62,11 +69,13 @@ function deserializeURLParams() { // Update the corresponding filter UI, so it reflects the internal state. let filterAdded = false; $(paramFilterArray).each(function(_, filter) { - // Make sure the filter is valid before we do anything. + // Catch special cases. if (String(filter) === "rickroll" && filterType === "type") { window.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; } else if (String(filter) === "sneakers" && filterType === "topics") { window.location.href = "https://www.youtube.com/watch?v=NNZscmNE9QI"; + + // If the filter is valid, mirror it to the UI. } else if (validFilters.hasOwnProperty(filterType) && validFilters[filterType].includes(String(filter))) { let checkbox = $(`.filter-checkbox[data-filter-name='${filterType}'][data-filter-item='${filter}']`); let filterTag = $(`.filter-box-tag[data-filter-name='${filterType}'][data-filter-item='${filter}']`); @@ -93,8 +102,10 @@ function deserializeURLParams() { /* Update the URL with new parameters */ function updateURL() { - // If there's nothing in the filters, we don't want anything in the URL. - if (noFilters()) { + let searchQuery = $("#resource-search input").val(); + + // If there's no active filtering parameters, we can return early. + if (noFilters() && searchQuery.length === 0) { window.history.replaceState(null, document.title, './'); return; } @@ -107,6 +118,11 @@ function updateURL() { } }); + // Add the search query, if necessary. + if (searchQuery.length > 0) { + searchParams.set("search", searchQuery); + } + // Now update the URL window.history.replaceState(null, document.title, `?${searchParams.toString()}`); } -- cgit v1.2.3 From 364060841c39c12973e6edd1854d251d5d8011c9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 10:26:51 +0100 Subject: Trigger a UI update when typing into search. --- pydis_site/static/js/resources/resources.js | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index c4d01f9d..348571e6 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -246,6 +246,11 @@ document.addEventListener("DOMContentLoaded", function () { setTimeout(() => { categoryHeaders.removeClass("no-transition"); }, 10); } + // When you type into the search bar, trigger an UI update. + $("#resource-search input").on("input", function() { + updateUI(); + }); + // If you click on the div surrounding the filter checkbox, it clicks the corresponding checkbox. $('.filter-panel').on("click",function(event) { let hitsCheckbox = Boolean(String(event.target)); -- cgit v1.2.3 From 9db38103343a662248aae9d4a134b93a2113b72f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 12:05:52 +0100 Subject: Implement fuzzy search. This implements the core logics for filtering by search. It uses fuzzysort to match the search query to the name of the resource. This name is set in the yaml for each resource. --- pydis_site/static/js/resources/resources.js | 39 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 348571e6..79c33cc7 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -8,6 +8,12 @@ var activeFilters = { difficulty: [] }; +// Options for fuzzysort +const fuzzysortOptions = { + allowTypo: true, // Allow our users to make typos + threshold: -10000, // The threshold for the fuzziness. Adjust for precision. +}; + /* Add a filter, and update the UI */ function addFilter(filterName, filterItem) { var filterIndex = activeFilters[filterName].indexOf(filterItem); @@ -54,7 +60,6 @@ function deserializeURLParams() { // Add the search query to the search bar. if (searchParams.has("search")) { let searchQuery = searchParams.get("search"); - console.log("Adding query to search box! Query is ${searchQuery}"); $("#resource-search input").val(searchQuery); } @@ -127,6 +132,17 @@ function updateURL() { window.history.replaceState(null, document.title, `?${searchParams.toString()}`); } +/* Apply search terms */ +function filterBySearch(resourceItems) { + let searchQuery = $("#resource-search input").val(); + + resourceItems.filter(function() { + // Run a fuzzy search over the item. Does the search query match? + let name = $(this).attr("name"); + return Boolean(fuzzysort.single(searchQuery, name, fuzzysortOptions)); + }).show(); +} + /* Update the resources to match 'active_filters' */ function updateUI() { let resources = $('.resource-box'); @@ -134,19 +150,28 @@ function updateUI() { let resourceTags = $('.resource-tag'); let noTagsSelected = $(".no-tags-selected.tag"); let closeFiltersButton = $(".close-filters-button"); + let searchQuery = $("#resource-search input").val(); // Update the URL to match the new filters. updateURL(); // If there's nothing in the filters, we can return early. if (noFilters()) { - resources.show(); + // If we have a searchQuery, we need to run all resources through a search. + if (searchQuery.length > 0) { + resources.hide(); + filterBySearch(resources); + } else { + resources.show(); + } + filterTags.hide(); noTagsSelected.show(); closeFiltersButton.hide(); resourceTags.removeClass("active"); $(`.filter-checkbox:checked`).prop("checked", false); $(".no-resources-found").hide(); + return; } else { // Hide everything @@ -176,7 +201,7 @@ function updateUI() { // Otherwise, hide everything and then filter the resources to decide what to show. let hasMatches = false; resources.hide(); - resources.filter(function() { + let filteredResources = resources.filter(function() { let validation = { topics: false, type: false, @@ -208,8 +233,14 @@ function updateUI() { } else { return false; } - }).show(); + }); + // Run the items we've found through the search filter, if necessary. + if (searchQuery.length > 0) { + filterBySearch(filteredResources); + } else { + filteredResources.show(); + } // If there are no matches, show the no matches message if (!hasMatches) { -- cgit v1.2.3 From 6addd63c2f5ea34382d3561073945a9ae6f2ea12 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 13:18:48 +0100 Subject: Add a filter tag when searching. --- pydis_site/static/css/resources/resources.css | 8 ++++++++ pydis_site/static/js/resources/resources.js | 17 +++++++++++++++-- pydis_site/templates/resources/resources.html | 10 ++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/css/resources/resources.css b/pydis_site/static/css/resources/resources.css index f46803e2..6f97422b 100644 --- a/pydis_site/static/css/resources/resources.css +++ b/pydis_site/static/css/resources/resources.css @@ -91,6 +91,14 @@ display: block; display: flex !important; } +/* By default, we hide the search tag. We'll add it only when there's a search happening. */ +.tag.search-query { + display: none; +} +.tag.search-query .inner { + padding: 0; +} + /* Disable clicking on the checkbox itself. */ /* Instead, we want to let the anchor tag handle clicks. */ .filter-checkbox { diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 79c33cc7..713a7402 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -136,10 +136,19 @@ function updateURL() { function filterBySearch(resourceItems) { let searchQuery = $("#resource-search input").val(); + /* Show and update the tag if there's a search query */ + if (searchQuery) { + let tag = $(".tag.search-query"); + let tagText = $(".tag.search-query span"); + tagText.text(`Search: ${searchQuery}`); + tag.show(); + } + resourceItems.filter(function() { // Run a fuzzy search over the item. Does the search query match? let name = $(this).attr("name"); - return Boolean(fuzzysort.single(searchQuery, name, fuzzysortOptions)); + let result = fuzzysort.single(searchQuery, name, fuzzysortOptions); + return Boolean(result) && result.score > fuzzysortOptions.threshold; }).show(); } @@ -151,6 +160,7 @@ function updateUI() { let noTagsSelected = $(".no-tags-selected.tag"); let closeFiltersButton = $(".close-filters-button"); let searchQuery = $("#resource-search input").val(); + let searchTag = $(".tag.search-query"); // Update the URL to match the new filters. updateURL(); @@ -160,13 +170,15 @@ function updateUI() { // If we have a searchQuery, we need to run all resources through a search. if (searchQuery.length > 0) { resources.hide(); + noTagsSelected.hide(); filterBySearch(resources); } else { resources.show(); + noTagsSelected.show(); + $(".tag.search-query").hide(); } filterTags.hide(); - noTagsSelected.show(); closeFiltersButton.hide(); resourceTags.removeClass("active"); $(`.filter-checkbox:checked`).prop("checked", false); @@ -240,6 +252,7 @@ function updateUI() { filterBySearch(filteredResources); } else { filteredResources.show(); + searchTag.hide(); } // If there are no matches, show the no matches message diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html index 1f16e5ec..19750612 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -44,11 +44,17 @@
{# A filter tag for when there are no filters active #} - - + + No filters selected + {# A filter tag for search queries #} + + + Search: ... + + {% for filter_name, filter_data in filters.items %} {% for filter_item in filter_data.filters %} {% if filter_name == "Difficulty" %} -- cgit v1.2.3 From 1ea2cbb5d7793b3e6c023a65572357630f33342c Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 14:07:18 +0100 Subject: Search the resource description too. --- pydis_site/static/js/resources/resources.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 713a7402..bbf07c94 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -10,8 +10,9 @@ var activeFilters = { // Options for fuzzysort const fuzzysortOptions = { - allowTypo: true, // Allow our users to make typos - threshold: -10000, // The threshold for the fuzziness. Adjust for precision. + allowTypo: true, // Allow our users to make typos + titleThreshold: -10000, // The threshold for the fuzziness on title matches. Closer to 0 is stricter. + descriptionThreshold: -500, // The threshold for the fuzziness on description matches. }; /* Add a filter, and update the UI */ @@ -145,10 +146,18 @@ function filterBySearch(resourceItems) { } resourceItems.filter(function() { - // Run a fuzzy search over the item. Does the search query match? - let name = $(this).attr("name"); - let result = fuzzysort.single(searchQuery, name, fuzzysortOptions); - return Boolean(result) && result.score > fuzzysortOptions.threshold; + // Get the resource title and description + let title = $(this).attr("name"); + let description = $(this).find("p").text(); + + // Run a fuzzy search. Does the title or description match the query? + let titleMatch = fuzzysort.single(searchQuery, title, fuzzysortOptions); + titleMatch = Boolean(titleMatch) && titleMatch.score > fuzzysortOptions.titleThreshold; + + let descriptionMatch = fuzzysort.single(searchQuery, description, fuzzysortOptions); + descriptionMatch = Boolean(descriptionMatch) && descriptionMatch.score > fuzzysortOptions.descriptionThreshold; + + return titleMatch || descriptionMatch; }).show(); } -- cgit v1.2.3 From b4fc433e22ec7192ac5dbe43aa6f483574ac1726 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 14:12:53 +0100 Subject: Fix duck pond not showing for search results of 0. --- pydis_site/static/js/resources/resources.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index bbf07c94..b74c28ec 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -220,7 +220,6 @@ function updateUI() { } // Otherwise, hide everything and then filter the resources to decide what to show. - let hasMatches = false; resources.hide(); let filteredResources = resources.filter(function() { let validation = { @@ -249,7 +248,6 @@ function updateUI() { // If validation passes, show the resource. if (Object.values(validation).every(Boolean)) { - hasMatches = true; return true; } else { return false; @@ -265,7 +263,8 @@ function updateUI() { } // If there are no matches, show the no matches message - if (!hasMatches) { + let visibleResources = Boolean($(".resource-box:visible").length); + if (!visibleResources) { $(".no-resources-found").show(); } else { $(".no-resources-found").hide(); -- cgit v1.2.3 From 0b8da4d9f15f5f596512aa71365cd7e959846a87 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 14:17:25 +0100 Subject: Make the remove all tags affect search, too. --- pydis_site/static/js/resources/resources.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index b74c28ec..113ce502 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -32,6 +32,7 @@ function removeAllFilters() { "payment-tiers": [], difficulty: [] }; + $("#resource-search input").val(""); updateUI(); } @@ -62,6 +63,7 @@ function deserializeURLParams() { if (searchParams.has("search")) { let searchQuery = searchParams.get("search"); $("#resource-search input").val(searchQuery); + $(".close-filters-button").show(); } // Work through the parameters and add them to the filter object @@ -143,6 +145,7 @@ function filterBySearch(resourceItems) { let tagText = $(".tag.search-query span"); tagText.text(`Search: ${searchQuery}`); tag.show(); + $(".close-filters-button").show(); } resourceItems.filter(function() { @@ -184,11 +187,11 @@ function updateUI() { } else { resources.show(); noTagsSelected.show(); + closeFiltersButton.hide(); $(".tag.search-query").hide(); } filterTags.hide(); - closeFiltersButton.hide(); resourceTags.removeClass("active"); $(`.filter-checkbox:checked`).prop("checked", false); $(".no-resources-found").hide(); -- cgit v1.2.3 From f5f231078ab6f6de6d0161372d00c57f26acba9e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 16:50:28 +0100 Subject: Add fuzzysort as a local dependency. --- pydis_site/static/js/fuzzysort/LICENSE.md | 21 + pydis_site/static/js/fuzzysort/fuzzysort.js | 636 ++++++++++++++++++++++++++ pydis_site/templates/resources/resources.html | 2 +- 3 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 pydis_site/static/js/fuzzysort/LICENSE.md create mode 100644 pydis_site/static/js/fuzzysort/fuzzysort.js (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/fuzzysort/LICENSE.md b/pydis_site/static/js/fuzzysort/LICENSE.md new file mode 100644 index 00000000..736006f1 --- /dev/null +++ b/pydis_site/static/js/fuzzysort/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/pydis_site/static/js/fuzzysort/fuzzysort.js b/pydis_site/static/js/fuzzysort/fuzzysort.js new file mode 100644 index 00000000..88cfe570 --- /dev/null +++ b/pydis_site/static/js/fuzzysort/fuzzysort.js @@ -0,0 +1,636 @@ +/* + fuzzysort.js https://github.com/farzher/fuzzysort + SublimeText-like Fuzzy Search + + fuzzysort.single('fs', 'Fuzzy Search') // {score: -16} + fuzzysort.single('test', 'test') // {score: 0} + fuzzysort.single('doesnt exist', 'target') // null + + fuzzysort.go('mr', [{file:'Monitor.cpp'}, {file:'MeshRenderer.cpp'}], {key:'file'}) + // [{score:-18, obj:{file:'MeshRenderer.cpp'}}, {score:-6009, obj:{file:'Monitor.cpp'}}] + + fuzzysort.go('mr', ['Monitor.cpp', 'MeshRenderer.cpp']) + // [{score: -18, target: "MeshRenderer.cpp"}, {score: -6009, target: "Monitor.cpp"}] + + fuzzysort.highlight(fuzzysort.single('fs', 'Fuzzy Search'), '', '') + // Fuzzy Search +*/ + +// UMD (Universal Module Definition) for fuzzysort +;(function(root, UMD) { + if(typeof define === 'function' && define.amd) define([], UMD) + else if(typeof module === 'object' && module.exports) module.exports = UMD() + else root.fuzzysort = UMD() +})(this, function UMD() { function fuzzysortNew(instanceOptions) { + + var fuzzysort = { + + single: function(search, target, options) { ;if(search=='farzher')return{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6]} + if(!search) return null + if(!isObj(search)) search = fuzzysort.getPreparedSearch(search) + + if(!target) return null + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + return algorithm(search, target, search[0]) + }, + + go: function(search, targets, options) { ;if(search=='farzher')return[{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}] + if(!search) return noResults + search = fuzzysort.prepareSearch(search) + var searchLowerCode = search[0] + + var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991 + var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991 + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + // This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys] + + // options.keys + if(options && options.keys) { + var scoreFn = options.scoreFn || defaultScoreFn + var keys = options.keys + var keysLen = keys.length + for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i] + var objResults = new Array(keysLen) + for (var keyI = keysLen - 1; keyI >= 0; --keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { objResults[keyI] = null; continue } + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + objResults[keyI] = algorithm(search, target, searchLowerCode) + } + objResults.obj = obj // before scoreFn so scoreFn can use it + var score = scoreFn(objResults) + if(score === null) continue + if(score < threshold) continue + objResults.score = score + if(resultsLen < limit) { q.add(objResults); ++resultsLen } + else { + ++limitedCount + if(score > q.peek().score) q.replaceTop(objResults) + } + } + + // options.key + } else if(options && options.key) { + var key = options.key + for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + + // have to clone result so duplicate targets from different obj can each reference the correct obj + result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden + + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + + // no keys + } else { + for(var i = targetsLen - 1; i >= 0; --i) { var target = targets[i] + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results + }, + + goAsync: function(search, targets, options) { + var canceled = false + var p = new Promise(function(resolve, reject) { ;if(search=='farzher')return resolve([{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}]) + if(!search) return resolve(noResults) + search = fuzzysort.prepareSearch(search) + var searchLowerCode = search[0] + + var q = fastpriorityqueue() + var iCurrent = targets.length - 1 + var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991 + var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991 + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + var resultsLen = 0; var limitedCount = 0 + function step() { + if(canceled) return reject('canceled') + + var startMs = Date.now() + + // This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys] + + // options.keys + if(options && options.keys) { + var scoreFn = options.scoreFn || defaultScoreFn + var keys = options.keys + var keysLen = keys.length + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var obj = targets[iCurrent] + var objResults = new Array(keysLen) + for (var keyI = keysLen - 1; keyI >= 0; --keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { objResults[keyI] = null; continue } + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + objResults[keyI] = algorithm(search, target, searchLowerCode) + } + objResults.obj = obj // before scoreFn so scoreFn can use it + var score = scoreFn(objResults) + if(score === null) continue + if(score < threshold) continue + objResults.score = score + if(resultsLen < limit) { q.add(objResults); ++resultsLen } + else { + ++limitedCount + if(score > q.peek().score) q.replaceTop(objResults) + } + } + + // options.key + } else if(options && options.key) { + var key = options.key + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var obj = targets[iCurrent] + var target = getValue(obj, key) + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + + // have to clone result so duplicate targets from different obj can each reference the correct obj + result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden + + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + + // no keys + } else { + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var target = targets[iCurrent] + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + } + + if(resultsLen === 0) return resolve(noResults) + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + resolve(results) + } + + isNode?setImmediate(step):step() //setTimeout here is too slow + }) + p.cancel = function() { canceled = true } + return p + }, + + highlight: function(result, hOpen, hClose) { + if(typeof hOpen == 'function') return fuzzysort.highlightCallback(result, hOpen) + if(result === null) return null + if(hOpen === undefined) hOpen = '' + if(hClose === undefined) hClose = '' + var highlighted = '' + var matchesIndex = 0 + var opened = false + var target = result.target + var targetLen = target.length + var matchesBest = result.indexes + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(matchesBest[matchesIndex] === i) { + ++matchesIndex + if(!opened) { opened = true + highlighted += hOpen + } + + if(matchesIndex === matchesBest.length) { + highlighted += char + hClose + target.substr(i+1) + break + } + } else { + if(opened) { opened = false + highlighted += hClose + } + } + highlighted += char + } + + return highlighted + }, + highlightCallback: function(result, cb) { + if(result === null) return null + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var result = [] + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + result.push(highlighted); highlighted = '' + } + + if(indexesI === indexes.length) { + highlighted += char + result.push(cb(highlighted, matchI++)); highlighted = '' + result.push(target.substr(i+1)) + break + } + } else { + if(opened) { opened = false + result.push(cb(highlighted, matchI++)); highlighted = '' + } + } + highlighted += char + } + return result + }, + + prepare: function(target) { + if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden + return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:null, score:null, indexes:null, obj:null} // hidden + }, + prepareSlow: function(target) { + if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden + return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:fuzzysort.prepareNextBeginningIndexes(target), score:null, indexes:null, obj:null} // hidden + }, + prepareSearch: function(search) { + if(!search) search = '' + return fuzzysort.prepareLowerCodes(search) + }, + + + + // Below this point is only internal code + // Below this point is only internal code + // Below this point is only internal code + // Below this point is only internal code + + + + getPrepared: function(target) { + if(target.length > 999) return fuzzysort.prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = fuzzysort.prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared + }, + getPreparedSearch: function(search) { + if(search.length > 999) return fuzzysort.prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = fuzzysort.prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared + }, + + algorithm: function(searchLowerCodes, prepared, searchLowerCode) { + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var typoSimpleI = 0 + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[typoSimpleI===0?searchI : (typoSimpleI===searchI?searchI+1 : (typoSimpleI===searchI-1?searchI-1 : searchI))] + } + + ++targetI; if(targetI >= targetLen) { // Failed to find searchI + // Check for typo or exit + // we go as far as possible before trying to transpose + // then we transpose backwards until we reach the beginning + for(;;) { + if(searchI <= 1) return null // not allowed to transpose first char + if(typoSimpleI === 0) { // we haven't tried to transpose yet + --searchI + var searchLowerCodeNew = searchLowerCodes[searchI] + if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char + typoSimpleI = searchI + } else { + if(typoSimpleI === 1) return null // reached the end of the line for transposing + --typoSimpleI + searchI = typoSimpleI + searchLowerCode = searchLowerCodes[searchI + 1] + var searchLowerCodeNew = searchLowerCodes[searchI] + if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char + } + matchesSimpleLen = searchI + targetI = matchesSimple[matchesSimpleLen - 1] + 1 + break + } + } + } + + var searchI = 0 + var typoStrictI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target) + var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) { // We failed to push chars forward for a better match + // transpose, starting from the beginning + ++typoStrictI; if(typoStrictI > searchLen-2) break + if(searchLowerCodes[typoStrictI] === searchLowerCodes[typoStrictI+1]) continue // doesn't make sense to transpose a repeat char + targetI = firstPossibleI + continue + } + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[typoStrictI===0?searchI : (typoStrictI===searchI?searchI+1 : (typoStrictI===searchI-1?searchI-1 : searchI))] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + { // tally up the score & keep track of matches for highlighting later + if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen } + else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen } + var score = 0 + var lastTargetI = -1 + for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i] + // score only goes down if they're not consecutive + if(lastTargetI !== targetI - 1) score -= targetI + lastTargetI = targetI + } + if(!successStrict) { + score *= 1000 + if(typoSimpleI !== 0) score += -20/*typoPenalty*/ + } else { + if(typoStrictI !== 0) score += -20/*typoPenalty*/ + } + score -= targetLen - searchLen + prepared.score = score + prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i] + + return prepared + } + }, + + algorithmNoTypo: function(searchLowerCodes, prepared, searchLowerCode) { + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return null // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target) + var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + { // tally up the score & keep track of matches for highlighting later + if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen } + else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen } + var score = 0 + var lastTargetI = -1 + for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i] + // score only goes down if they're not consecutive + if(lastTargetI !== targetI - 1) score -= targetI + lastTargetI = targetI + } + if(!successStrict) score *= 1000 + score -= targetLen - searchLen + prepared.score = score + prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i] + + return prepared + } + }, + + prepareLowerCodes: function(str) { + var strLen = str.length + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var lower = str.toLowerCase() + for(var i = 0; i < strLen; ++i) lowerCodes[i] = lower.charCodeAt(i) + return lowerCodes + }, + prepareBeginningIndexes: function(target) { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes + }, + prepareNextBeginningIndexes: function(target) { + var targetLen = target.length + var beginningIndexes = fuzzysort.prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes + }, + + cleanup: cleanup, + new: fuzzysortNew, + } + return fuzzysort +} // fuzzysortNew + +// This stuff is outside fuzzysortNew, because it's shared with instances of fuzzysort.new() +var isNode = typeof require !== 'undefined' && typeof window === 'undefined' +var MyMap = Map||function(){var s=Object.create(null);this.get=function(k){return s[k]};this.set=function(k,val){s[k]=val;return this};this.clear=function(){s=Object.create(null)}} +var preparedCache = new MyMap() +var preparedSearchCache = new MyMap() +var noResults = []; noResults.total = 0 +var matchesSimple = []; var matchesStrict = [] +function cleanup() { preparedCache.clear(); preparedSearchCache.clear(); matchesSimple = []; matchesStrict = [] } +function defaultScoreFn(a) { + var max = -9007199254740991 + for (var i = a.length - 1; i >= 0; --i) { + var result = a[i]; if(result === null) continue + var score = result.score + if(score > max) max = score + } + if(max === -9007199254740991) return null + return max +} + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +function getValue(obj, prop) { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +function isObj(x) { return typeof x === 'object' } // faster as a function + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=function(){var r=[],o=0,e={};function n(){for(var e=0,n=r[e],c=1;c>1]=r[e],c=1+(e<<1)}for(var a=e-1>>1;e>0&&n.score>1)r[e]=r[a];r[e]=n}return e.add=function(e){var n=o;r[o++]=e;for(var c=n-1>>1;n>0&&e.score>1)r[n]=r[c];r[n]=e},e.poll=function(){if(0!==o){var e=r[0];return r[0]=r[--o],n(),e}},e.peek=function(e){if(0!==o)return r[0]},e.replaceTop=function(o){r[0]=o,n()},e}; +var q = fastpriorityqueue() // reuse this, except for async, it needs to make its own + +return fuzzysortNew() +}) // UMD + +// TODO: (performance) wasm version!? +// TODO: (performance) threads? +// TODO: (performance) avoid cache misses +// TODO: (performance) preparedCache is a memory leak +// TODO: (like sublime) backslash === forwardslash +// TODO: (like sublime) spaces: "a b" should do 2 searches 1 for a and 1 for b +// TODO: (scoring) garbage in targets that allows most searches to strict match need a penality +// TODO: (performance) idk if allowTypo is optimized \ No newline at end of file diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html index 201caf85..101f9965 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -16,7 +16,7 @@ - + {% endblock %} {% block content %} -- cgit v1.2.3 From 33e8f0cb477547bbef7564a83353d2379caa9e0e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 16:53:32 +0100 Subject: Best practice: Switch name to data-resource-name. --- pydis_site/static/js/fuzzysort/LICENSE.md | 2 +- pydis_site/static/js/fuzzysort/fuzzysort.js | 2 +- pydis_site/static/js/resources/resources.js | 2 +- pydis_site/templates/resources/resource_box.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/fuzzysort/LICENSE.md b/pydis_site/static/js/fuzzysort/LICENSE.md index 736006f1..a3b9d9d7 100644 --- a/pydis_site/static/js/fuzzysort/LICENSE.md +++ b/pydis_site/static/js/fuzzysort/LICENSE.md @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/pydis_site/static/js/fuzzysort/fuzzysort.js b/pydis_site/static/js/fuzzysort/fuzzysort.js index 88cfe570..ba01ae63 100644 --- a/pydis_site/static/js/fuzzysort/fuzzysort.js +++ b/pydis_site/static/js/fuzzysort/fuzzysort.js @@ -633,4 +633,4 @@ return fuzzysortNew() // TODO: (like sublime) backslash === forwardslash // TODO: (like sublime) spaces: "a b" should do 2 searches 1 for a and 1 for b // TODO: (scoring) garbage in targets that allows most searches to strict match need a penality -// TODO: (performance) idk if allowTypo is optimized \ No newline at end of file +// TODO: (performance) idk if allowTypo is optimized diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 113ce502..6e4a09ba 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -150,7 +150,7 @@ function filterBySearch(resourceItems) { resourceItems.filter(function() { // Get the resource title and description - let title = $(this).attr("name"); + let title = $(this).attr("data-resource-name"); let description = $(this).find("p").text(); // Run a fuzzy search. Does the title or description match the query? diff --git a/pydis_site/templates/resources/resource_box.html b/pydis_site/templates/resources/resource_box.html index 5189bb3e..5ca46296 100644 --- a/pydis_site/templates/resources/resource_box.html +++ b/pydis_site/templates/resources/resource_box.html @@ -2,7 +2,7 @@ {% load to_kebabcase %} {% load get_category_icon %} -
+
{% if 'title_url' in resource %} {% include "resources/resource_box_header.html" %} -- cgit v1.2.3 From fbea3ccd4e7bf90c6efa67f4a5b98148eb4af23f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 20:03:46 +0100 Subject: Edge cases: Show duckies when no visible resources --- pydis_site/static/js/resources/resources.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) (limited to 'pydis_site/static/js') diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js index 6e4a09ba..d6cc8128 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -108,6 +108,17 @@ function deserializeURLParams() { }); } +/* Show or hide the duckies, depending on whether or not there are any resources visible. */ +function updateDuckies() { + let visibleResources = Boolean($(".resource-box:visible").length); + if (!visibleResources) { + $(".no-resources-found").show(); + } else { + $(".no-resources-found").hide(); + } +} + + /* Update the URL with new parameters */ function updateURL() { let searchQuery = $("#resource-search input").val(); @@ -194,7 +205,7 @@ function updateUI() { filterTags.hide(); resourceTags.removeClass("active"); $(`.filter-checkbox:checked`).prop("checked", false); - $(".no-resources-found").hide(); + updateDuckies(); return; } else { @@ -265,13 +276,8 @@ function updateUI() { searchTag.hide(); } - // If there are no matches, show the no matches message - let visibleResources = Boolean($(".resource-box:visible").length); - if (!visibleResources) { - $(".no-resources-found").show(); - } else { - $(".no-resources-found").hide(); - } + // Gotta update those duckies! + updateDuckies(); } // Executed when the page has finished loading. -- cgit v1.2.3 From f2ad3eed8ef8872713666f69ec783f59006d3d81 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 22:53:50 +0200 Subject: Improve Tag Cropping Move the tag cropping logic to the frontend, which makes it easier to crop without crossing boundaries such as link or code block boundaries. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/utils.py | 6 +----- pydis_site/static/js/content/listing.js | 36 +++++++++++++++++++++++++++++++ pydis_site/templates/content/listing.html | 4 +++- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 pydis_site/static/js/content/listing.js (limited to 'pydis_site/static/js') diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 76437593..cc08f81f 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -131,14 +131,10 @@ def get_category_pages(path: Path) -> dict[str, dict]: tags = {} for tag in get_tags(): content = frontmatter.parse(tag.body)[1] - if len(content) > 100: - # Trim the preview to a maximum of 100 visible characters - # This causes some markdown to break, but we ignore that - content = content[:100] + "..." tags[tag.name] = { "title": tag.name, - "description": markdown.markdown(content), + "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), "icon": "fas fa-tag" } diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js new file mode 100644 index 00000000..3502cb2a --- /dev/null +++ b/pydis_site/static/js/content/listing.js @@ -0,0 +1,36 @@ +/** + * Trim a tag listing to only show a few lines of content. + */ +function trimTag() { + const containers = document.getElementsByClassName("tag-container"); + for (const container of containers) { + // Remove every element after the first two paragraphs + while (container.children.length > 2) { + container.removeChild(container.lastChild); + } + + // Trim down the elements if they are too long + const containerLength = container.textContent.length; + if (containerLength > 300) { + if (containerLength - container.firstChild.textContent.length > 300) { + // The first element alone takes up more than 300 characters + container.removeChild(container.lastChild); + } + + let last = container.lastChild.lastChild; + while (container.textContent.length > 300 && container.lastChild.childNodes.length > 0) { + last = container.lastChild.lastChild; + last.remove(); + } + + if (container.textContent.length > 300 && (last instanceof HTMLElement && last.tagName !== "CODE")) { + // Add back the final element (up to a period if possible) + const stop = last.textContent.indexOf("."); + last.textContent = last.textContent.slice(0, stop > 0 ? stop + 1: null); + container.lastChild.appendChild(last); + } + } + } +} + +trimTag(); diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index eeb6b5e2..098f4237 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -1,5 +1,6 @@ {# Base navigation screen for resources #} {% extends 'content/base.html' %} +{% load static %} {% block page_content %} {# Nested Categories #} @@ -26,10 +27,11 @@ {{ data.title }} {% if "tags" in location %} -

{{ data.description | safe }}

+
{{ data.description | safe }}
{% else %}

{{ data.description }}

{% endif %}
{% endfor %} + {% endblock %} -- cgit v1.2.3 From 45cdb27a82297ede18d7bd908213dde54fef06a9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 14 Aug 2022 05:34:27 +0200 Subject: Add Tag Group Support Adds support for tag groups in content. This involves some modification to the routing, and templating. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/urls.py | 12 ++- pydis_site/apps/content/utils.py | 101 ++++++++++++++++++++----- pydis_site/apps/content/views/page_category.py | 5 +- pydis_site/apps/content/views/tags.py | 79 +++++++++++++++---- pydis_site/static/css/content/color.css | 7 ++ pydis_site/static/css/content/tag.css | 8 -- pydis_site/static/js/content/listing.js | 5 ++ pydis_site/templates/content/listing.html | 17 ++++- pydis_site/templates/content/tag.html | 5 +- 9 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 pydis_site/static/css/content/color.css (limited to 'pydis_site/static/js') diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index b4ffc07d..03c0015a 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -39,15 +39,21 @@ def get_all_pages() -> DISTILL_RETURN: def get_all_tags() -> DISTILL_RETURN: - """Return all tag names in the repository in static builds.""" + """Return all tag names and groups in static builds.""" + groups = {None} for tag in utils.get_tags_static(): - yield {"name": tag.name} + groups.add(tag.group) + yield {"location": (f"{tag.group}/" if tag.group else "") + tag.name} + + groups.remove(None) + for group in groups: + yield {"location": group} urlpatterns = [ distill_path("", views.PageOrCategoryView.as_view(), name='pages'), distill_path( - "tags//", + "tags//", views.TagView.as_view(), name="tag", distill_func=get_all_tags diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index cc08f81f..da6a024d 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -2,6 +2,7 @@ import datetime import functools import tarfile import tempfile +import typing from io import BytesIO from pathlib import Path @@ -16,7 +17,6 @@ from markdown.extensions.toc import TocExtension from pydis_site import settings from .models import Tag -TAG_URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" TAG_CACHE_TTL = datetime.timedelta(hours=1) @@ -44,9 +44,13 @@ def get_tags_static() -> list[Tag]: """ Fetch tag information in static builds. + This also includes some fake tags to preview the tag groups feature. This will return a cached value, so it should only be used for static builds. """ - return fetch_tags() + tags = fetch_tags() + for tag in tags[3:5]: + tag.group = "very-cool-group" + return tags def fetch_tags() -> list[Tag]: @@ -79,10 +83,15 @@ def fetch_tags() -> list[Tag]: repo.extractall(folder, included) for tag_file in Path(folder).rglob("*.md"): + group = None + if tag_file.parent.name != "tags": + # Tags in sub-folders are considered part of a group + group = tag_file.parent.name + tags.append(Tag( name=tag_file.name.removesuffix(".md"), + group=group, body=tag_file.read_text(encoding="utf-8"), - url=f"{TAG_URL_BASE}/{tag_file.name}" )) return tags @@ -114,31 +123,85 @@ def get_tags() -> list[Tag]: return Tag.objects.all() -def get_tag(name: str) -> Tag: - """Return a tag by name.""" - tags = get_tags() - for tag in tags: - if tag.name == name: +def get_tag(path: str) -> typing.Union[Tag, list[Tag]]: + """ + Return a tag based on the search location. + + The tag name and group must match. If only one argument is provided in the path, + it's assumed to either be a group name, or a no-group tag name. + + If it's a group name, a list of tags which belong to it is returned. + """ + path = path.split("/") + if len(path) == 2: + group, name = path[0], path[1] + else: + name = path[0] + group = None + + matches = [] + for tag in get_tags(): + if tag.name == name and tag.group == group: return tag + elif tag.group == name and group is None: + matches.append(tag) + + if matches: + return matches raise Tag.DoesNotExist() -def get_category_pages(path: Path) -> dict[str, dict]: - """Get all page names and their metadata at a category path.""" - # Special handling for tags - if path == Path(__file__).parent / "resources/tags": - tags = {} - for tag in get_tags(): - content = frontmatter.parse(tag.body)[1] +def get_tag_category( + tags: typing.Optional[list[Tag]] = None, *, collapse_groups: bool +) -> dict[str, dict]: + """ + Generate context data for `tags`, or all tags if None. + + If `tags` is None, `get_tag` is used to populate the data. + If `collapse_groups` is True, tags with parent groups are not included in the list, + and instead the parent itself is included as a single entry with it's sub-tags + in the description. + """ + if not tags: + tags = get_tags() + + data = [] + groups = {} - tags[tag.name] = { + # Create all the metadata for the tags + for tag in tags: + if tag.group is None or not collapse_groups: + content = frontmatter.parse(tag.body)[1] + data.append({ "title": tag.name, "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), - "icon": "fas fa-tag" - } + "icon": "fas fa-tag", + }) + else: + if tag.group not in groups: + groups[tag.group] = { + "title": tag.group, + "description": [tag.name], + "icon": "fas fa-tags", + } + else: + groups[tag.group]["description"].append(tag.name) - return {name: tags[name] for name in sorted(tags)} + # Flatten group description into a single string + for group in groups.values(): + group["description"] = "Contains the following tags: " + ", ".join(group["description"]) + data.append(group) + + # Sort the tags, and return them in the proper format + return {tag["title"]: tag for tag in sorted(data, key=lambda tag: tag["title"].lower())} + + +def get_category_pages(path: Path) -> dict[str, dict]: + """Get all page names and their metadata at a category path.""" + # Special handling for tags + if path == Path(__file__).parent / "resources/tags": + return get_tag_category(collapse_groups=True) pages = {} diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 01ce8402..062c2bc1 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -5,7 +5,7 @@ from django.conf import settings from django.http import Http404, HttpRequest, HttpResponse from django.views.generic import TemplateView -from pydis_site.apps.content import utils +from pydis_site.apps.content import models, utils class PageOrCategoryView(TemplateView): @@ -91,4 +91,7 @@ class PageOrCategoryView(TemplateView): "page_title": category["title"], "page_description": category["description"], "icon": category.get("icon"), + "app_name": "content:page_category", + "is_tag_listing": "/resources/tags" in path.as_posix(), + "tag_url": models.Tag.URL_BASE, } diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py index 5295537d..a8df65db 100644 --- a/pydis_site/apps/content/views/tags.py +++ b/pydis_site/apps/content/views/tags.py @@ -1,4 +1,5 @@ import re +import typing import frontmatter import markdown @@ -16,23 +17,65 @@ COMMAND_REGEX = re.compile(r"`*!tags? (?P[\w\d-]+)`*") class TagView(TemplateView): """Handles tag pages.""" - template_name = "content/tag.html" + tag: typing.Union[Tag, list[Tag]] + is_group: bool + + def setup(self, *args, **kwargs) -> None: + """Look for a tag, and configure the view.""" + super().setup(*args, **kwargs) - def get_context_data(self, **kwargs) -> dict: - """Get the relevant context for this tag page.""" try: - tag = utils.get_tag(kwargs.get("name")) + self.tag = utils.get_tag(kwargs.get("location")) + self.is_group = isinstance(self.tag, list) except Tag.DoesNotExist: raise Http404 + def get_template_names(self) -> list[str]: + """Either return the tag page template, or the listing.""" + if self.is_group: + template_name = "content/listing.html" + else: + template_name = "content/tag.html" + + return [template_name] + + def get_context_data(self, **kwargs) -> dict: + """Get the relevant context for this tag page or group.""" context = super().get_context_data(**kwargs) - context["page_title"] = tag.name + context["breadcrumb_items"] = [{ + "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], + "path": location, + } for location in (".", "tags")] + + if self.is_group: + self._set_group_context(context, self.tag) + else: + self._set_tag_context(context, self.tag) + + return context + + @staticmethod + def _set_tag_context(context: dict[str, any], tag: Tag) -> None: + """Update the context with the information for a tag page.""" + context.update({ + "page_title": tag.name, + "tag": tag, + }) + + if tag.group: + # Add group names to the breadcrumbs + context["breadcrumb_items"].append({ + "name": tag.group, + "path": f"tags/{tag.group}", + }) + + # Clean up tag body body = frontmatter.parse(tag.body) content = body[1] # Check for tags which can be hyperlinked def sub(match: re.Match) -> str: - link = reverse("content:tag", kwargs={"name": match.group("name")}) + link = reverse("content:tag", kwargs={"location": match.group("name")}) return f"[{match.group()}]({link})" content = COMMAND_REGEX.sub(sub, content) @@ -42,14 +85,20 @@ class TagView(TemplateView): if image := embed.get("image"): content = f"![{embed['title']}]({image['url']})\n\n" + content + # Insert the content + context["page"] = markdown.markdown(content, extensions=["pymdownx.superfences"]) + + @staticmethod + def _set_group_context(context: dict[str, any], tags: list[Tag]) -> None: + """Update the context with the information for a group of tags.""" + group = tags[0].group context.update({ - "page": markdown.markdown(content, extensions=["pymdownx.superfences"]), - "tag": tag, + "categories": {}, + "pages": utils.get_tag_category(tags, collapse_groups=False), + "page_title": group, + "icon": "fab fa-tags", + "is_tag_listing": True, + "app_name": "content:tag", + "path": f"{group}/", + "tag_url": f"{tags[0].URL_BASE}/{group}" }) - - context["breadcrumb_items"] = [{ - "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], - "path": str(location) - } for location in [".", "tags"]] - - return context diff --git a/pydis_site/static/css/content/color.css b/pydis_site/static/css/content/color.css new file mode 100644 index 00000000..f4801c28 --- /dev/null +++ b/pydis_site/static/css/content/color.css @@ -0,0 +1,7 @@ +.content .fa-github { + color: black; +} + +.content .fa-github:hover { + color: #7289DA; +} diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css index a3db046c..ec45bfc7 100644 --- a/pydis_site/static/css/content/tag.css +++ b/pydis_site/static/css/content/tag.css @@ -1,11 +1,3 @@ -h1.title a { - color: black; -} - -h1.title a:hover { - color: #7289DA; -} - .content a * { /* This is the original color, but propagated down the chain */ /* which allows for elements inside links, such as codeblocks */ diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js index 3502cb2a..4b722632 100644 --- a/pydis_site/static/js/content/listing.js +++ b/pydis_site/static/js/content/listing.js @@ -4,6 +4,11 @@ function trimTag() { const containers = document.getElementsByClassName("tag-container"); for (const container of containers) { + if (container.textContent.startsWith("Contains the following tags:")) { + // Tag group, no need to trim + continue; + } + // Remove every element after the first two paragraphs while (container.children.length > 2) { container.removeChild(container.lastChild); diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index 098f4237..934b95f6 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -2,6 +2,19 @@ {% extends 'content/base.html' %} {% load static %} +{# Show a GitHub button on tag pages #} +{% block title_element %} +{% if is_tag_listing %} + +
+
{{ block.super }}
+
+ +
+
+{% endif %} +{% endblock %} + {% block page_content %} {# Nested Categories #} {% for category, data in categories.items %} @@ -23,10 +36,10 @@ - + {{ data.title }} - {% if "tags" in location %} + {% if is_tag_listing %}
{{ data.description | safe }}
{% else %}

{{ data.description }}

diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html index 264f63d0..9bd65744 100644 --- a/pydis_site/templates/content/tag.html +++ b/pydis_site/templates/content/tag.html @@ -3,6 +3,7 @@ {% block head %} {{ block.super }} + {{ tag.name }} {% endblock %} @@ -15,7 +16,3 @@
{% endblock %} - -{% block page_content %} - {{ block.super }} -{% endblock %} -- cgit v1.2.3