mirror of
https://github.com/makeworld-the-better-one/amfora.git
synced 2024-11-29 03:52:51 +03:00
✨ Use XBEL for bookmarks - #68
This commit is contained in:
parent
3789580e6e
commit
af5bd00bcb
@ -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
|
||||||
|
10
amfora.go
10
amfora.go
@ -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 {
|
||||||
|
@ -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
43
bookmarks/xbel.go
Normal 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
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user