Add search functionality
This commit is contained in:
parent
149841bdd1
commit
85470a277e
8 changed files with 138 additions and 24 deletions
35
database.go
35
database.go
|
@ -4,12 +4,39 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
Keys []string
|
Keys []string
|
||||||
Entries map[string]string
|
Entries map[string]string
|
||||||
|
matcher *search.Matcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) search(query string) []string { // returns keys (entry names)
|
||||||
|
results := []string{}
|
||||||
|
// compile patterns
|
||||||
|
queryPatterns := []*search.Pattern{}
|
||||||
|
for _, q := range strings.Split(query, " ") { // per word
|
||||||
|
queryPatterns = append(queryPatterns, db.matcher.CompileString(q))
|
||||||
|
}
|
||||||
|
// search
|
||||||
|
for _, k := range db.Keys {
|
||||||
|
patternsFound := 0
|
||||||
|
for _, p := range queryPatterns {
|
||||||
|
if s, _ := p.IndexString(db.Entries[k]); s != -1 {
|
||||||
|
patternsFound++ // this pattern was found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patternsFound == len(queryPatterns) && !slices.Contains(results, k) {
|
||||||
|
// if all patterns were found, add the key (entry name) to the list
|
||||||
|
results = append(results, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDB(directory string) Database {
|
func BuildDB(directory string) Database {
|
||||||
|
@ -19,14 +46,16 @@ func BuildDB(directory string) Database {
|
||||||
var keys []string
|
var keys []string
|
||||||
entries := map[string]string{}
|
entries := map[string]string{}
|
||||||
// get files in directory and read them
|
// get files in directory and read them
|
||||||
directory = strings.TrimRight(directory, "/") // we don't need that last / -> if '/' is used as directory, you are dumb.
|
directory = strings.TrimRight(directory, "/") // we don't need that last /, don't use the root directory /
|
||||||
entriesDirFs := os.DirFS(directory)
|
entriesDirFs := os.DirFS(directory)
|
||||||
keys, err := fs.Glob(entriesDirFs, "*")
|
keys, err := fs.Glob(entriesDirFs, "*")
|
||||||
if err != nil { logger.Panicln(err) }
|
if err != nil { logger.Panicln(err) }
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
contentB, err := os.ReadFile(directory + "/" + k)
|
contentB, err := os.ReadFile(directory + "/" + k)
|
||||||
if err != nil { logger.Panicln(err) }
|
if err != nil { logger.Panicln(err) }
|
||||||
entries[k] = string(contentB)
|
content := string(contentB)
|
||||||
|
entries[k] = content
|
||||||
}
|
}
|
||||||
return Database{Keys: keys, Entries: entries}
|
matcher := search.New(ContentLanguage, search.IgnoreCase, search.IgnoreDiacritics)
|
||||||
|
return Database{Keys: keys, Entries: entries, matcher: matcher}
|
||||||
}
|
}
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -1,3 +1,5 @@
|
||||||
module github.com/ChaoticByte/plaintext-encyclopedia
|
module github.com/ChaoticByte/plaintext-encyclopedia
|
||||||
|
|
||||||
go 1.22.4
|
go 1.22.4
|
||||||
|
|
||||||
|
require golang.org/x/text v0.17.0
|
||||||
|
|
2
go.sum
Normal file
2
go.sum
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||||
|
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
25
main.go
25
main.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -28,16 +29,13 @@ func loadTemplate() {
|
||||||
func handleApplication(w http.ResponseWriter, req *http.Request) {
|
func handleApplication(w http.ResponseWriter, req *http.Request) {
|
||||||
var entry string
|
var entry string
|
||||||
var err error
|
var err error
|
||||||
entryName := strings.Trim(req.URL.Path, "/")
|
entryName := path.Base(req.URL.Path)
|
||||||
if entryName != "" {
|
if entryName != "/" {
|
||||||
if strings.Contains(entryName, "/") || strings.Contains(entryName, "..") {
|
|
||||||
// path traversal
|
|
||||||
logger.Println("Possible path traversal attempt from", req.RemoteAddr, "to", entryName)
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// load entry
|
// load entry
|
||||||
entry = db.Entries[entryName]
|
entry = db.Entries[entryName]
|
||||||
|
if entry == "" { // redirect if entry doesn't exist (or is empty)
|
||||||
|
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = appTemplate.ExecuteTemplate(
|
err = appTemplate.ExecuteTemplate(
|
||||||
w, "app",
|
w, "app",
|
||||||
|
@ -45,6 +43,16 @@ func handleApplication(w http.ResponseWriter, req *http.Request) {
|
||||||
if err != nil { logger.Println(err) }
|
if err != nil { logger.Println(err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleSearchAPI(w http.ResponseWriter, req *http.Request) {
|
||||||
|
searchQuery := path.Base(req.URL.Path)
|
||||||
|
if searchQuery == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := db.search(searchQuery)
|
||||||
|
w.Write([]byte(strings.Join(results, "\n")))
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// get logger
|
// get logger
|
||||||
logger = log.Default()
|
logger = log.Default()
|
||||||
|
@ -57,6 +65,7 @@ func main() {
|
||||||
http.Handle("/static/", staticHandler)
|
http.Handle("/static/", staticHandler)
|
||||||
// handle application
|
// handle application
|
||||||
http.HandleFunc("/", handleApplication)
|
http.HandleFunc("/", handleApplication)
|
||||||
|
http.HandleFunc("/search/", handleSearchAPI)
|
||||||
// start server
|
// start server
|
||||||
logger.Println("Starting server on", ServerListen)
|
logger.Println("Starting server on", ServerListen)
|
||||||
err := http.ListenAndServe(ServerListen, nil)
|
err := http.ListenAndServe(ServerListen, nil)
|
||||||
|
|
|
@ -17,11 +17,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
<div class="toc">
|
<div class="home-main">
|
||||||
|
<input type="text" id="search-box" placeholder="search">
|
||||||
|
<div id="search-results" class="hidden"></div>
|
||||||
|
<div class="toc" id="toc">
|
||||||
{{- range .TOC -}}
|
{{- range .TOC -}}
|
||||||
<div><a href="{{ . }}">{{ . }}</a></div>
|
<div><a href="{{ . }}">{{ . }}</a></div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/search.js"></script>
|
||||||
{{- end}}
|
{{- end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
45
public/static/search.js
Normal file
45
public/static/search.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
(() => {
|
||||||
|
let searchBox = document.getElementById("search-box");
|
||||||
|
let searchResults = document.getElementById("search-results");
|
||||||
|
let toc = document.getElementById("toc");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} results
|
||||||
|
*/
|
||||||
|
function updateSearchResults(results) {
|
||||||
|
if (results.length > 0) {
|
||||||
|
searchResults.innerHTML = "";
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
let resultElem = document.createElement("div");
|
||||||
|
let resultAnchor = document.createElement("a");
|
||||||
|
resultAnchor.href = results[i]; // we should be at /, so this is right
|
||||||
|
resultAnchor.innerText = results[i];
|
||||||
|
resultElem.appendChild(resultAnchor);
|
||||||
|
searchResults.appendChild(resultElem);
|
||||||
|
}
|
||||||
|
toc.classList.add("hidden");
|
||||||
|
searchResults.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
toc.classList.remove("hidden");
|
||||||
|
searchResults.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearchInput() {
|
||||||
|
// get search query
|
||||||
|
const query = searchBox.value;
|
||||||
|
if (query == "") {
|
||||||
|
updateSearchResults([]);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// make request
|
||||||
|
const response = await fetch("/search/" + query);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Couldn't search, status code ", response.status);
|
||||||
|
}
|
||||||
|
let result = await response.text();
|
||||||
|
updateSearchResults(result.split('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchBox.addEventListener("input", handleSearchInput);
|
||||||
|
})();
|
|
@ -7,10 +7,11 @@ body {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
a:hover { text-decoration: underline; }
|
a:hover, a:focus { text-decoration: underline; }
|
||||||
a {
|
a {
|
||||||
color: black;
|
color: black;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.homebtn {
|
.homebtn {
|
||||||
|
@ -36,16 +37,34 @@ a {
|
||||||
}
|
}
|
||||||
.content { white-space: pre-line; }
|
.content { white-space: pre-line; }
|
||||||
|
|
||||||
.toc {
|
.home-main {
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.home-main, .toc, #search-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.toc, #search-results, #search-box { padding: 0 .2rem; }
|
||||||
|
.toc, #search-results { gap: .2rem; }
|
||||||
|
#search-box {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid #00000040;
|
||||||
|
height: 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
#search-box:active, #search-box:focus, #search-box:hover {
|
||||||
|
border-bottom: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
@media only screen and (max-width: 750px) {
|
@media only screen and (max-width: 750px) {
|
||||||
.main {
|
.main { margin-top: 4rem; }
|
||||||
margin-top: 4rem;
|
|
||||||
}
|
|
||||||
.main > h1, .content {
|
.main > h1, .content {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: unset;
|
max-width: unset;
|
||||||
|
|
13
settings.go
13
settings.go
|
@ -1,7 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
var ServerListen = ":7000"
|
import "golang.org/x/text/language"
|
||||||
var EntriesDirectory = "./entries"
|
|
||||||
var TemplateFile = "./public/index.html"
|
const ServerListen = ":7000"
|
||||||
var StaticDirectory = "./public/static"
|
const EntriesDirectory = "./entries"
|
||||||
var MainTitle = "Encyclopedia"
|
const TemplateFile = "./public/index.html"
|
||||||
|
const StaticDirectory = "./public/static"
|
||||||
|
const MainTitle = "Encyclopedia"
|
||||||
|
var ContentLanguage = language.English
|
||||||
|
|
Reference in a new issue