Use XBEL for bookmarks - #68

This commit is contained in:
makeworld 2021-02-27 00:13:11 -05:00
parent 3789580e6e
commit af5bd00bcb
6 changed files with 240 additions and 78 deletions

View File

@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- Favicon support removed (#199) - Favicon support removed (#199)
- Bookmarks are stored using XML in the XBEL format, old bookmarks are transferred (#68)
### Fixed ### Fixed
- Help text is now the same color as `regular_text` in the theme config - Help text is now the same color as `regular_text` in the theme config
- Non-ASCII (multibyte) characters can now be used as keybindings (#198, #200) - Non-ASCII (multibyte) characters can now be used as keybindings (#198, #200)
- Possible subscription update race condition on startup
## [1.8.0] - 2021-02-17 ## [1.8.0] - 2021-02-17

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/makeworld-the-better-one/amfora/bookmarks"
"github.com/makeworld-the-better-one/amfora/client" "github.com/makeworld-the-better-one/amfora/client"
"github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/display" "github.com/makeworld-the-better-one/amfora/display"
@ -44,13 +45,18 @@ func main() {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err) fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
client.Init()
err = subscriptions.Init() err = subscriptions.Init()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "subscriptions.json error: %v\n", err) fmt.Fprintf(os.Stderr, "subscriptions.json error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
err = bookmarks.Init()
client.Init() if err != nil {
fmt.Fprintf(os.Stderr, "bookmarks.xml error: %v\n", err)
os.Exit(1)
}
// Initialize lower-level cview app // Initialize lower-level cview app
if err = display.App.Init(); err != nil { if err = display.App.Init(); err != nil {

View File

@ -2,55 +2,88 @@ package bookmarks
import ( import (
"encoding/base32" "encoding/base32"
"encoding/xml"
"fmt"
"io/ioutil"
"os"
"sort" "sort"
"strings" "strings"
"github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/config"
) )
var bkmkStore = config.BkmkStore func Init() error {
f, err := os.Open(config.BkmkPath)
if err == nil {
// File exists and could be opened
// bkmkKey returns the viper key for the given bookmark URL. fi, err := f.Stat()
// Note that URLs are the keys, NOT the bookmark name. if err == nil && fi.Size() > 0 {
func bkmkKey(url string) string { // File is not empty
// Keys are base32 encoded URLs to prevent any special chars like periods from being used
return "bookmarks." + base32.StdEncoding.EncodeToString([]byte(url)) xbelBytes, err := ioutil.ReadAll(f)
f.Close()
if err != nil {
return fmt.Errorf("read bookmarks.xml error: %w", err)
}
err = xml.Unmarshal(xbelBytes, &data)
if err != nil {
return fmt.Errorf("bookmarks.xml is corrupted: %w", err)
}
}
f.Close()
} else if !os.IsNotExist(err) {
// There's an error opening the file, but it's not bc is doesn't exist
return fmt.Errorf("open bookmarks.xml error: %w", err)
} }
func Set(url, name string) { if data.Bookmarks == nil {
bkmkStore.Set(bkmkKey(url), name) data.Bookmarks = make([]*xbelBookmark, 0)
bkmkStore.WriteConfig() //nolint:errcheck data.Version = xbelVersion
} }
// Get returns the NAME of the bookmark, given the URL. if config.BkmkStore != nil {
// It also returns a bool indicating whether it exists. // There's still bookmarks stored in the old format
func Get(url string) (string, bool) { // Add them and delete the file
name := bkmkStore.GetString(bkmkKey(url))
return name, name != "" names, urls := oldBookmarks()
for i := range names {
data.Bookmarks = append(data.Bookmarks, &xbelBookmark{
URL: urls[i],
Name: names[i],
})
} }
func Remove(url string) { err := writeXbel()
// XXX: Viper can't actually delete keys, which means the bookmarks file might get clouded if err != nil {
// with non-entries over time. return fmt.Errorf("error saving old bookmarks into new format: %w", err)
bkmkStore.Set(bkmkKey(url), "")
bkmkStore.WriteConfig() //nolint:errcheck
} }
// All returns all the bookmarks in a map of URLs to names. err = os.Remove(config.OldBkmkPath)
// It also returns a slice of map keys, sorted so that the map *values* if err != nil {
// are in alphabetical order, with case ignored. return fmt.Errorf(
func All() (map[string]string, []string) { "couldn't delete old bookmarks file (%s), you must delete it yourself to prevent duplicate bookmarks: %w",
bkmks := make(map[string]string) config.OldBkmkPath,
err,
)
}
config.BkmkStore = nil
}
bkmksMap, ok := bkmkStore.AllSettings()["bookmarks"].(map[string]interface{}) return nil
}
// oldBookmarks returns a slice of names and a slice of URLs of the
// bookmarks in config.BkmkStore.
func oldBookmarks() ([]string, []string) {
bkmksMap, ok := config.BkmkStore.AllSettings()["bookmarks"].(map[string]interface{})
if !ok { if !ok {
// No bookmarks stored yet, return empty map // No bookmarks stored yet, return empty map
return bkmks, []string{} return []string{}, []string{}
} }
inverted := make(map[string]string) // Holds inverted map, name->URL names := make([]string, 0, len(bkmksMap))
names := make([]string, 0, len(bkmksMap)) // Holds bookmark names, for sorting urls := make([]string, 0, len(bkmksMap))
keys := make([]string, 0, len(bkmksMap)) // Final sorted keys (URLs), for returning at the end
for b32Url, name := range bkmksMap { for b32Url, name := range bkmksMap {
if n, ok := name.(string); n == "" || !ok { if n, ok := name.(string); n == "" || !ok {
@ -63,15 +96,89 @@ func All() (map[string]string, []string) {
// This would only happen if a user messed around with the bookmarks file // This would only happen if a user messed around with the bookmarks file
continue continue
} }
bkmks[string(url)] = name.(string)
inverted[name.(string)] = string(url)
names = append(names, name.(string)) names = append(names, name.(string))
urls = append(urls, string(url))
} }
// Sort, then turn back into URL keys return names, urls
sort.Strings(names)
for _, name := range names {
keys = append(keys, inverted[name])
} }
return bkmks, keys func writeXbel() error {
xbelBytes, err := xml.MarshalIndent(&data, "", " ")
if err != nil {
return err
}
xbelBytes = append(xbelHeader, xbelBytes...)
err = ioutil.WriteFile(config.BkmkPath, xbelBytes, 0666)
if err != nil {
return err
}
return nil
}
// Change the name of the bookmark at the provided URL.
func Change(url, name string) {
for _, bkmk := range data.Bookmarks {
if bkmk.URL == url {
bkmk.Name = name
writeXbel() //nolint:errcheck
return
}
}
}
// Add will add a new bookmark.
func Add(url, name string) {
data.Bookmarks = append(data.Bookmarks, &xbelBookmark{
URL: url,
Name: name,
})
writeXbel() //nolint:errcheck
}
// Get returns the NAME of the bookmark, given the URL.
// It also returns a bool indicating whether it exists.
func Get(url string) (string, bool) {
for _, bkmk := range data.Bookmarks {
if bkmk.URL == url {
return bkmk.Name, true
}
}
return "", false
}
func Remove(url string) {
for i, bkmk := range data.Bookmarks {
if bkmk.URL == url {
data.Bookmarks[i] = data.Bookmarks[len(data.Bookmarks)-1]
data.Bookmarks = data.Bookmarks[:len(data.Bookmarks)-1]
writeXbel() //nolint:errcheck
return
}
}
}
// All returns all the bookmarks in a map of URLs to names.
// It also returns a slice of map keys, sorted so that the map *values*
// are in alphabetical order, with case ignored.
func All() (map[string]string, []string) {
bkmksMap := make(map[string]string)
inverted := make(map[string]string) // Holds inverted map, name->URL
names := make([]string, len(data.Bookmarks)) // Holds bookmark names, for sorting
keys := make([]string, len(data.Bookmarks)) // Final sorted keys (URLs), for returning at the end
for i, bkmk := range data.Bookmarks {
bkmksMap[bkmk.URL] = bkmk.Name
inverted[bkmk.Name] = bkmk.URL
names[i] = bkmk.Name
}
// Sort, then turn back into URL keys
sort.Strings(names)
for i, name := range names {
keys[i] = inverted[name]
}
return bkmksMap, keys
} }

43
bookmarks/xbel.go Normal file
View File

@ -0,0 +1,43 @@
package bookmarks
// Structs and code for the XBEL XML bookmark format.
// https://github.com/makeworld-the-better-one/amfora/issues/68
import (
"encoding/xml"
)
var xbelHeader = []byte(xml.Header + `<!DOCTYPE xbel
PUBLIC "+//IDN python.org//DTD XML Bookmark Exchange Language 1.1//EN//XML"
"http://www.python.org/topics/xml/dtds/xbel-1.1.dtd">
`)
const xbelVersion = "1.1"
type xbelBookmark struct {
XMLName xml.Name `xml:"bookmark"`
URL string `xml:"href,attr"`
Name string `xml:"title"`
}
// xbelFolder is unused as folders aren't supported by the UI yet.
// Follow #56 for details.
// https://github.com/makeworld-the-better-one/amfora/issues/56
type xbelFolder struct {
XMLName xml.Name `xml:"folder"`
Version string `xml:"version,attr"`
Folded string `xml:"folded,attr"` // Idk if this will be used or not
Name string `xml:"title"`
Bookmarks []*xbelBookmark `xml:"bookmark"`
Folders []*xbelFolder `xml:"folder"`
}
type xbel struct {
XMLName xml.Name `xml:"xbel"`
Version string `xml:"version,attr"`
Bookmarks []*xbelBookmark `xml:"bookmark"`
// Later: Folders []*xbelFolder
}
// Instance of xbel - loaded from bookmarks file
var data xbel

View File

@ -32,10 +32,10 @@ var tofuDBDir string
var tofuDBPath string var tofuDBPath string
// Bookmarks // Bookmarks
var BkmkStore = viper.New() // TOML API for old bookmarks file
var BkmkStore = viper.New()
var bkmkDir string var bkmkDir string
var bkmkPath string var OldBkmkPath string // Old bookmarks file that used TOML format
var BkmkPath string // New XBEL (XML) bookmarks file, see #68
var DownloadsDir string var DownloadsDir string
var TempDownloadsDir string var TempDownloadsDir string
@ -111,7 +111,8 @@ func Init() error {
// XDG data dir on POSIX systems // XDG data dir on POSIX systems
bkmkDir = filepath.Join(basedir.DataHome, "amfora") bkmkDir = filepath.Join(basedir.DataHome, "amfora")
} }
bkmkPath = filepath.Join(bkmkDir, "bookmarks.toml") OldBkmkPath = filepath.Join(bkmkDir, "bookmarks.toml")
BkmkPath = filepath.Join(bkmkDir, "bookmarks.xml")
// Feeds dir and path // Feeds dir and path
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@ -160,10 +161,8 @@ func Init() error {
if err != nil { if err != nil {
return err return err
} }
f, err = os.OpenFile(bkmkPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) // OldBkmkPath isn't created because it shouldn't be there anyway
if err == nil {
f.Close()
}
// Feeds // Feeds
err = os.MkdirAll(subscriptionDir, 0755) err = os.MkdirAll(subscriptionDir, 0755)
if err != nil { if err != nil {
@ -179,16 +178,12 @@ func Init() error {
return err return err
} }
BkmkStore.SetConfigFile(bkmkPath) BkmkStore.SetConfigFile(OldBkmkPath)
BkmkStore.SetConfigType("toml") BkmkStore.SetConfigType("toml")
err = BkmkStore.ReadInConfig() err = BkmkStore.ReadInConfig()
if err != nil { if err != nil {
return err // File doesn't exist, so remove the viper
} BkmkStore = nil
BkmkStore.Set("DO NOT TOUCH", true)
err = BkmkStore.WriteConfig()
if err != nil {
return err
} }
// Setup main config // Setup main config

View File

@ -15,8 +15,17 @@ import (
// For adding and removing bookmarks, basically a clone of the input modal. // For adding and removing bookmarks, basically a clone of the input modal.
var bkmkModal = cview.NewModal() var bkmkModal = cview.NewModal()
type bkmkAction int
const (
add bkmkAction = iota
change
cancel
remove
)
// bkmkCh is for the user action // bkmkCh is for the user action
var bkmkCh = make(chan int) // 1, 0, -1 for add/update, cancel, and remove var bkmkCh = make(chan bkmkAction)
var bkmkModalText string // The current text of the input field in the modal var bkmkModalText string // The current text of the input field in the modal
func bkmkInit() { func bkmkInit() {
@ -60,15 +69,15 @@ func bkmkInit() {
m.SetDoneFunc(func(buttonIndex int, buttonLabel string) { m.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
switch buttonLabel { switch buttonLabel {
case "Add": case "Add":
bkmkCh <- 1 bkmkCh <- add
case "Change": case "Change":
bkmkCh <- 1 bkmkCh <- change
case "Remove": case "Remove":
bkmkCh <- -1 bkmkCh <- remove
case "Cancel": case "Cancel":
bkmkCh <- 0 bkmkCh <- cancel
case "": case "":
bkmkCh <- 0 bkmkCh <- cancel
} }
}) })
} }
@ -76,9 +85,8 @@ func bkmkInit() {
// Bkmk displays the "Add a bookmark" modal. // Bkmk displays the "Add a bookmark" modal.
// It accepts the default value for the bookmark name that will be displayed, but can be changed by the user. // It accepts the default value for the bookmark name that will be displayed, but can be changed by the user.
// It also accepts a bool indicating whether this page already has a bookmark. // It also accepts a bool indicating whether this page already has a bookmark.
// It returns the bookmark name and the bookmark action: // It returns the bookmark name and the bookmark action.
// 1, 0, -1 for add/update, cancel, and remove func openBkmkModal(name string, exists bool) (string, bkmkAction) {
func openBkmkModal(name string, exists bool) (string, int) {
// Basically a copy of Input() // Basically a copy of Input()
// Reset buttons before input field, to make sure the input is in focus // Reset buttons before input field, to make sure the input is in focus
@ -152,11 +160,12 @@ func addBookmark() {
// Open a bookmark modal with the current name of the bookmark, if it exists // Open a bookmark modal with the current name of the bookmark, if it exists
newName, action := openBkmkModal(name, exists) newName, action := openBkmkModal(name, exists)
switch action { switch action {
case 1: case add:
// Add/change the bookmark bookmarks.Add(p.URL, newName)
bookmarks.Set(p.URL, newName) case change:
case -1: bookmarks.Change(p.URL, newName)
case remove:
bookmarks.Remove(p.URL) bookmarks.Remove(p.URL)
} }
// Other case is action = 0, meaning "Cancel", so nothing needs to happen // Other case is action == cancel, so nothing needs to happen
} }