diff --git a/cmd/liber/db.go b/cmd/liber/db.go new file mode 100644 index 0000000000000000000000000000000000000000..5d301b9b6b204ddcadf482b649b6064534ad4c7b --- /dev/null +++ b/cmd/liber/db.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + + "github.com/google/subcommands" +) + +// Dump the database. +type dumpCommand struct{} + +func (c *dumpCommand) Name() string { return "dump-db" } +func (c *dumpCommand) Synopsis() string { return "Dump the database" } +func (c *dumpCommand) Usage() string { + return `dump-db + Dump the database. + + Requires exclusive access to the db, so the HTTP server must not be + running at the same time. + +` +} + +func (c *dumpCommand) SetFlags(f *flag.FlagSet) {} + +func (c *dumpCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + db := openDB() + defer db.Close() + + if err := db.Dump(os.Stdout); err != nil { + log.Printf("error: %v", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess +} + +// Restore the database. +type restoreCommand struct{} + +func (c *restoreCommand) Name() string { return "dump-db" } +func (c *restoreCommand) Synopsis() string { return "Dump the database" } +func (c *restoreCommand) Usage() string { + return `dump-db + Dump the database. + + Requires exclusive access to the db, so the HTTP server must not be + running at the same time. + +` +} + +func (c *restoreCommand) SetFlags(f *flag.FlagSet) {} + +func (c *restoreCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + db := openDB() + defer db.Close() + + if err := db.Restore(os.Stdin); err != nil { + log.Printf("error: %v", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess +} + +// Reindex the database. +type reindexCommand struct{} + +func (c *reindexCommand) Name() string { return "reindex" } +func (c *reindexCommand) Synopsis() string { return "Regenerate the full-text search index" } +func (c *reindexCommand) Usage() string { + return `reindex + Regenerate the full-text search index. + +` +} + +func (c *reindexCommand) SetFlags(f *flag.FlagSet) {} + +func (c *reindexCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + db := openDB() + defer db.Close() + + if err := db.Reindex(); err != nil { + log.Printf("error: %v", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess +} diff --git a/cmd/liber/dialog.go b/cmd/liber/dialog.go new file mode 100644 index 0000000000000000000000000000000000000000..d65af8329c75fadca645078cb5fa3a5beb57a641 --- /dev/null +++ b/cmd/liber/dialog.go @@ -0,0 +1,126 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log" + "os" + "os/exec" + "strconv" + "strings" + "sync" + + "git.autistici.org/ale/liber" +) + +// Various ways to ask a user to choose something. + +// Only make one user prompt at a time. +var promptMutex sync.Mutex + +// Prompt user using stdin. Kind of annoying because it interferes +// with logging on stderr. It is used as a fallback. +func promptUserStdin(path string, choices []*liber.Metadata) *liber.Metadata { + promptMutex.Lock() + defer promptMutex.Unlock() + + fmt.Printf("\n[*] Possible matches for %s:\n\n", path) + for idx, md := range choices { + fmt.Printf(" %d) %s\n", idx+1, md.String()) + } + prompt := "Pick one, or Enter to skip: " + + rdr := bufio.NewReader(os.Stdin) + for { + fmt.Printf(prompt) + os.Stdout.Sync() + result, err := rdr.ReadString('\n') + if err != nil || result == "\n" { + break + } + idx, err := strconv.Atoi(strings.TrimSpace(result)) + if err != nil { + fmt.Printf("%v\n", err) + continue + } + if idx < 1 || idx > len(choices) { + fmt.Printf("Insert a number between 1 and %d.\n", len(choices)) + continue + } + return choices[idx-1] + } + return nil +} + +func findProgram(progs []string) (string, error) { + for _, p := range progs { + if path, err := exec.LookPath(p); err == nil { + return path, nil + } + } + return "", errors.New("not found") +} + +var dialogProg string + +func findDialogProg() bool { + dialogProgs := []string{"whiptail", "dialog"} + if os.Getenv("DISPLAY") != "" { + dialogProgs = append([]string{"gdialog", "xdialog"}, dialogProgs...) + } + if p, err := findProgram(dialogProgs); err == nil { + dialogProg = p + } + if dialogProg == "" { + return false + } + return true +} + +// Prompt user using 'dialog', or a graphical variant if X11 is detected. +func promptUserDialog(path string, choices []*liber.Metadata) *liber.Metadata { + promptMutex.Lock() + defer promptMutex.Unlock() + + args := []string{ + "--title", "Metadata Chooser", + "--menu", fmt.Sprintf("Possible matches for %s:", path), + "0", "0", "0", + } + for idx, md := range choices { + args = append(args, strconv.Itoa(idx+1)) + args = append(args, md.String()) + } + var output bytes.Buffer + cmd := exec.Command(dialogProg, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = &output + if err := cmd.Run(); err != nil { + // If the user selects 'Cancel', dialog will exit with + // status 1. + log.Printf("dialog failed: %v", err) + return nil + } + result, err := strconv.Atoi(strings.TrimSpace(output.String())) + if err != nil { + return nil + } + return choices[result-1] +} + +func promptUser(noninteractive bool) func(string, []*liber.Metadata) *liber.Metadata { + if noninteractive { + return func(path string, choices []*liber.Metadata) *liber.Metadata { + return nil + } + } + + if findDialogProg() { + return promptUserDialog + } + + return promptUserStdin +} diff --git a/cmd/liber/liber.go b/cmd/liber/liber.go index 5e59652ae235760371c53f7dbcc18a9fb22b4d36..c5db157d740da7417587237e0e11ff03bb999b78 100644 --- a/cmd/liber/liber.go +++ b/cmd/liber/liber.go @@ -1,246 +1,45 @@ package main import ( - "bufio" - "bytes" - "errors" + "context" "flag" - "fmt" "log" "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" + + "github.com/google/subcommands" "git.autistici.org/ale/liber" "git.autistici.org/ale/liber/util" ) var ( - databaseDir = flag.String("db-dir", "~/.liber", "database directory") - bookDir = flag.String("book-dir", "", "books directory") - update = flag.Bool("update", false, "update the db") - search = flag.Bool("search", false, "search something") - remotesync = flag.String("sync", "", "push data to remote server") - reindex = flag.Bool("reindex", false, "re-create the search index") - httpserver = flag.String("http-server", "", "start the HTTP server on the specified address") - noninteractive = flag.Bool("noninteractive", false, "disable interactive metadata prompts on update") -) - -// Various ways to ask a user to choose something. - -// Prompt user using stdin. Kind of annoying because it interferes -// with logging on stderr. It is used as a fallback. -func promptUserStdin(path string, choices []*liber.Metadata) (*liber.Metadata, error) { - fmt.Printf("\n[*] Possible matches for %s:\n\n", path) - for idx, md := range choices { - fmt.Printf(" %d) %s\n", idx+1, md.String()) - } - prompt := "Pick one, or Enter to skip: " - - rdr := bufio.NewReader(os.Stdin) - for { - fmt.Printf(prompt) - os.Stdout.Sync() - result, err := rdr.ReadString('\n') - if err != nil || result == "\n" { - break - } - idx, err := strconv.Atoi(strings.TrimSpace(result)) - if err != nil { - fmt.Printf("%v\n", err) - continue - } - if idx < 1 || idx > len(choices) { - fmt.Printf("Insert a number between 1 and %d.\n", len(choices)) - continue - } - return choices[idx-1], nil - } - return nil, nil -} - -func findProgram(progs []string) (string, error) { - for _, p := range progs { - if path, err := exec.LookPath(p); err == nil { - return path, nil - } - } - return "", errors.New("not found") -} - -var ( - dialogProg string - dialogProgInit bool + databaseDir = flag.String("db-dir", "~/.liber", "database directory") + bookDir = flag.String("book-dir", "", "books directory") ) -func getDialogProg() (string, error) { - if !dialogProgInit { - dialogProgInit = true - dialogProgs := []string{"whiptail", "dialog"} - if os.Getenv("DISPLAY") != "" { - dialogProgs = append([]string{"gdialog", "xdialog"}, dialogProgs...) - } - if p, err := findProgram(dialogProgs); err == nil { - dialogProg = p - } - } - if dialogProg == "" { - return "", errors.New("not found") - } - return dialogProg, nil -} - -// Prompt user using 'dialog', or a graphical variant if X11 is detected. -func promptUserDialog(path string, choices []*liber.Metadata) (*liber.Metadata, error) { - dialog, err := getDialogProg() - if err != nil { - return nil, err - } - - args := []string{ - "--title", "Metadata Chooser", - "--menu", fmt.Sprintf("Possible matches for %s:", path), - "0", "0", "0", - } - for idx, md := range choices { - args = append(args, strconv.Itoa(idx+1)) - args = append(args, md.String()) - } - var output bytes.Buffer - cmd := exec.Command(dialog, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = &output - if err := cmd.Run(); err != nil { - // If the user selects 'Cancel', dialog will exit with - // status 1. - log.Printf("dialog failed: %v", err) - return nil, nil - } - result, err := strconv.Atoi(strings.TrimSpace(output.String())) - if err != nil { - return nil, nil - } - return choices[result-1], nil -} - -var promptMutex sync.Mutex - -func promptUser(path string, choices []*liber.Metadata) *liber.Metadata { - if *noninteractive { - return nil - } - - promptMutex.Lock() - defer promptMutex.Unlock() - - result, err := promptUserDialog(path, choices) - if err != nil { - result, err = promptUserStdin(path, choices) - if err != nil { - return nil - } - } - return result -} - -func doUpdate(db *liber.Database, dir string) { - db.Update(dir, promptUser) -} - -func doSync(db *liber.Database, remoteAddr string) { - storage := liber.NewFileStorage(util.ExpandTilde(*bookDir)) - sc := liber.NewRemoteServer(remoteAddr) - if err := db.Sync(storage, sc); err != nil { - log.Fatal(err) - } -} - -func doSearch(db *liber.Database, query string) { - results, err := db.Search(query, 0, 100) +func openDB() *liber.Database { + dbdir := util.ExpandTilde(*databaseDir) + db, err := liber.NewDb(dbdir) if err != nil { log.Fatal(err) } - if results.NumResults == 0 { - fmt.Printf("No results.\n") - } else { - fmt.Printf("%d results found:\n\n", results.NumResults) - for i, book := range results.Results { - fmt.Printf("%d) %s\n", i+1, book.Metadata.String()) - if files, err := db.GetBookFiles(book.Id); err == nil { - for _, f := range files { - fmt.Printf(" %s: %s\n", strings.TrimPrefix(f.FileType, "."), f.Path) - } - } - fmt.Printf("\n") - } - } -} - -func doHttpServer(db *liber.Database, addr string) { - storage := liber.NewRWFileStorage(util.ExpandTilde(*bookDir), 2) - cache := liber.NewRWFileStorage(filepath.Join(util.ExpandTilde(*databaseDir), "cache"), 2) - server := liber.NewHttpServer(db, storage, cache, addr) - log.Fatal(server.ListenAndServe()) -} - -func doReindex(db *liber.Database) { - log.Println("starting database indexing") - if err := db.Reindex(); err != nil { - log.Fatal(err) - } - log.Println("database indexing complete") -} - -func b2i(b bool) int { - if b { - return 1 - } - return 0 + return db } func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + subcommands.Register(&updateCommand{}, "") + subcommands.Register(&searchCommand{}, "") + subcommands.Register(&syncCommand{}, "") + subcommands.Register(&serverCommand{}, "") + subcommands.Register(&dumpCommand{}, "Maintenance") + subcommands.Register(&restoreCommand{}, "Maintenance") + subcommands.Register(&reindexCommand{}, "Maintenance") + log.SetFlags(0) flag.Parse() - nset := b2i(*update) + b2i(*search) + b2i(*httpserver != "") + b2i(*remotesync != "") + b2i(*reindex) - if nset != 1 { - log.Fatal("Must specify one of --update, --sync, --search, --reindex or --http-server") - } - if *bookDir == "" { - log.Fatal("Must specify --book-dir") - } - dbdir := util.ExpandTilde(*databaseDir) - db, err := liber.NewDb(dbdir) - if err != nil { - log.Fatal(err) - } - defer db.Close() - - if *update { - // Redirect logging to dbdir/update.log. - logf, err := os.OpenFile(filepath.Join(dbdir, "update.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) - if err == nil { - defer logf.Close() - log.SetOutput(logf) - log.SetFlags(log.Ldate | log.Ltime) - } - - doUpdate(db, util.ExpandTilde(*bookDir)) - } else if *remotesync != "" { - doSync(db, *remotesync) - } else if *search { - query := strings.Join(flag.Args(), " ") - if query == "" { - log.Fatal("No query specified") - } - doSearch(db, query) - } else if *reindex { - doReindex(db) - } else if *httpserver != "" { - doHttpServer(db, *httpserver) - } + os.Exit(int(subcommands.Execute(context.Background()))) } diff --git a/cmd/liber/search.go b/cmd/liber/search.go new file mode 100644 index 0000000000000000000000000000000000000000..dd0610369c1f473142bccda19412d98198af2314 --- /dev/null +++ b/cmd/liber/search.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + + "github.com/google/subcommands" +) + +type searchCommand struct{} + +func (c *searchCommand) SetFlags(f *flag.FlagSet) {} +func (c *searchCommand) Name() string { return "search" } +func (c *searchCommand) Synopsis() string { return "Search the database" } +func (c *searchCommand) Usage() string { + return `search <QUERY> + Search the local database. + +` +} + +func (c *searchCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + query := strings.Join(f.Args(), " ") + if query == "" { + log.Printf("Must specify a query") + return subcommands.ExitUsageError + } + + db := openDB() + defer db.Close() + + results, err := db.Search(query, 0, 100) + if err != nil { + log.Printf("error: %v", err) + return subcommands.ExitFailure + } + if results.NumResults == 0 { + fmt.Printf("No results.\n") + } else { + fmt.Printf("%d results found:\n\n", results.NumResults) + for i, book := range results.Results { + fmt.Printf("%d) %s\n", i+1, book.Metadata.String()) + if files, err := db.GetBookFiles(book.Id); err == nil { + for _, f := range files { + fmt.Printf(" %s: %s\n", strings.TrimPrefix(f.FileType, "."), f.Path) + } + } + fmt.Printf("\n") + } + } + + return subcommands.ExitSuccess +} diff --git a/cmd/liber/server.go b/cmd/liber/server.go new file mode 100644 index 0000000000000000000000000000000000000000..751655e4cd958766bac3d14ff5272cfe87bea2bc --- /dev/null +++ b/cmd/liber/server.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "flag" + "log" + "path/filepath" + + "github.com/google/subcommands" + + "git.autistici.org/ale/liber" + "git.autistici.org/ale/liber/util" +) + +type serverCommand struct { + addr string +} + +func (c *serverCommand) Name() string { return "server" } +func (c *serverCommand) Synopsis() string { return "Run the HTTP server" } +func (c *serverCommand) Usage() string { + return `server [<OPTIONS>] + Run the HTTP server. + +` +} + +func (c *serverCommand) SetFlags(f *flag.FlagSet) { + f.StringVar(&c.addr, "addr", ":3001", "address to listen on") +} + +func (c *serverCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + db := openDB() + defer db.Close() + + storage := liber.NewRWFileStorage(util.ExpandTilde(*bookDir), 2) + cache := liber.NewRWFileStorage(filepath.Join(util.ExpandTilde(*databaseDir), "cache"), 2) + server := liber.NewHttpServer(db, storage, cache, c.addr) + if err := server.ListenAndServe(); err != nil { + log.Printf("error: %v", err) + return subcommands.ExitFailure + } + + return subcommands.ExitSuccess +} diff --git a/cmd/liber/sync.go b/cmd/liber/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..5b29209a1477cf4900e2b7c8a1bca09799ea4a14 --- /dev/null +++ b/cmd/liber/sync.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/google/subcommands" + + "git.autistici.org/ale/liber" + "git.autistici.org/ale/liber/util" +) + +type syncCommand struct{} + +func (c *syncCommand) SetFlags(f *flag.FlagSet) {} +func (c *syncCommand) Name() string { return "sync" } +func (c *syncCommand) Synopsis() string { return "Synchronize with remote database" } +func (c *syncCommand) Usage() string { + return `sync <URL> + Push local content to a remote database. + +` +} + +func (c *syncCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if f.NArg() == 0 { + log.Printf("Must specify the remote URL") + return subcommands.ExitUsageError + } else if f.NArg() > 1 { + log.Printf("Too many arguments") + return subcommands.ExitUsageError + } + + db := openDB() + defer db.Close() + + storage := liber.NewFileStorage(util.ExpandTilde(*bookDir)) + sc := liber.NewRemoteServer(f.Arg(0)) + if err := db.Sync(storage, sc); err != nil { + log.Printf("sync failed: %v", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess +} diff --git a/cmd/liber/update.go b/cmd/liber/update.go new file mode 100644 index 0000000000000000000000000000000000000000..3125fa29d54740a8348fc6497ff8f2cd8403b58d --- /dev/null +++ b/cmd/liber/update.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "path/filepath" + + "github.com/google/subcommands" + + "git.autistici.org/ale/liber/util" +) + +type updateCommand struct { + noninteractive bool +} + +func (c *updateCommand) SetFlags(f *flag.FlagSet) { + f.BoolVar(&c.noninteractive, "noninteractive", false, "disable user prompts") +} + +func (c *updateCommand) Name() string { return "update" } +func (c *updateCommand) Synopsis() string { return "Add books to the local db" } +func (c *updateCommand) Usage() string { + return `update [<OPTIONS>] + Add books to the local database. + +` +} + +func (c *updateCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if f.NArg() > 0 { + log.Printf("Too many arguments") + return subcommands.ExitUsageError + } + + db := openDB() + defer db.Close() + + // Redirect logging to dbdir/update.log. + logf, err := os.OpenFile(filepath.Join(*databaseDir, "update.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + if err == nil { + defer logf.Close() + log.SetOutput(logf) + log.SetFlags(log.Ldate | log.Ltime) + } + + db.Update(util.ExpandTilde(*bookDir), promptUser(c.noninteractive)) + + return subcommands.ExitSuccess +} diff --git a/cmd/liberdbtool/liberdbtool.go b/cmd/liberdbtool/liberdbtool.go deleted file mode 100644 index d3ce5431f905ca309a97016d80527d06db6aaef4..0000000000000000000000000000000000000000 --- a/cmd/liberdbtool/liberdbtool.go +++ /dev/null @@ -1,124 +0,0 @@ -// Raw backup/restore tool for the 'liber' database. Mostly useful -// when upgrading the backend to an on-disk incompatible version. -// -package main - -import ( - "encoding/binary" - "flag" - "io" - "log" - "os" - - "git.autistici.org/ale/liber" - "git.autistici.org/ale/liber/util" -) - -var ( - databaseDir = flag.String("db-dir", "~/.liber", "database directory") - doDump = flag.Bool("dump", false, "dump the database") - doRestore = flag.Bool("restore", false, "restore the database") -) - -var buckets = [][]byte{ - liber.BookBucket, - liber.FileBucket, - liber.BookFileBucket, -} - -func writeBytes(w io.Writer, b []byte) error { - sz := uint64(len(b)) - if err := binary.Write(w, binary.BigEndian, sz); err != nil { - return err - } - _, err := w.Write(b) - return err -} - -func readBytes(r io.Reader) ([]byte, error) { - var sz uint64 - if err := binary.Read(r, binary.BigEndian, &sz); err != nil { - return nil, err - } - b := make([]byte, sz) - _, err := r.Read(b) - return b, err -} - -func writeRecord(w io.Writer, key, value []byte) error { - if err := writeBytes(w, key); err != nil { - return err - } - return writeBytes(w, value) -} - -func readRecord(r io.Reader) ([]byte, []byte, error) { - key, err := readBytes(r) - if err != nil { - return nil, nil, err - } - value, err := readBytes(r) - if err != nil { - return nil, nil, err - } - return key, value, nil -} - -func backup(db *liber.Database, w io.Writer) error { - for _, bkt := range buckets { - i := db.Scan(bkt) - for i.Next() { - if err := writeRecord(w, i.RawKey(), i.RawValue()); err != nil { - return err - } - } - if err := i.Close(); err != nil { - return err - } - } - return nil -} - -func restore(db *liber.Database, r io.Reader) error { - nrec := 0 - for { - key, value, err := readRecord(r) - if err == io.EOF { - break - } else if err != nil { - return err - } - if err := db.RawPut(key, value); err != nil { - return err - } - nrec++ - } - log.Printf("restored %d records, reindexing...", nrec) - if err := db.Reindex(); err != nil { - return err - } - log.Printf("done") - return nil -} - -func main() { - log.SetFlags(0) - flag.Parse() - - dbdir := util.ExpandTilde(*databaseDir) - db, err := liber.NewDb(dbdir) - if err != nil { - log.Fatal(err) - } - defer db.Close() - - switch { - case *doDump: - err = backup(db, os.Stdout) - case *doRestore: - err = restore(db, os.Stdin) - } - if err != nil { - log.Fatal(err) - } -} diff --git a/debian/rules b/debian/rules index d17d4bbfe89af097765707de3f59a32e3d788ac4..5b77ad03fafc8b70a7adae724a49bf6147bc2ccf 100755 --- a/debian/rules +++ b/debian/rules @@ -20,7 +20,7 @@ override_dh_install: install -m 755 -o root -g root -d $(PKGDIR)/usr/share/liber -mkdir build (export GOPATH=$(CURDIR)/build ; mkdir -p build/src/$(shell dirname $(DH_GOPKG)) ; ln -s $(CURDIR) build/src/$(DH_GOPKG) ; cd build/src/$(DH_GOPKG) && go install -v ./...) - (for f in liber liberdbtool ; do \ + (for f in liber ; do \ install -m 755 -o root -g root build/bin/$$f $(PKGDIR)/usr/bin/$$f ; done) (umask 022; cp -R --preserve=timestamps htdocs $(PKGDIR)/usr/share/liber/htdocs)