Skip to content

Commit

Permalink
Show broken bookmarks in the UI. Add schema versioning.
Browse files Browse the repository at this point in the history
  • Loading branch information
saaste committed Mar 10, 2024
1 parent d1b5a78 commit b148433
Show file tree
Hide file tree
Showing 18 changed files with 247 additions and 35 deletions.
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ go run .
Be default, the app is listening to port 8000. You can change this in the `config.yml`.

## Scheduled bookmark check
The app can check for invalid bookmarks, but this feature is disabled by default.
To enable the check, set `check_interval` configuration in `config.yml` to `1` or more. If you want to run the check when the app starts, set `check_on_app_start` configuration to `true`.
The app can check for broken bookmarks, but this feature is disabled by default.
To enable the check, set the `check_interval` setting in `config.yml` to `1` or more.
If you want the check to run when the app starts, set the `check_on_app_start` setting to `true`.

Bookmark Manager sends a notification if invalid bookmarks are found. Currently, only
[Gotify](https://gotify.net/) notifications are supported. If you don't have Gotify, please
do not enable bookmark check. I'm planning to add email notifications in the future.
Broken bookmarks are indicated by an exclamation point icon. Yuo will also see a *Broken Bookmarks* link in the top navigation bar, which will take you to a view listing all broken bookmarks. These are only visible if you are logged in.

Bookmark Manager can send a notification when it detects broken bookmarks. Currently, only
[Gotify](https://gotify.net/) notifications are supported. To enable notifications, set
the `gotify_enabled` setting to `true` and set `gotify_url` and `gotify_token` settings to match your
environment.

## Plans for the future
- Themes
- Email notifications
- Themes
1 change: 1 addition & 0 deletions bookmarks/bookmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Bookmark struct {
IsPrivate bool
Created time.Time
Tags []string
IsWorking bool
}

type BookmarkResult struct {
Expand Down
9 changes: 9 additions & 0 deletions bookmarks/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,27 @@ func (bc *BookmarkChecker) CheckBookbarks() ([]BookmarkError, error) {
log.Printf("Checking %d bookmarks...\n", len(bms))
for _, bookmark := range bms {
resp, err := bc.get(bookmark.URL)
working := true
if err != nil {
errors = append(errors, BookmarkError{
Title: bookmark.Title,
URL: bookmark.URL,
Message: err.Error(),
})
working = false
} else if resp.StatusCode >= 300 {
errors = append(errors, BookmarkError{
Title: bookmark.Title,
URL: bookmark.URL,
Message: fmt.Sprintf("Returned %s", resp.Status),
})
working = false
}

bookmark.IsWorking = working
_, err = bc.repo.Update(bookmark)
if err != nil {
return errors, err
}
}
log.Printf("Bookmarks check done! Found %d errors\n", len(errors))
Expand Down
49 changes: 37 additions & 12 deletions bookmarks/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Repository interface {
GetByKeyword(showPrivate bool, q string, page, pageSize int) (*BookmarkResult, error)
GetTags(showPrivate bool) ([]string, error)
GetAllWithoutPagination() ([]*Bookmark, error)
GetBrokenBookmarks() ([]*Bookmark, error)
}

type SqliteRepository struct {
Expand All @@ -45,8 +46,8 @@ func (r *SqliteRepository) Create(bookmark *Bookmark) (*Bookmark, error) {
defer tx.Rollback()

res, err := tx.Exec(
"INSERT INTO bookmarks (url, title, description, is_private, created) VALUES (?, ?, ?, ?, ?)",
bookmark.URL, bookmark.Title, bookmark.Description, bookmark.IsPrivate, bookmark.Created.UTC().Format(time.RFC3339))
"INSERT INTO bookmarks (url, title, description, is_private, created, is_working) VALUES (?, ?, ?, ?, ?, ?)",
bookmark.URL, bookmark.Title, bookmark.Description, bookmark.IsPrivate, bookmark.Created.UTC().Format(time.RFC3339), bookmark.IsWorking)
if err != nil {
return nil, fmt.Errorf("sql exec failed: %w", err)
}
Expand Down Expand Up @@ -78,8 +79,8 @@ func (r *SqliteRepository) Update(bookmark *Bookmark) (*Bookmark, error) {
defer tx.Rollback()

_, err = tx.Exec(
"UPDATE bookmarks SET url = ?, title = ?, description = ?, is_private = ? WHERE id = ?",
bookmark.URL, bookmark.Title, bookmark.Description, bookmark.IsPrivate, bookmark.ID)
"UPDATE bookmarks SET url = ?, title = ?, description = ?, is_private = ?, is_working = ? WHERE id = ?",
bookmark.URL, bookmark.Title, bookmark.Description, bookmark.IsPrivate, bookmark.IsWorking, bookmark.ID)
if err != nil {
return nil, fmt.Errorf("sql exec failed: %w", err)
}
Expand All @@ -98,11 +99,11 @@ func (r *SqliteRepository) Update(bookmark *Bookmark) (*Bookmark, error) {
}

func (r *SqliteRepository) Get(id int64) (*Bookmark, error) {
row := r.db.QueryRow("SELECT id, url, title, description, is_private, created FROM bookmarks WHERE id = ?", id)
row := r.db.QueryRow("SELECT id, url, title, description, is_private, created, is_working FROM bookmarks WHERE id = ?", id)

var bm Bookmark
var created string
if err := row.Scan(&bm.ID, &bm.URL, &bm.Title, &bm.Description, &bm.IsPrivate, &created); err != nil {
if err := row.Scan(&bm.ID, &bm.URL, &bm.Title, &bm.Description, &bm.IsPrivate, &created, &bm.IsWorking); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
Expand Down Expand Up @@ -154,7 +155,7 @@ func (r *SqliteRepository) GetAll(showPrivate bool, page int, pageSize int) (*Bo
condition = ""
}

query := fmt.Sprintf("SELECT id, url, title, description, is_private, created FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)
query := fmt.Sprintf("SELECT id, url, title, description, is_private, created, is_working FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)
offset := r.calculateOffset(page, pageSize)
rows, err := r.db.Query(query, offset, pageSize)
if err != nil {
Expand Down Expand Up @@ -191,7 +192,7 @@ func (r *SqliteRepository) GetAll(showPrivate bool, page int, pageSize int) (*Bo
func (r *SqliteRepository) GetPrivate(page, pageSize int) (*BookmarkResult, error) {
offset := r.calculateOffset(page, pageSize)

rows, err := r.db.Query("SELECT id, url, title, description, is_private, created FROM bookmarks WHERE is_private = true ORDER BY created DESC, id DESC LIMIT ?, ?", offset, pageSize)
rows, err := r.db.Query("SELECT id, url, title, description, is_private, created, is_working FROM bookmarks WHERE is_private = true ORDER BY created DESC, id DESC LIMIT ?, ?", offset, pageSize)
if err != nil {
return nil, fmt.Errorf("db query failed: %w", err)
}
Expand Down Expand Up @@ -248,7 +249,7 @@ func (r *SqliteRepository) GetByTags(showPrivate bool, containsTags []string, pa
}

condition := builder.String()
query := fmt.Sprintf("SELECT id, url, title, description, is_private, created FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)
query := fmt.Sprintf("SELECT id, url, title, description, is_private, created, is_working FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)

offset := r.calculateOffset(page, pageSize)
params = append(params, offset, pageSize)
Expand Down Expand Up @@ -305,7 +306,7 @@ func (r *SqliteRepository) GetByKeyword(showPrivate bool, q string, page, pageSi
offset := r.calculateOffset(page, pageSize)
params = append(params, offset, pageSize)

query := fmt.Sprintf("SELECT id, url, title, description, is_private, created FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)
query := fmt.Sprintf("SELECT id, url, title, description, is_private, created, is_working FROM bookmarks %s ORDER BY created DESC, id DESC LIMIT ?, ?", condition)
rows, err := r.db.Query(query, params...)
if err != nil {
return nil, fmt.Errorf("db query failed: %w", err)
Expand Down Expand Up @@ -363,7 +364,7 @@ func (r *SqliteRepository) GetTags(showPrivate bool) ([]string, error) {
}

func (r *SqliteRepository) GetAllWithoutPagination() ([]*Bookmark, error) {
query := "SELECT id, url, title, description, is_private, created FROM bookmarks ORDER BY created DESC, id DESC"
query := "SELECT id, url, title, description, is_private, created, is_working FROM bookmarks ORDER BY created DESC, id DESC"
rows, err := r.db.Query(query)
if err != nil {
return nil, fmt.Errorf("fetching bookmarks failed: %w", err)
Expand All @@ -385,7 +386,31 @@ func (r *SqliteRepository) GetAllWithoutPagination() ([]*Bookmark, error) {
}

return bookmarks, nil
}

func (r *SqliteRepository) GetBrokenBookmarks() ([]*Bookmark, error) {
query := "SELECT id, url, title, description, is_private, created, is_working FROM bookmarks WHERE is_working = false ORDER BY created DESC, id DESC"
rows, err := r.db.Query(query)
if err != nil {
return nil, fmt.Errorf("fetching bookmarks failed: %w", err)
}
defer rows.Close()

bookmarks := make([]*Bookmark, 0)
for rows.Next() {
bm, err := r.scanBookmarkRow(rows)
if err != nil {
return nil, err
}
bookmarks = append(bookmarks, bm)
}

err = r.fetchAndSetBookmarkTags(bookmarks)
if err != nil {
return nil, err
}

return bookmarks, nil
}

func (r *SqliteRepository) getTagsByBookmarkIDs(bookmarkIDs []int64) (map[int64][]string, error) {
Expand Down Expand Up @@ -462,7 +487,7 @@ func (r *SqliteRepository) getRowCount(row *sql.Row, pageSize int) (int, error)
func (r *SqliteRepository) scanBookmarkRow(rows *sql.Rows) (*Bookmark, error) {
var bm Bookmark
var created string
if err := rows.Scan(&bm.ID, &bm.URL, &bm.Title, &bm.Description, &bm.IsPrivate, &created); err != nil {
if err := rows.Scan(&bm.ID, &bm.URL, &bm.Title, &bm.Description, &bm.IsPrivate, &created, &bm.IsWorking); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
Expand Down
32 changes: 32 additions & 0 deletions bookmarks/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,37 @@ func TestGetAllWithoutPagination(t *testing.T) {
assert.Equal(t, bookmark1.ID, result[3].ID)
}

func TestGetBrokenBookmarks(t *testing.T) {
db := initTestDatabase(t)
defer db.Close()

repo := NewSqliteRepository(db)

bookmark1 := createBookmark(false)
_, err := repo.Create(bookmark1)
assert.Nil(t, err)

bookmark2 := createBookmark(false)
_, err = repo.Create(bookmark2)
assert.Nil(t, err)

bookmark3 := createBookmark(true)
bookmark3.IsWorking = false
_, err = repo.Create(bookmark3)
assert.Nil(t, err)

bookmark4 := createBookmark(true)
bookmark4.IsWorking = false
_, err = repo.Create(bookmark4)
assert.Nil(t, err)

result, err := repo.GetBrokenBookmarks()
assert.Nil(t, err)
assert.Len(t, result, 2)
assert.Equal(t, bookmark4.ID, result[0].ID)
assert.Equal(t, bookmark3.ID, result[1].ID)
}

func initTestDatabase(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", "test_data/test.db")
assert.Nil(t, err)
Expand All @@ -353,6 +384,7 @@ func createBookmark(isPrivate bool) *Bookmark {
IsPrivate: isPrivate,
Created: removeNanoseconds(time.Now()),
Tags: []string{"tag1", "tag2"},
IsWorking: true,
}
}

Expand Down
8 changes: 7 additions & 1 deletion config.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ template: default
# -------------
# BOOKMARK CHECK
# --------------
# If you enable bookmark check, don't forget to configure notifications.

# How often bookmarks are checked (in hours, 0 to disable)
#check_interval: 24

# Run bookmarks check when app starts
#check_on_app_start: false

# -------------
# Notifications
# --------------

# Use Gotify notifications
gotify_enabled: false

# Gotify base url
# gotify_url: https://mygotifyinstance.com

Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AppConfig struct {
Template string `yaml:"template"`
CheckInterval int `yaml:"check_interval,omitempty"`
CheckRunOnStartup bool `yaml:"check_on_app_start,omitempty"`
GotifyEnabled bool `yaml:"gotify_enabled,omitempty"`
GotifyURL string `yaml:"gotify_url,omitempty"`
GotifyToken string `yaml:"gotify_token,omitempty"`
}
Expand Down
37 changes: 37 additions & 0 deletions handlers/admin_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,41 @@ func (h *Handler) HandlePrivateBookmarks(w http.ResponseWriter, r *http.Request)
h.parseTemplateWithFunc("index.html", r, w, data)
}

func (h *Handler) HandleBrokenBookmarks(w http.ResponseWriter, r *http.Request) {
isAuthenticated := h.isAuthenticated(r)
if !isAuthenticated {
http.Redirect(w, r, fmt.Sprintf("%s/login", h.appConf.BaseURL), http.StatusFound)
return
}

bookmarks, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch bookmarks", err)
return
}

allTags, err := h.bookmarkRepo.GetTags(isAuthenticated)
if err != nil {
h.internalServerError(w, "Failed to fetch tags", err)
return
}

data := templateData{
SiteName: h.appConf.SiteName,
Description: h.appConf.Description,
Title: "Broken Bookmarks",
BaseURL: h.appConf.BaseURL,
CurrentURL: h.getCurrentURL(r, h.appConf),
IsAuthenticated: isAuthenticated,
PrivateOnly: true,
Bookmarks: bookmarks,
Tags: allTags,
BrokenBookmarks: bookmarks,
}

h.parseTemplateWithFunc("index.html", r, w, data)
}

func (h *Handler) HandleBookmarkAdd(w http.ResponseWriter, r *http.Request) {
isAuthenticated := h.isAuthenticated(r)
if !isAuthenticated {
Expand Down Expand Up @@ -90,6 +125,7 @@ func (h *Handler) HandleBookmarkAdd(w http.ResponseWriter, r *http.Request) {
data.Bookmark.Description = r.Form.Get("description")
data.Bookmark.IsPrivate = r.Form.Get("is_private") == "1"
data.Bookmark.Created = time.Now().UTC()
data.Bookmark.IsWorking = true

data.Tags = r.Form.Get("tags")
data.Bookmark.Tags = strings.Split(data.Tags, " ")
Expand Down Expand Up @@ -167,6 +203,7 @@ func (h *Handler) HandleBookmarkEdit(w http.ResponseWriter, r *http.Request) {
data.Bookmark.Title = r.Form.Get("title")
data.Bookmark.Description = r.Form.Get("description")
data.Bookmark.IsPrivate = r.Form.Get("is_private") == "1"
data.Bookmark.IsWorking = r.Form.Get("is_working") == "1"
data.Bookmark.Created = time.Now().UTC()

data.Tags = r.Form.Get("tags")
Expand Down
30 changes: 25 additions & 5 deletions handlers/public_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"

"github.com/go-chi/chi/v5"
"github.com/saaste/bookmark-manager/bookmarks"
)

func (h *Handler) HandleIndex(w http.ResponseWriter, r *http.Request) {
Expand All @@ -24,6 +25,15 @@ func (h *Handler) HandleIndex(w http.ResponseWriter, r *http.Request) {
return
}

brokenBookmarks := make([]*bookmarks.Bookmark, 0)
if isAuthenticated {
broken, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
}
brokenBookmarks = broken
}

title := "Recent Bookmarks"
if q != "" {
title = "Search Results"
Expand All @@ -40,6 +50,7 @@ func (h *Handler) HandleIndex(w http.ResponseWriter, r *http.Request) {
Tags: allTags,
TextFilter: q,
Pages: h.getPages(page, bookmarkResult.PageCount),
BrokenBookmarks: brokenBookmarks,
}

h.parseTemplateWithFunc("index.html", r, w, data)
Expand All @@ -62,18 +73,27 @@ func (h *Handler) HandleTags(w http.ResponseWriter, r *http.Request) {
return
}

brokenBookmarks := make([]*bookmarks.Bookmark, 0)
if isAuthenticated {
broken, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
}
brokenBookmarks = broken
}

data := templateData{
SiteName: h.appConf.SiteName,
Description: h.appConf.Description,
Title: fmt.Sprintf("Bookmarks With Tag: %s", tagsParam),
BaseURL: h.appConf.BaseURL,
CurrentURL: h.getCurrentURL(r, h.appConf),
IsAuthenticated: isAuthenticated,

Bookmarks: bookmarkResult.Bookmarks,
Tags: allTags,
TagFilter: tagsParam,
Pages: h.getPages(page, bookmarkResult.PageCount),
Bookmarks: bookmarkResult.Bookmarks,
Tags: allTags,
TagFilter: tagsParam,
Pages: h.getPages(page, bookmarkResult.PageCount),
BrokenBookmarks: brokenBookmarks,
}

h.parseTemplateWithFunc("index.html", r, w, data)
Expand Down
1 change: 1 addition & 0 deletions handlers/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type templateData struct {
TagFilter string
TextFilter string
Pages []Page
BrokenBookmarks []*bookmarks.Bookmark
}

type adminTemplateData struct {
Expand Down
Loading

0 comments on commit b148433

Please sign in to comment.