Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d91b22bd7 | |||
| 28f3ca73d0 | |||
| a144c6b383 | |||
| adfdf0fade | |||
| 173474d23f | |||
| 226fa4a143 | |||
| 60e0c6a09d | |||
| 8fee260c44 | |||
| f8c8f520af | |||
| c924c3e590 | |||
| 4da1f7a6d1 | |||
| 37e3a3fe7c | |||
| 49288a5b8b |
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github: tardisx
|
||||||
|
ko_fi: tardisx
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -4,7 +4,9 @@
|
|||||||
"colly",
|
"colly",
|
||||||
"incpatch",
|
"incpatch",
|
||||||
"linkwallet",
|
"linkwallet",
|
||||||
|
"nicetime",
|
||||||
"serialised",
|
"serialised",
|
||||||
"stopword"
|
"stopword",
|
||||||
|
"Upsert"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Justin Hawkins
|
||||||
|
|
||||||
|
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.
|
||||||
45
README.md
45
README.md
@@ -6,17 +6,60 @@ A self-hosted bookmark database with full-text page content search.
|
|||||||
|
|
||||||
* Simple cross-platform single binary deployment
|
* Simple cross-platform single binary deployment
|
||||||
* or docker if you prefer
|
* or docker if you prefer
|
||||||
|
* Bookmarklet, single click to add a bookmark from any webpage
|
||||||
* Full-text search
|
* Full-text search
|
||||||
* Bookmark content is scraped and indexed locally
|
* Bookmark content is scraped and indexed locally
|
||||||
* Page content periodically refreshed automatically
|
* Page content periodically refreshed automatically
|
||||||
* Interactively search across titles and content
|
* Interactively search across titles and content
|
||||||
* Rippingly fast results, as you type
|
* Rippingly fast results, as you type
|
||||||
|
* full text search ~60ms (over full text content of 600 bookmarks)
|
||||||
* No need to remember how you filed something, you just need a keyword
|
* No need to remember how you filed something, you just need a keyword
|
||||||
or two to discover it again
|
or two to discover it again
|
||||||
* Embedded database, no separate database system required
|
* Embedded database, no separate database required
|
||||||
* Light on resources
|
* Light on resources
|
||||||
|
* ~21Mb binary
|
||||||
|
* ~40Mb memory
|
||||||
|
* ~24Mb database (600 bookmarks, full text content indexed)
|
||||||
* Easily export your bookmarks to a plain text file - your data is yours
|
* Easily export your bookmarks to a plain text file - your data is yours
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
* Copy the `docker-compose.yml-sample` to a directory somewhere
|
||||||
|
* Rename to `docker-compose.yml` and edit to your needs
|
||||||
|
* In most cases, you only need to change the path to the `/data`
|
||||||
|
mountpoint.
|
||||||
|
* Run `docker-compose up -d`
|
||||||
|
|
||||||
|
## Binary
|
||||||
|
|
||||||
|
* Download the appropriate binary from the releases page
|
||||||
|
* Install somewhere on your system
|
||||||
|
* Run `./linkwallet -db-path /some/path/xxxx.db` where `/some/path/xxxx.db`
|
||||||
|
is the location of your bookmarks database (will be created if it does not yet exist)
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
* Checkout the code
|
||||||
|
* `go build cmd/linkwallet/linkwallet.go`
|
||||||
|
|
||||||
|
## deb/rpm packages
|
||||||
|
|
||||||
|
Coming soon.
|
||||||
|
|
||||||
|
# Using
|
||||||
|
|
||||||
|
linkwallet is a 100% web-driven app. After running, hit the web interface
|
||||||
|
on port 8109 (docker using the sample docker-compose.yml) or 8080 (default
|
||||||
|
on binary).
|
||||||
|
|
||||||
|
Change the port number by setting the PORT environment variable.
|
||||||
|
|
||||||
|
If you put linkwallet on a separate machine, or behind a reverse proxy,
|
||||||
|
go into the config page and set the correct `BaseURL` parameter, or the bookmarklets
|
||||||
|
will not work.
|
||||||
|
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
* More options when managing links
|
* More options when managing links
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/tardisx/linkwallet/db"
|
"github.com/tardisx/linkwallet/db"
|
||||||
@@ -10,8 +11,19 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
var dbPath string
|
||||||
|
flag.StringVar(&dbPath, "db-path", "", "path to the database file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if dbPath == "" {
|
||||||
|
log.Fatal("You need to specify the path to the database file with -db-path")
|
||||||
|
}
|
||||||
|
|
||||||
dbh := db.DB{}
|
dbh := db.DB{}
|
||||||
dbh.Open("badger")
|
err := dbh.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
bmm := db.NewBookmarkManager(&dbh)
|
bmm := db.NewBookmarkManager(&dbh)
|
||||||
cmm := db.NewConfigManager(&dbh)
|
cmm := db.NewConfigManager(&dbh)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ func (m *BookmarkManager) AddBookmark(bm *entity.Bookmark) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *BookmarkManager) DeleteBookmark(bm *entity.Bookmark) error {
|
||||||
|
err := m.db.store.FindOne(bm, bolthold.Where("URL").Eq(bm.URL))
|
||||||
|
if err == bolthold.ErrNotFound {
|
||||||
|
return fmt.Errorf("bookmark does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete it
|
||||||
|
m.db.store.DeleteMatching(bm, bolthold.Where("ID").Eq(bm.ID))
|
||||||
|
// delete all the index entries
|
||||||
|
m.db.UpdateIndexForWordsByID([]string{}, bm.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListBookmarks returns all bookmarks.
|
// ListBookmarks returns all bookmarks.
|
||||||
func (m *BookmarkManager) ListBookmarks() ([]entity.Bookmark, error) {
|
func (m *BookmarkManager) ListBookmarks() ([]entity.Bookmark, error) {
|
||||||
bookmarks := make([]entity.Bookmark, 0, 0)
|
bookmarks := make([]entity.Bookmark, 0, 0)
|
||||||
|
|||||||
9
db/db.go
9
db/db.go
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/tardisx/linkwallet/entity"
|
"github.com/tardisx/linkwallet/entity"
|
||||||
@@ -11,16 +12,16 @@ type DB struct {
|
|||||||
store *bolthold.Store
|
store *bolthold.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Open(dir string) {
|
func (db *DB) Open(path string) error {
|
||||||
// options := bolthold.DefaultOptions
|
// options := bolthold.DefaultOptions
|
||||||
// options.Dir = dir
|
// options.Dir = dir
|
||||||
// options.ValueDir = dir
|
// options.ValueDir = dir
|
||||||
store, err := bolthold.Open("bolt.db", 0666, nil)
|
store, err := bolthold.Open(path, 0666, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return fmt.Errorf("cannot open '%s' - %s", path, err)
|
||||||
|
|
||||||
}
|
}
|
||||||
db.store = store
|
db.store = store
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Close() {
|
func (db *DB) Close() {
|
||||||
|
|||||||
10
db/index.go
10
db/index.go
@@ -2,7 +2,6 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tardisx/linkwallet/entity"
|
"github.com/tardisx/linkwallet/entity"
|
||||||
bolthold "github.com/timshannon/bolthold"
|
bolthold "github.com/timshannon/bolthold"
|
||||||
@@ -21,15 +20,12 @@ func (db *DB) UpdateIndexForWordsByID(words []string, id uint64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.store.TxForEach(txn, &bolthold.Query{}, func(wi *entity.WordIndex) {
|
db.store.TxForEach(txn, &bolthold.Query{}, func(wi *entity.WordIndex) {
|
||||||
// log.Printf("considering this one: %s", wi.Word)
|
|
||||||
delete(wi.Bitmap, id)
|
delete(wi.Bitmap, id)
|
||||||
})
|
})
|
||||||
|
|
||||||
// adding
|
// adding
|
||||||
var find, store time.Duration
|
|
||||||
for i, word := range words {
|
for i, word := range words {
|
||||||
// log.Printf("indexing %s", word)
|
// log.Printf("indexing %s", word)
|
||||||
tF := time.Now()
|
|
||||||
thisWI := entity.WordIndex{Word: word}
|
thisWI := entity.WordIndex{Word: word}
|
||||||
err := db.store.TxGet(txn, "word_index_"+word, &thisWI)
|
err := db.store.TxGet(txn, "word_index_"+word, &thisWI)
|
||||||
if err == bolthold.ErrNotFound {
|
if err == bolthold.ErrNotFound {
|
||||||
@@ -38,18 +34,13 @@ func (db *DB) UpdateIndexForWordsByID(words []string, id uint64) {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
findT := time.Since(tF)
|
|
||||||
|
|
||||||
tS := time.Now()
|
|
||||||
thisWI.Bitmap[id] = true
|
thisWI.Bitmap[id] = true
|
||||||
// log.Printf("BM: %v", thisWI.Bitmap)
|
// log.Printf("BM: %v", thisWI.Bitmap)
|
||||||
err = db.store.TxUpsert(txn, "word_index_"+word, thisWI)
|
err = db.store.TxUpsert(txn, "word_index_"+word, thisWI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
findS := time.Since(tS)
|
|
||||||
find += findT
|
|
||||||
store += findS
|
|
||||||
|
|
||||||
if i > 0 && i%100 == 0 {
|
if i > 0 && i%100 == 0 {
|
||||||
txn.Commit()
|
txn.Commit()
|
||||||
@@ -60,7 +51,6 @@ func (db *DB) UpdateIndexForWordsByID(words []string, id uint64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
//log.Printf("find %s store %s", find, store)
|
|
||||||
|
|
||||||
txn.Commit()
|
txn.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
12
docker-compose.yml-sample
Normal file
12
docker-compose.yml-sample
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
version: "3.0"
|
||||||
|
services:
|
||||||
|
linkwallet:
|
||||||
|
image: tardisx/linkwallet:latest
|
||||||
|
container_name: linkwallet
|
||||||
|
entrypoint: [ '/linkwallet', '-db-path', '/data/linkwallet.db' ]
|
||||||
|
volumes:
|
||||||
|
- /home/USERNAME/.linkwallet:/data
|
||||||
|
ports:
|
||||||
|
- 8109:8080
|
||||||
|
restart: unless-stopped
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Tag = "v0.0.11"
|
const Tag = "v0.0.14"
|
||||||
|
|
||||||
var versionInfo struct {
|
var versionInfo struct {
|
||||||
Local struct {
|
Local struct {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
{{ template "manage.html" . }}
|
{{ template "manage.html" . }}
|
||||||
{{ else if eq .page "config" }}
|
{{ else if eq .page "config" }}
|
||||||
{{ template "config.html" . }}
|
{{ template "config.html" . }}
|
||||||
|
{{ else if eq .page "edit" }}
|
||||||
|
{{ template "edit.html" . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{/* template "foundation_sample.html" . */}}
|
{{/* template "foundation_sample.html" . */}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
web/templates/edit.html
Normal file
7
web/templates/edit.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="grid-x grid-padding-x">
|
||||||
|
<div class="large-12 cell">
|
||||||
|
|
||||||
|
<h5>Edit</h5>
|
||||||
|
{{ template "edit_form.html" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
32
web/templates/edit_form.html
Normal file
32
web/templates/edit_form.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
<form onsubmit="return false;" id="edit-form" hx-target="#edit-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<td>{{ .bookmark.Info.Title }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>URL</th>
|
||||||
|
<td>{{ .bookmark.URL }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>tags</th>
|
||||||
|
<td>
|
||||||
|
{{ template "tags_widget.html" .tw }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th>Created</th>
|
||||||
|
<td>{{ (nicetime .bookmark.TimestampCreated).HumanDuration }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Last Scraped</th>
|
||||||
|
<td>{{ (nicetime .bookmark.TimestampLastScraped).HumanDuration }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<button type="button" hx-confirm="Delete this bookmark permanently?" hx-delete="/edit/{{.bookmark.ID}}" class="alert button">delete</button>
|
||||||
|
<button type="button" class="button" hx-post="/edit/{{.bookmark.ID}}">save</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
2
web/templates/edit_form_deleted.html
Normal file
2
web/templates/edit_form_deleted.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
<p>Bookmark deleted</p>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>id</th>
|
<th> </th>
|
||||||
<th>title/url</th>
|
<th>title/url</th>
|
||||||
<th>tags</th>
|
<th>tags</th>
|
||||||
<th class="show-for-large">created</th>
|
<th class="show-for-large">created</th>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{ range .bookmarks }}
|
{{ range .bookmarks }}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ .ID }}</th>
|
<th><a class="button" href="/edit/{{ .ID }}">edit</a></th>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ .URL }}">{{ .Info.Title }}</a>
|
<a href="{{ .URL }}">{{ .Info.Title }}</a>
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
hx-target="#label-widget"
|
hx-target="#label-widget"
|
||||||
hx-trigger="change">
|
hx-trigger="change">
|
||||||
<label for="tag-entry"
|
<label for="tag-entry"
|
||||||
class="Xtext-right Xmiddle">Tags</label>
|
class="">Tags</label>
|
||||||
|
|
||||||
<input id="tag-entry" type="text" name="tag" placeholder="enter tags" />
|
<input id="tag-entry" type="text" name="tag" placeholder="enter tags" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
62
web/web.go
62
web/web.go
@@ -174,7 +174,7 @@ func Create(bmm *db.BookmarkManager, cmm *db.ConfigManager) *Server {
|
|||||||
|
|
||||||
r.POST("/tags", func(c *gin.Context) {
|
r.POST("/tags", func(c *gin.Context) {
|
||||||
|
|
||||||
newTag := c.PostForm("tag")
|
newTag := c.PostForm("tag") // new tag
|
||||||
oldTags := strings.Split(c.PostForm("tags_hidden"), "|")
|
oldTags := strings.Split(c.PostForm("tags_hidden"), "|")
|
||||||
|
|
||||||
remove := c.Query("remove")
|
remove := c.Query("remove")
|
||||||
@@ -235,6 +235,66 @@ func Create(bmm *db.BookmarkManager, cmm *db.ConfigManager) *Server {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.GET("/edit/:id", func(c *gin.Context) {
|
||||||
|
bookmarkIDstring := c.Param("id")
|
||||||
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
||||||
|
if ok != nil {
|
||||||
|
c.String(http.StatusBadRequest, "bad id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
||||||
|
meta := gin.H{"page": "edit", "bookmark": bookmark, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK,
|
||||||
|
"_layout.html", meta,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.POST("/edit/:id", func(c *gin.Context) {
|
||||||
|
bookmarkIDstring := c.Param("id")
|
||||||
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
||||||
|
if ok != nil {
|
||||||
|
c.String(http.StatusBadRequest, "bad id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
||||||
|
|
||||||
|
// freshen tags
|
||||||
|
if c.PostForm("tags_hidden") == "" {
|
||||||
|
// empty
|
||||||
|
bookmark.Tags = []string{}
|
||||||
|
} else {
|
||||||
|
bookmark.Tags = strings.Split(c.PostForm("tags_hidden"), "|")
|
||||||
|
}
|
||||||
|
bmm.SaveBookmark(&bookmark)
|
||||||
|
|
||||||
|
meta := gin.H{"page": "edit", "bookmark": bookmark, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK,
|
||||||
|
"edit_form.html", meta,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.DELETE("/edit/:id", func(c *gin.Context) {
|
||||||
|
bookmarkIDstring := c.Param("id")
|
||||||
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
||||||
|
if ok != nil {
|
||||||
|
c.String(http.StatusBadRequest, "bad id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
||||||
|
err := bmm.DeleteBookmark(&bookmark)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK,
|
||||||
|
"edit_form_deleted.html", nil,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user