mirror of https://github.com/jlelse/GoBlog
Full text search
This commit is contained in:
parent
3584171f75
commit
764c9f7536
|
@ -4,7 +4,7 @@
|
||||||
{
|
{
|
||||||
"label": "Build",
|
"label": "Build",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "go build"
|
"command": "go build --tags \"libsqlite3 linux sqlite_fts5\""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -4,7 +4,7 @@ ADD *.go /app/
|
||||||
ADD go.mod /app/
|
ADD go.mod /app/
|
||||||
ADD go.sum /app/
|
ADD go.sum /app/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN go build --tags "libsqlite3 linux"
|
RUN go build --tags "libsqlite3 linux sqlite_fts5"
|
||||||
|
|
||||||
FROM alpine:3.12
|
FROM alpine:3.12
|
||||||
RUN apk add --no-cache sqlite-dev
|
RUN apk add --no-cache sqlite-dev
|
||||||
|
|
11
config.go
11
config.go
|
@ -49,11 +49,12 @@ type configBlog struct {
|
||||||
Title string `mapstructure:"title"`
|
Title string `mapstructure:"title"`
|
||||||
Description string `mapstructure:"description"`
|
Description string `mapstructure:"description"`
|
||||||
Pagination int `mapstructure:"pagination"`
|
Pagination int `mapstructure:"pagination"`
|
||||||
|
DefaultSection string `mapstructure:"defaultsection"`
|
||||||
Sections map[string]*section `mapstructure:"sections"`
|
Sections map[string]*section `mapstructure:"sections"`
|
||||||
Taxonomies []*taxonomy `mapstructure:"taxonomies"`
|
Taxonomies []*taxonomy `mapstructure:"taxonomies"`
|
||||||
Menus map[string]*menu `mapstructure:"menus"`
|
Menus map[string]*menu `mapstructure:"menus"`
|
||||||
Photos *photos `mapstructure:"photos"`
|
Photos *photos `mapstructure:"photos"`
|
||||||
DefaultSection string `mapstructure:"defaultsection"`
|
Search *search `mapstructure:"search"`
|
||||||
CustomPages []*customPage `mapstructure:"custompages"`
|
CustomPages []*customPage `mapstructure:"custompages"`
|
||||||
Telegram *configTelegram `mapstructure:"telegram"`
|
Telegram *configTelegram `mapstructure:"telegram"`
|
||||||
}
|
}
|
||||||
|
@ -88,6 +89,14 @@ type photos struct {
|
||||||
Description string `mapstructure:"description"`
|
Description string `mapstructure:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type search struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Path string `mapstructure:"path"`
|
||||||
|
Title string `mapstructure:"title"`
|
||||||
|
Description string `mapstructure:"description"`
|
||||||
|
Placeholder string `mapstructure:"placeholder"`
|
||||||
|
}
|
||||||
|
|
||||||
type customPage struct {
|
type customPage struct {
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
Template string `mapstructure:"template"`
|
Template string `mapstructure:"template"`
|
||||||
|
|
|
@ -15,17 +15,17 @@ func migrateDb() error {
|
||||||
Name: "00001",
|
Name: "00001",
|
||||||
Func: func(tx *sql.Tx) error {
|
Func: func(tx *sql.Tx) error {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
CREATE TABLE posts (path text not null primary key, content text, published text, updated text, blog text not null, section text);
|
create table posts (path text not null primary key, content text, published text, updated text, blog text not null, section text);
|
||||||
CREATE TABLE post_parameters (id integer primary key autoincrement, path text not null, parameter text not null, value text);
|
create table post_parameters (id integer primary key autoincrement, path text not null, parameter text not null, value text);
|
||||||
CREATE INDEX index_pp_path on post_parameters (path);
|
create index index_pp_path on post_parameters (path);
|
||||||
CREATE TRIGGER AFTER DELETE on posts BEGIN delete from post_parameters where path = old.path; END;
|
create trigger after delete on posts begin delete from post_parameters where path = old.path; end;
|
||||||
CREATE TABLE indieauthauth (time text not null, code text not null, me text not null, client text not null, redirect text not null, scope text not null);
|
create table indieauthauth (time text not null, code text not null, me text not null, client text not null, redirect text not null, scope text not null);
|
||||||
CREATE TABLE indieauthtoken (time text not null, token text not null, me text not null, client text not null, scope text not null);
|
create table indieauthtoken (time text not null, token text not null, me text not null, client text not null, scope text not null);
|
||||||
CREATE INDEX index_iat_token on indieauthtoken (token);
|
create index index_iat_token on indieauthtoken (token);
|
||||||
CREATE TABLE autocert (key text not null primary key, data blob not null, created text not null);
|
create table autocert (key text not null primary key, data blob not null, created text not null);
|
||||||
CREATE TABLE activitypub_followers (blog text not null, follower text not null, inbox text not null, primary key (blog, follower));
|
create table activitypub_followers (blog text not null, follower text not null, inbox text not null, primary key (blog, follower));
|
||||||
CREATE TABLE webmentions (id integer primary key autoincrement, source text not null, target text not null, created integer not null, status text not null default "new", title text, content text, author text, type text, UNIQUE(source, target));
|
create table webmentions (id integer primary key autoincrement, source text not null, target text not null, created integer not null, status text not null default "new", title text, content text, author text, type text, unique(source, target));
|
||||||
CREATE INDEX index_wm_target on webmentions (target);
|
create index index_wm_target on webmentions (target);
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
@ -34,7 +34,7 @@ func migrateDb() error {
|
||||||
Name: "00002",
|
Name: "00002",
|
||||||
Func: func(tx *sql.Tx) error {
|
Func: func(tx *sql.Tx) error {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
DROP TABLE autocert;
|
drop table autocert;
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
@ -43,8 +43,19 @@ func migrateDb() error {
|
||||||
Name: "00003",
|
Name: "00003",
|
||||||
Func: func(tx *sql.Tx) error {
|
Func: func(tx *sql.Tx) error {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
DROP TRIGGER AFTER;
|
drop trigger AFTER;
|
||||||
CREATE TRIGGER trigger_posts_delete_pp AFTER DELETE on posts BEGIN delete from post_parameters where path = old.path; END;
|
create trigger trigger_posts_delete_pp after delete on posts begin delete from post_parameters where path = old.path; end;
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&migrator.Migration{
|
||||||
|
Name: "00004",
|
||||||
|
Func: func(tx *sql.Tx) error {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
create view view_posts_with_title as select id, path, title, content, published, updated, blog, section from (select p.rowid as id, p.path as path, pp.value as title, content, published, updated, blog, section from posts p left outer join post_parameters pp on p.path = pp.path where pp.parameter = 'title');
|
||||||
|
create virtual table posts_fts using fts5(path unindexed, title, content, published unindexed, updated unindexed, blog unindexed, section unindexed, content=view_posts_with_title, content_rowid=id);
|
||||||
|
insert into posts_fts(posts_fts) values ('rebuild');
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
|
13
http.go
13
http.go
|
@ -228,6 +228,19 @@ func buildHandler() (http.Handler, error) {
|
||||||
r.With(cacheMiddleware, minifier.Middleware).Get(photoPath+paginationPath, handler)
|
r.With(cacheMiddleware, minifier.Middleware).Get(photoPath+paginationPath, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if blogConfig.Search.Enabled {
|
||||||
|
searchPath := blogPath + blogConfig.Search.Path
|
||||||
|
handler := serveSearch(blog, searchPath)
|
||||||
|
r.With(cacheMiddleware, minifier.Middleware).Get(searchPath, handler)
|
||||||
|
r.With(cacheMiddleware, minifier.Middleware).Post(searchPath, handler)
|
||||||
|
searchResultPath := searchPath + "/" + searchPlaceholder
|
||||||
|
resultHandler := serveSearchResults(blog, searchResultPath)
|
||||||
|
r.With(cacheMiddleware, minifier.Middleware).Get(searchResultPath, resultHandler)
|
||||||
|
r.With(cacheMiddleware, minifier.Middleware).Get(searchResultPath+feedPath, resultHandler)
|
||||||
|
r.With(cacheMiddleware, minifier.Middleware).Get(searchResultPath+paginationPath, resultHandler)
|
||||||
|
}
|
||||||
|
|
||||||
// Blog
|
// Blog
|
||||||
var mw []func(http.Handler) http.Handler
|
var mw []func(http.Handler) http.Handler
|
||||||
if appConfig.ActivityPub.Enabled {
|
if appConfig.ActivityPub.Enabled {
|
||||||
|
|
23
posts.go
23
posts.go
|
@ -155,6 +155,14 @@ func servePhotos(blog string, path string) func(w http.ResponseWriter, r *http.R
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveSearchResults(blog string, path string) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return serveIndex(&indexConfig{
|
||||||
|
blog: blog,
|
||||||
|
path: path,
|
||||||
|
template: templateIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type indexConfig struct {
|
type indexConfig struct {
|
||||||
blog string
|
blog string
|
||||||
path string
|
path string
|
||||||
|
@ -167,6 +175,10 @@ type indexConfig struct {
|
||||||
|
|
||||||
func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
search := chi.URLParam(r, "search")
|
||||||
|
if search != "" {
|
||||||
|
search = searchDecode(search)
|
||||||
|
}
|
||||||
pageNoString := chi.URLParam(r, "page")
|
pageNoString := chi.URLParam(r, "page")
|
||||||
pageNo, _ := strconv.Atoi(pageNoString)
|
pageNo, _ := strconv.Atoi(pageNoString)
|
||||||
var sections []string
|
var sections []string
|
||||||
|
@ -183,6 +195,7 @@ func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
||||||
taxonomy: ic.tax,
|
taxonomy: ic.tax,
|
||||||
taxonomyValue: ic.taxValue,
|
taxonomyValue: ic.taxValue,
|
||||||
parameter: ic.parameter,
|
parameter: ic.parameter,
|
||||||
|
search: search,
|
||||||
}}, appConfig.Blogs[ic.blog].Pagination)
|
}}, appConfig.Blogs[ic.blog].Pagination)
|
||||||
p.SetPage(pageNo)
|
p.SetPage(pageNo)
|
||||||
var posts []*post
|
var posts []*post
|
||||||
|
@ -198,6 +211,8 @@ func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
||||||
} else if ic.section != nil {
|
} else if ic.section != nil {
|
||||||
title = ic.section.Title
|
title = ic.section.Title
|
||||||
description = ic.section.Description
|
description = ic.section.Description
|
||||||
|
} else if search != "" {
|
||||||
|
title = fmt.Sprintf("%s: %s", appConfig.Blogs[ic.blog].Search.Title, search)
|
||||||
}
|
}
|
||||||
// Check if feed
|
// Check if feed
|
||||||
if ft := feedType(chi.URLParam(r, "feed")); ft != noFeed {
|
if ft := feedType(chi.URLParam(r, "feed")); ft != noFeed {
|
||||||
|
@ -217,6 +232,10 @@ func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(template) == 0 {
|
if len(template) == 0 {
|
||||||
template = templateIndex
|
template = templateIndex
|
||||||
}
|
}
|
||||||
|
path := ic.path
|
||||||
|
if strings.Contains(path, searchPlaceholder) {
|
||||||
|
path = strings.ReplaceAll(path, searchPlaceholder, searchEncode(search))
|
||||||
|
}
|
||||||
render(w, template, &renderData{
|
render(w, template, &renderData{
|
||||||
blogString: ic.blog,
|
blogString: ic.blog,
|
||||||
Canonical: appConfig.Server.PublicAddress + r.URL.Path,
|
Canonical: appConfig.Server.PublicAddress + r.URL.Path,
|
||||||
|
@ -227,8 +246,8 @@ func serveIndex(ic *indexConfig) func(w http.ResponseWriter, r *http.Request) {
|
||||||
HasPrev: p.HasPrev(),
|
HasPrev: p.HasPrev(),
|
||||||
HasNext: p.HasNext(),
|
HasNext: p.HasNext(),
|
||||||
First: ic.path,
|
First: ic.path,
|
||||||
Prev: fmt.Sprintf("%s/page/%d", ic.path, prevPage),
|
Prev: fmt.Sprintf("%s/page/%d", path, prevPage),
|
||||||
Next: fmt.Sprintf("%s/page/%d", ic.path, nextPage),
|
Next: fmt.Sprintf("%s/page/%d", path, nextPage),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
15
postsDb.go
15
postsDb.go
|
@ -159,6 +159,7 @@ func (p *post) createOrReplace(new bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
finishWritingToDb()
|
finishWritingToDb()
|
||||||
|
rebuildFTSIndex()
|
||||||
if !postExists {
|
if !postExists {
|
||||||
defer p.postPostHooks()
|
defer p.postPostHooks()
|
||||||
} else {
|
} else {
|
||||||
|
@ -176,10 +177,19 @@ func deletePost(path string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = appDbExec("delete from posts where path = @path", sql.Named("path", p.Path))
|
_, err = appDbExec("delete from posts where path = @path", sql.Named("path", p.Path))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rebuildFTSIndex()
|
||||||
defer p.postDeleteHooks()
|
defer p.postDeleteHooks()
|
||||||
return reloadRouter()
|
return reloadRouter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rebuildFTSIndex() {
|
||||||
|
_, _ = appDbExec("insert into posts_fts(posts_fts) values ('rebuild')")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func postExists(path string) bool {
|
func postExists(path string) bool {
|
||||||
result := 0
|
result := 0
|
||||||
row, err := appDbQueryRow("select exists(select 1 from posts where path = @path)", sql.Named("path", path))
|
row, err := appDbQueryRow("select exists(select 1 from posts where path = @path)", sql.Named("path", path))
|
||||||
|
@ -203,6 +213,7 @@ func getPost(path string) (*post, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type postsRequestConfig struct {
|
type postsRequestConfig struct {
|
||||||
|
search string
|
||||||
blog string
|
blog string
|
||||||
path string
|
path string
|
||||||
limit int
|
limit int
|
||||||
|
@ -218,6 +229,10 @@ func buildQuery(config *postsRequestConfig) (query string, args []interface{}) {
|
||||||
args = []interface{}{}
|
args = []interface{}{}
|
||||||
defaultSelection := "select p.path as path, coalesce(content, ''), coalesce(published, ''), coalesce(updated, ''), coalesce(blog, ''), coalesce(section, ''), coalesce(parameter, ''), coalesce(value, '') "
|
defaultSelection := "select p.path as path, coalesce(content, ''), coalesce(published, ''), coalesce(updated, ''), coalesce(blog, ''), coalesce(section, ''), coalesce(parameter, ''), coalesce(value, '') "
|
||||||
postsTable := "posts"
|
postsTable := "posts"
|
||||||
|
if config.search != "" {
|
||||||
|
postsTable = "posts_fts(@search)"
|
||||||
|
args = append(args, sql.Named("search", config.search))
|
||||||
|
}
|
||||||
if config.blog != "" {
|
if config.blog != "" {
|
||||||
postsTable = "(select * from " + postsTable + " where blog = @blog)"
|
postsTable = "(select * from " + postsTable + " where blog = @blog)"
|
||||||
args = append(args, sql.Named("blog", config.blog))
|
args = append(args, sql.Named("blog", config.blog))
|
||||||
|
|
|
@ -28,6 +28,7 @@ const templateError = "error"
|
||||||
const templateIndex = "index"
|
const templateIndex = "index"
|
||||||
const templateTaxonomy = "taxonomy"
|
const templateTaxonomy = "taxonomy"
|
||||||
const templatePhotos = "photos"
|
const templatePhotos = "photos"
|
||||||
|
const templateSearch = "search"
|
||||||
|
|
||||||
var templates map[string]*template.Template
|
var templates map[string]*template.Template
|
||||||
var templateFunctions template.FuncMap
|
var templateFunctions template.FuncMap
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchPlaceholder = "{search}"
|
||||||
|
|
||||||
|
func serveSearch(blog string, path string) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q := r.Form.Get("q"); q != "" {
|
||||||
|
http.Redirect(w, r, path+"/"+searchEncode(q), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(w, templateSearch, &renderData{
|
||||||
|
blogString: blog,
|
||||||
|
Canonical: appConfig.Server.PublicAddress + path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchEncode(search string) string {
|
||||||
|
return url.PathEscape(strings.ReplaceAll(base64.StdEncoding.EncodeToString([]byte(search)), "/", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchDecode(encoded string) string {
|
||||||
|
encoded, err := url.PathUnescape(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
encoded = strings.ReplaceAll(encoded, "_", "/")
|
||||||
|
db, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(db)
|
||||||
|
}
|
|
@ -1,9 +1,5 @@
|
||||||
{{ define "title" }}
|
{{ define "title" }}
|
||||||
{{ if .Blog.Photos.Title }}
|
<title>{{ with .Blog.Photos.Title }}{{ . }} - {{ end }}{{ .Blog.Title }}</title>
|
||||||
<title>{{ .Blog.Photos.Title }} - {{ .Blog.Title }}</title>
|
|
||||||
{{ else }}
|
|
||||||
<title>{{ .Blog.Title }}</title>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
{{ define "title" }}
|
||||||
|
<title>{{ with .Blog.Search.Title }}{{ . }} - {{ end }}{{ .Blog.Title }}</title>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "main" }}
|
||||||
|
<main>
|
||||||
|
{{ with .Blog.Search.Title }}<h1>{{ . }}</h1>{{ end }}
|
||||||
|
{{ with .Blog.Search.Description }}{{ md . }}{{ end }}
|
||||||
|
{{ if (or .Blog.Search.Title .Blog.Search.Description) }}
|
||||||
|
<hr>
|
||||||
|
{{ end }}
|
||||||
|
<form class="fw-form p" method="post">
|
||||||
|
<input class="fw" type="text" style="margin-bottom: 5px" name="q" {{ with .Blog.Search.Placeholder }}placeholder="{{ . }}"{{ end }} />
|
||||||
|
<input class="fw" type="submit" value="🔍 {{ string .Blog.Lang "search" }}">
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "search" }}
|
||||||
|
{{ template "base" . }}
|
||||||
|
{{ end }}
|
|
@ -9,4 +9,5 @@ translations: "Übersetzungen"
|
||||||
share: "Teilen"
|
share: "Teilen"
|
||||||
speak: "Lies mir bitte vor."
|
speak: "Lies mir bitte vor."
|
||||||
stopspeak: "Hör auf zu sprechen!"
|
stopspeak: "Hör auf zu sprechen!"
|
||||||
oldcontent: "⚠️ Dieser Eintrag ist bereits über ein Jahr alt. Er ist möglicherweise nicht mehr aktuell. Meinungen können sich geändert haben."
|
oldcontent: "⚠️ Dieser Eintrag ist bereits über ein Jahr alt. Er ist möglicherweise nicht mehr aktuell. Meinungen können sich geändert haben."
|
||||||
|
search: "Suchen"
|
|
@ -21,4 +21,5 @@ approve: "Approve"
|
||||||
anoncomment: "You can also create an anonymous comment."
|
anoncomment: "You can also create an anonymous comment."
|
||||||
interactions: "Interactions"
|
interactions: "Interactions"
|
||||||
send: "Send"
|
send: "Send"
|
||||||
interactionslabel: "Have you published a response to this? Paste the URL here."
|
interactionslabel: "Have you published a response to this? Paste the URL here."
|
||||||
|
search: "Search"
|
Loading…
Reference in New Issue