added dynamic loading of favicons
This commit is contained in:
parent
255acb360f
commit
81fb811111
4 changed files with 355 additions and 35 deletions
45
favicon.go
45
favicon.go
|
@ -50,6 +50,30 @@ var (
|
||||||
iconLinkRegex = regexp.MustCompile(`<link[^>]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`)
|
iconLinkRegex = regexp.MustCompile(`<link[^>]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Add this near the top with other vars
|
||||||
|
var (
|
||||||
|
faviconDownloadQueue = make(chan faviconDownloadRequest, 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
type faviconDownloadRequest struct {
|
||||||
|
faviconURL string
|
||||||
|
pageURL string
|
||||||
|
cacheID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Start 5 worker goroutines to process favicon downloads
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
go faviconDownloadWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func faviconDownloadWorker() {
|
||||||
|
for req := range faviconDownloadQueue {
|
||||||
|
cacheFavicon(req.faviconURL, req.cacheID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generates a cache ID from URL
|
// Generates a cache ID from URL
|
||||||
func faviconIDFromURL(rawURL string) string {
|
func faviconIDFromURL(rawURL string) string {
|
||||||
hasher := md5.New()
|
hasher := md5.New()
|
||||||
|
@ -312,14 +336,14 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string {
|
||||||
return fmt.Sprintf("/image/%s_thumb.webp", cacheID)
|
return fmt.Sprintf("/image/%s_thumb.webp", cacheID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve URL (but ignore resolved ID — we always use the one from pageURL)
|
// Resolve URL
|
||||||
faviconURL, _ := resolveFaviconURL(rawFavicon, pageURL)
|
faviconURL, _ := resolveFaviconURL(rawFavicon, pageURL)
|
||||||
if faviconURL == "" {
|
if faviconURL == "" {
|
||||||
recordInvalidImageID(cacheID)
|
recordInvalidImageID(cacheID)
|
||||||
return "/static/images/missing.svg"
|
return "/static/images/missing.svg"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid re-downloading
|
// Check if already downloading
|
||||||
faviconCache.RLock()
|
faviconCache.RLock()
|
||||||
downloading := faviconCache.m[cacheID]
|
downloading := faviconCache.m[cacheID]
|
||||||
faviconCache.RUnlock()
|
faviconCache.RUnlock()
|
||||||
|
@ -329,17 +353,12 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string {
|
||||||
faviconCache.m[cacheID] = true
|
faviconCache.m[cacheID] = true
|
||||||
faviconCache.Unlock()
|
faviconCache.Unlock()
|
||||||
|
|
||||||
go func() {
|
// Send to download queue instead of starting goroutine
|
||||||
defer func() {
|
faviconDownloadQueue <- faviconDownloadRequest{
|
||||||
faviconCache.Lock()
|
faviconURL: faviconURL,
|
||||||
delete(faviconCache.m, cacheID)
|
pageURL: pageURL,
|
||||||
faviconCache.Unlock()
|
cacheID: cacheID,
|
||||||
}()
|
}
|
||||||
_, _, err := cacheFavicon(faviconURL, cacheID)
|
|
||||||
if err != nil {
|
|
||||||
recordInvalidImageID(cacheID)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("/image/%s_thumb.webp", cacheID)
|
return fmt.Sprintf("/image/%s_thumb.webp", cacheID)
|
||||||
|
|
248
static/js/dynamicscrollingtext.js
Normal file
248
static/js/dynamicscrollingtext.js
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
(function() {
|
||||||
|
// Get template data and configuration
|
||||||
|
const templateData = document.getElementById('template-data');
|
||||||
|
const type = templateData.getAttribute('data-type');
|
||||||
|
const hardCacheEnabled = templateData.getAttribute('data-hard-cache-enabled') === 'true';
|
||||||
|
|
||||||
|
// Track all favicon/image elements and their IDs
|
||||||
|
const allMediaElements = [];
|
||||||
|
const allMediaIds = [];
|
||||||
|
let statusCheckTimeout = null;
|
||||||
|
|
||||||
|
// Add loading effects to image/favicon and associated text
|
||||||
|
function addLoadingEffects(imgElement) {
|
||||||
|
const container = imgElement.closest(type === 'image' ? '.image' : '.result_item');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const titleSelector = type === 'image' ? '.img_title' : '.result-url';
|
||||||
|
const title = container.querySelector(titleSelector);
|
||||||
|
imgElement.closest('.favicon-wrapper')?.classList.add('loading');
|
||||||
|
// if (title) title.classList.add('title-loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove loading effects when image/favicon loads
|
||||||
|
function removeLoadingEffects(imgElement) {
|
||||||
|
const container = imgElement.closest(type === 'image' ? '.image' : '.result_item');
|
||||||
|
const titleSelector = type === 'image' ? '.img_title' : '.result-url';
|
||||||
|
const title = container?.querySelector(titleSelector);
|
||||||
|
imgElement.closest('.favicon-wrapper')?.classList.remove('loading');
|
||||||
|
if (title) title.classList.remove('title-loading');
|
||||||
|
|
||||||
|
if (type === 'image' && imgElement.src.endsWith('/images/missing.svg')) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image/favicon loading errors
|
||||||
|
function handleImageError(imgElement) {
|
||||||
|
const container = imgElement.closest(type === 'image' ? '.image' : '.result_item');
|
||||||
|
const titleSelector = type === 'image' ? '.img_title' : '.result-url';
|
||||||
|
const title = container?.querySelector(titleSelector);
|
||||||
|
|
||||||
|
imgElement.closest('.favicon-wrapper')?.classList.remove('loading');
|
||||||
|
if (title) title.classList.remove('title-loading');
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
container.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
imgElement.src = '/static/images/missing.svg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared configuration
|
||||||
|
const statusCheckInterval = 500;
|
||||||
|
const scrollThreshold = 500;
|
||||||
|
const loadingIndicator = document.getElementById('message-bottom-right');
|
||||||
|
let loadingTimer;
|
||||||
|
let isFetching = false;
|
||||||
|
let page = parseInt(templateData.getAttribute('data-page')) || 1;
|
||||||
|
let query = templateData.getAttribute('data-query');
|
||||||
|
let noMoreImages = false;
|
||||||
|
|
||||||
|
function showLoadingMessage() {
|
||||||
|
loadingIndicator.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoadingMessage() {
|
||||||
|
loadingIndicator.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureScrollable() {
|
||||||
|
if (noMoreImages) return;
|
||||||
|
if (document.body.scrollHeight <= window.innerHeight) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a new media element for tracking
|
||||||
|
function registerMediaElement(imgElement) {
|
||||||
|
const id = imgElement.getAttribute('data-id');
|
||||||
|
if (!id || allMediaIds.includes(id)) return;
|
||||||
|
|
||||||
|
// Wrap the image in a .favicon-wrapper if not already
|
||||||
|
if (!imgElement.parentElement.classList.contains('favicon-wrapper')) {
|
||||||
|
const wrapper = document.createElement('span');
|
||||||
|
wrapper.classList.add('favicon-wrapper');
|
||||||
|
imgElement.parentElement.replaceChild(wrapper, imgElement);
|
||||||
|
wrapper.appendChild(imgElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track and style
|
||||||
|
allMediaElements.push(imgElement);
|
||||||
|
allMediaIds.push(id);
|
||||||
|
addLoadingEffects(imgElement);
|
||||||
|
|
||||||
|
|
||||||
|
if (hardCacheEnabled) {
|
||||||
|
imgElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||||
|
} else {
|
||||||
|
imgElement.src = '/static/images/placeholder.svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a status check if not already pending
|
||||||
|
if (!statusCheckTimeout) {
|
||||||
|
statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status of all tracked media elements
|
||||||
|
function checkMediaStatus() {
|
||||||
|
statusCheckTimeout = null;
|
||||||
|
|
||||||
|
if (allMediaIds.length === 0) return;
|
||||||
|
|
||||||
|
// Group IDs to avoid very long URLs
|
||||||
|
const idGroups = [];
|
||||||
|
for (let i = 0; i < allMediaIds.length; i += 50) {
|
||||||
|
idGroups.push(allMediaIds.slice(i, i + 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkGroup = (group) => {
|
||||||
|
return fetch(`/image_status?image_ids=${group.join(',')}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(statusMap => {
|
||||||
|
const pendingElements = [];
|
||||||
|
const pendingIds = [];
|
||||||
|
|
||||||
|
allMediaElements.forEach((imgElement, index) => {
|
||||||
|
const id = allMediaIds[index];
|
||||||
|
if (group.includes(id)) {
|
||||||
|
if (statusMap[id]) {
|
||||||
|
if (imgElement.src !== statusMap[id]) {
|
||||||
|
imgElement.src = statusMap[id];
|
||||||
|
imgElement.onload = () => removeLoadingEffects(imgElement);
|
||||||
|
imgElement.onerror = () => handleImageError(imgElement);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingElements.push(imgElement);
|
||||||
|
pendingIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update global arrays with remaining pending items
|
||||||
|
allMediaElements = pendingElements;
|
||||||
|
allMediaIds = pendingIds;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all groups sequentially
|
||||||
|
const processGroups = async () => {
|
||||||
|
for (const group of idGroups) {
|
||||||
|
try {
|
||||||
|
await checkGroup(group);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status check error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still have pending items, schedule another check
|
||||||
|
if (allMediaIds.length > 0) {
|
||||||
|
statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNextPage() {
|
||||||
|
if (isFetching || noMoreImages) return;
|
||||||
|
|
||||||
|
loadingTimer = setTimeout(() => {
|
||||||
|
showLoadingMessage();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
isFetching = true;
|
||||||
|
page += 1;
|
||||||
|
|
||||||
|
fetch(`/search?q=${encodeURIComponent(query)}&t=${type}&p=${page}&ajax=true`)
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(html => {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
hideLoadingMessage();
|
||||||
|
|
||||||
|
let tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
let newItems = tempDiv.querySelectorAll(type === 'image' ? '.image' : '.result_item');
|
||||||
|
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
let resultsContainer = document.querySelector(type === 'image' ? '.images' : '.results');
|
||||||
|
newItems.forEach(item => {
|
||||||
|
let clonedItem = item.cloneNode(true);
|
||||||
|
resultsContainer.appendChild(clonedItem);
|
||||||
|
|
||||||
|
// Register any new media elements
|
||||||
|
const img = clonedItem.querySelector('img[data-id]');
|
||||||
|
if (img) {
|
||||||
|
registerMediaElement(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureScrollable();
|
||||||
|
} else {
|
||||||
|
noMoreImages = true;
|
||||||
|
}
|
||||||
|
isFetching = false;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
hideLoadingMessage();
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
isFetching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all existing media elements
|
||||||
|
function initializeMediaElements() {
|
||||||
|
document.querySelectorAll('img[data-id]').forEach(img => {
|
||||||
|
registerMediaElement(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start periodic checks if hard cache is enabled
|
||||||
|
if (hardCacheEnabled && allMediaIds.length > 0) {
|
||||||
|
statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
initializeMediaElements();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', initializeMediaElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite scroll handler
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (isFetching || noMoreImages) return;
|
||||||
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - scrollThreshold) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (statusCheckTimeout) {
|
||||||
|
clearTimeout(statusCheckTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
|
@ -17,6 +17,45 @@
|
||||||
<link rel="icon" href="{{ .IconPathSVG }}" type="image/svg+xml">
|
<link rel="icon" href="{{ .IconPathSVG }}" type="image/svg+xml">
|
||||||
<link rel="icon" href="{{ .IconPathPNG }}" type="image/png">
|
<link rel="icon" href="{{ .IconPathPNG }}" type="image/png">
|
||||||
<link rel="apple-touch-icon" href="{{ .IconPathPNG }}">
|
<link rel="apple-touch-icon" href="{{ .IconPathPNG }}">
|
||||||
|
<style>
|
||||||
|
.favicon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favicon-wrapper.loading img {
|
||||||
|
visibility: hidden; /* hide placeholder */
|
||||||
|
}
|
||||||
|
|
||||||
|
.favicon-wrapper.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: -8px 0 0 -8px;
|
||||||
|
border: 2px solid var(--html-bg);
|
||||||
|
border-top-color: var(--fg);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
@keyframes loading {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
} */
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Menu Button -->
|
<!-- Menu Button -->
|
||||||
|
@ -194,10 +233,9 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="template-data" data-page="{{ .Page }}" data-query="{{ .Query }}" data-type="text"></div>
|
<div id="template-data" data-page="{{ .Page }}" data-query="{{ .Query }}" data-type="text"></div>
|
||||||
<script defer src="/static/js/dynamicscrolling.js"></script>
|
<script defer src="/static/js/dynamicscrollingtext.js"></script>
|
||||||
<script defer src="/static/js/autocomplete.js"></script>
|
<script defer src="/static/js/autocomplete.js"></script>
|
||||||
<script defer src="/static/js/minimenu.js"></script>
|
<script defer src="/static/js/minimenu.js"></script>
|
||||||
<script defer src="/static/js/dynamicscrollingimages.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('.js-enabled').forEach(el => el.classList.remove('js-enabled'));
|
document.querySelectorAll('.js-enabled').forEach(el => el.classList.remove('js-enabled'));
|
||||||
</script>
|
</script>
|
||||||
|
|
55
text.go
55
text.go
|
@ -1,7 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -52,12 +55,11 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string
|
||||||
|
|
||||||
elapsedTime := time.Since(startTime)
|
elapsedTime := time.Since(startTime)
|
||||||
|
|
||||||
// Prepare safe decorated results
|
// Simplified result structure without waiting for favicons
|
||||||
type DecoratedResult struct {
|
type DecoratedResult struct {
|
||||||
TextSearchResult
|
TextSearchResult
|
||||||
FaviconURL string
|
|
||||||
FaviconID string
|
|
||||||
PrettyLink LinkParts
|
PrettyLink LinkParts
|
||||||
|
FaviconID string // Just the ID, URL will be generated client-side
|
||||||
}
|
}
|
||||||
|
|
||||||
var decoratedResults []DecoratedResult
|
var decoratedResults []DecoratedResult
|
||||||
|
@ -66,39 +68,52 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// First format the link
|
|
||||||
prettyLink := FormatLinkHTML(r.URL)
|
prettyLink := FormatLinkHTML(r.URL)
|
||||||
faviconID := faviconIDFromURL(prettyLink.RootURL)
|
faviconID := faviconIDFromURL(prettyLink.RootURL)
|
||||||
faviconURL := getFaviconProxyURL("", prettyLink.RootURL)
|
|
||||||
|
|
||||||
decoratedResults = append(decoratedResults, DecoratedResult{
|
decoratedResults = append(decoratedResults, DecoratedResult{
|
||||||
TextSearchResult: r,
|
TextSearchResult: r,
|
||||||
PrettyLink: prettyLink,
|
PrettyLink: prettyLink,
|
||||||
FaviconID: faviconID,
|
FaviconID: faviconID,
|
||||||
FaviconURL: faviconURL,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Start async favicon fetch if not already cached
|
||||||
|
go ensureFaviconIsCached(faviconID, prettyLink.RootURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"Results": decoratedResults,
|
"Results": decoratedResults,
|
||||||
"Query": query,
|
"Query": query,
|
||||||
"Fetched": FormatElapsedTime(elapsedTime),
|
"Fetched": FormatElapsedTime(elapsedTime),
|
||||||
"Page": page,
|
"Page": page,
|
||||||
"HasPrevPage": hasPrevPage,
|
"HasPrevPage": hasPrevPage,
|
||||||
"HasNextPage": len(combinedResults) >= 50,
|
"HasNextPage": len(combinedResults) >= 50,
|
||||||
"NoResults": len(combinedResults) == 0,
|
"NoResults": len(combinedResults) == 0,
|
||||||
"LanguageOptions": languageOptions,
|
"LanguageOptions": languageOptions,
|
||||||
"CurrentLang": settings.SearchLanguage,
|
"CurrentLang": settings.SearchLanguage,
|
||||||
"Theme": settings.Theme,
|
"Theme": settings.Theme,
|
||||||
"Safe": settings.SafeSearch,
|
"Safe": settings.SafeSearch,
|
||||||
"IsThemeDark": settings.IsThemeDark,
|
"IsThemeDark": settings.IsThemeDark,
|
||||||
"Trans": Translate,
|
"Trans": Translate,
|
||||||
|
"HardCacheEnabled": config.DriveCacheEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the template
|
|
||||||
renderTemplate(w, "text.html", data)
|
renderTemplate(w, "text.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureFaviconIsCached(faviconID, rootURL string) {
|
||||||
|
// Check if already exists in cache
|
||||||
|
filename := fmt.Sprintf("%s_thumb.webp", faviconID)
|
||||||
|
cachedPath := filepath.Join(config.DriveCache.Path, "images", filename)
|
||||||
|
|
||||||
|
if _, err := os.Stat(cachedPath); err == nil {
|
||||||
|
return // Already cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached, initiate download
|
||||||
|
getFaviconProxyURL("", rootURL) // This will trigger async download
|
||||||
|
}
|
||||||
|
|
||||||
func getTextResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string, page int) []TextSearchResult {
|
func getTextResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string, page int) []TextSearchResult {
|
||||||
cacheChan := make(chan []SearchResult)
|
cacheChan := make(chan []SearchResult)
|
||||||
var combinedResults []TextSearchResult
|
var combinedResults []TextSearchResult
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue