Implement fuzzy search for archive entries

This commit is contained in:
Maciej Pędzich 2022-07-27 11:50:36 +02:00
parent b7ae383e65
commit a30bb573b5
5 changed files with 109 additions and 24 deletions

View File

@ -1,4 +1,4 @@
export interface SearchResult { export interface SearchResult {
id: string; id: string;
title: string; name: string;
} }

83
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@vueuse/nuxt": "^8.7.5", "@vueuse/nuxt": "^8.7.5",
"chart.js": "^3.8.0", "chart.js": "^3.8.0",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"fast-fuzzy": "^1.11.2",
"format-duration": "^2.0.0", "format-duration": "^2.0.0",
"html-entities": "^2.3.3", "html-entities": "^2.3.3",
"primeflex": "^3.2.1", "primeflex": "^3.2.1",
@ -4884,6 +4885,14 @@
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"dev": true "dev": true
}, },
"node_modules/fast-fuzzy": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.11.2.tgz",
"integrity": "sha512-H1ct10Pzx+pSO4h7F1uBXET91ay2hy67J1aQZFKL23EXsOoanpwjPNQQoc+NhClKJMmlGGN+0bXhIdFJX70BJw==",
"dependencies": {
"graphemesplit": "^2.4.1"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.2.11", "version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@ -5405,6 +5414,15 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
}, },
"node_modules/graphemesplit": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.4.4.tgz",
"integrity": "sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==",
"dependencies": {
"js-base64": "^3.6.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/gzip-size": { "node_modules/gzip-size": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz",
@ -6146,6 +6164,11 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
"integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ=="
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -7355,6 +7378,11 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -9369,6 +9397,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true "dev": true
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
@ -9596,6 +9629,15 @@
"pathe": "^0.3.0" "pathe": "^0.3.0"
} }
}, },
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unimport": { "node_modules/unimport": {
"version": "0.4.5", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-0.4.5.tgz", "resolved": "https://registry.npmjs.org/unimport/-/unimport-0.4.5.tgz",
@ -13824,6 +13866,14 @@
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"dev": true "dev": true
}, },
"fast-fuzzy": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.11.2.tgz",
"integrity": "sha512-H1ct10Pzx+pSO4h7F1uBXET91ay2hy67J1aQZFKL23EXsOoanpwjPNQQoc+NhClKJMmlGGN+0bXhIdFJX70BJw==",
"requires": {
"graphemesplit": "^2.4.1"
}
},
"fast-glob": { "fast-glob": {
"version": "3.2.11", "version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@ -14206,6 +14256,15 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
}, },
"graphemesplit": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.4.4.tgz",
"integrity": "sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==",
"requires": {
"js-base64": "^3.6.0",
"unicode-trie": "^2.0.0"
}
},
"gzip-size": { "gzip-size": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz",
@ -14726,6 +14785,11 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.14.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.14.0.tgz",
"integrity": "sha512-4IwstlaKQc9vCTC+qUXLM1hajy2ImiL9KnLvVYiaHOtS/v3wRjhLlGl121AmgDgx/O43uKmxownJghS5XMya2A==" "integrity": "sha512-4IwstlaKQc9vCTC+qUXLM1hajy2ImiL9KnLvVYiaHOtS/v3wRjhLlGl121AmgDgx/O43uKmxownJghS5XMya2A=="
}, },
"js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
"integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ=="
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -15666,6 +15730,11 @@
"integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==",
"dev": true "dev": true
}, },
"pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"parent-module": { "parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -17130,6 +17199,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true "dev": true
}, },
"tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"tiny-invariant": { "tiny-invariant": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
@ -17300,6 +17374,15 @@
"pathe": "^0.3.0" "pathe": "^0.3.0"
} }
}, },
"unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"requires": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"unimport": { "unimport": {
"version": "0.4.5", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-0.4.5.tgz", "resolved": "https://registry.npmjs.org/unimport/-/unimport-0.4.5.tgz",

View File

@ -27,6 +27,7 @@
"@vueuse/nuxt": "^8.7.5", "@vueuse/nuxt": "^8.7.5",
"chart.js": "^3.8.0", "chart.js": "^3.8.0",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"fast-fuzzy": "^1.11.2",
"format-duration": "^2.0.0", "format-duration": "^2.0.0",
"html-entities": "^2.3.3", "html-entities": "^2.3.3",
"primeflex": "^3.2.1", "primeflex": "^3.2.1",

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLocalStorage } from '@vueuse/core';
import { $fetch } from 'ohmyfetch'; import { $fetch } from 'ohmyfetch';
import AutoComplete from 'primevue/autocomplete'; import AutoComplete from 'primevue/autocomplete';
@ -6,10 +7,8 @@ import { SearchResult } from '~~/models/search-result';
const router = useRouter(); const router = useRouter();
const searchHistory = useCookie<SearchResult[]>('searchHistory'); const searchHistory = useLocalStorage('searchHistory', []);
searchHistory.value = searchHistory.value || []; const searchName = ref('');
const searchText = ref('');
const suggestions = ref([]); const suggestions = ref([]);
// For some odd reason, if you don't do JSON.parse(JSON.stringify), // For some odd reason, if you don't do JSON.parse(JSON.stringify),
@ -18,10 +17,10 @@ const displaySearchHistory = () =>
(suggestions.value = JSON.parse(JSON.stringify(searchHistory.value))); (suggestions.value = JSON.parse(JSON.stringify(searchHistory.value)));
const findPlaylists = async () => { const findPlaylists = async () => {
if (searchText.value.length === 0) return displaySearchHistory(); if (searchName.value.length === 0) return displaySearchHistory();
try { try {
const urlObject = new URL(searchText.value); const urlObject = new URL(searchName.value);
const [collectionName, playlistId] = urlObject.pathname const [collectionName, playlistId] = urlObject.pathname
.split('/') .split('/')
.filter(Boolean); .filter(Boolean);
@ -40,7 +39,7 @@ const findPlaylists = async () => {
: [ : [
{ {
id: playlistId, id: playlistId,
title: JSON.parse(body).original_name name: JSON.parse(body).original_name
} }
] ]
} }
@ -54,7 +53,7 @@ const findPlaylists = async () => {
if (error.message === 'This is not a valid playlist link') return []; if (error.message === 'This is not a valid playlist link') return [];
const searchResults = await $fetch<SearchResult[]>( const searchResults = await $fetch<SearchResult[]>(
`/api/playlists/search?title=${searchText.value}` `/api/playlists/search?name=${searchName.value}`
); );
return (suggestions.value = searchResults); return (suggestions.value = searchResults);
@ -81,18 +80,18 @@ const openSnapshotsCalendar = async ({
<template> <template>
<NuxtLayout name="centered-content"> <NuxtLayout name="centered-content">
<h1 class="m-0 md:text-5xl text-4xl">Spotify Playlist Archive</h1> <h1 class="m-0 md:text-5xl text-3xl">Spotify Playlist Archive</h1>
<div class="md:p-0 p-2 flex flex-column justify-content-center text-center"> <div class="md:p-0 p-2 flex flex-column justify-content-center text-center">
<p class="md:mt-3 mt-2 text-lg text-gray-300"> <p class="md:mt-3 mt-2 text-lg text-gray-300">
Browse past versions of thousands of playlists saved over time Browse past versions of thousands of playlists saved over time
</p> </p>
<div class="w-full md:px-0 px-3"> <div class="w-full md:px-0 px-3">
<AutoComplete <AutoComplete
v-model="searchText" v-model="searchName"
:suggestions="suggestions" :suggestions="suggestions"
class="w-full" class="w-full"
placeholder="Type in at least 3 characters, or paste a playlist link" placeholder="Start typing, or paste a playlist link"
field="title" field="name"
:min-length="3" :min-length="3"
complete-on-focus complete-on-focus
@complete="findPlaylists" @complete="findPlaylists"

View File

@ -1,9 +1,11 @@
import { $fetch } from 'ohmyfetch'; import { $fetch } from 'ohmyfetch';
import { Searcher } from 'fast-fuzzy';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = useQuery(event); const query = useQuery(event);
const searchTitle = query.title as string; const searchName = query.name as string;
const searchPhrases = searchTitle.trim().split(/[ ]{1,}/);
if (!searchName || searchName.length < 3) return [];
const readmeFileContent = await $fetch<string>( const readmeFileContent = await $fetch<string>(
'https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/README.md' 'https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/README.md'
@ -16,15 +18,15 @@ export default defineEventHandler(async (event) => {
.replaceAll('.md)', '') .replaceAll('.md)', '')
.split('\n') .split('\n')
.map((textEntry) => { .map((textEntry) => {
const [title, id] = textEntry.split(' /playlists/pretty/'); const [name, id] = textEntry.split(' /playlists/pretty/');
return { title, id }; return { name, id };
}) });
.filter((entry) =>
searchPhrases.every((phrase) => const fuzzySearcher = new Searcher(archiveEntries, {
entry.title.toLowerCase().includes(phrase.toLowerCase()) keySelector: (obj) => obj.name
) });
); const searchResults = fuzzySearcher.search(searchName).slice(0, 10);
return archiveEntries; return searchResults;
}); });