diff --git a/app/attr.go b/app/attr.go new file mode 100644 index 0000000..52bd441 --- /dev/null +++ b/app/attr.go @@ -0,0 +1,91 @@ +package app + +import ( + "fmt" + "io" + "os" + "unicode/utf8" +) + +type DbAttr struct { + Key string + Val string + Bin []byte + Args []string + Tags []string + File string +} + +func (attr *DbAttr) ParseKV() error { + switch len(attr.Args) { + case 1: + // 1 arg = key + read from file or stdin + attr.Key = attr.Args[0] + if attr.File == "" { + attr.File = "-" + } + case 2: + attr.Key = attr.Args[0] + attr.Val = attr.Args[1] + + if attr.Args[1] == "-" { + attr.File = "-" + } + } + + if attr.File != "" { + return attr.GetFileValue() + } + + return nil +} + +func (attr *DbAttr) GetFileValue() error { + var fd io.Reader + + if attr.File == "-" { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + fd = os.Stdin + } + } else { + filehandle, err := os.OpenFile(attr.File, os.O_RDONLY, 0600) + if err != nil { + return err + } + + fd = filehandle + } + + if fd != nil { + // read from file or stdin pipe + data, err := io.ReadAll(fd) + if err != nil { + return err + } + + // poor man's text file test + sdata := string(data) + if utf8.ValidString(sdata) { + attr.Val = sdata + } else { + attr.Bin = data + } + } else { + // read from console stdin + var input string + var data string + + for { + _, err := fmt.Scanln(&input) + if err != nil { + break + } + data += input + "\n" + } + + attr.Val = data + } + + return nil +} diff --git a/app/db.go b/app/db.go index 946f3cd..2d26928 100644 --- a/app/db.go +++ b/app/db.go @@ -1,59 +1,206 @@ package app import ( + "encoding/json" + "fmt" "os" "path/filepath" + "regexp" + "slices" + "strings" "time" - "github.com/asdine/storm/v3" + bolt "go.etcd.io/bbolt" ) type DB struct { - Debug bool - DB *storm.DB -} - -type DbAttr struct { - Key string - Args []string - Tags []string - File string + Debug bool + Dbfile string + DB *bolt.DB } type DbEntry struct { - ID int `storm:"id,increment"` - Key string `storm:"unique"` - Value string `storm:"index"` // FIXME: turn info []byte or add blob? - Tags []string `storm:"index"` - CreatedAt time.Time `storm:"index"` + Id string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Bin []byte `json:"bin"` + Tags []string `json:"tags"` + Created time.Time `json:"created"` } +type DbEntries []DbEntry + +type DbTag struct { + Keys []string `json:"key"` +} + +const BucketData string = "data" +const BucketTags string = "tags" + func New(file string, debug bool) (*DB, error) { if _, err := os.Stat(filepath.Dir(file)); os.IsNotExist(err) { os.MkdirAll(filepath.Dir(file), 0700) } - db, err := storm.Open(file) - if err != nil { - return nil, err - } - // FIXME: defer db.Close() here leads to: Error: database not open + return &DB{Debug: debug, Dbfile: file}, nil +} - return &DB{Debug: debug, DB: db}, nil +func (db *DB) Open() error { + b, err := bolt.Open(db.Dbfile, 0600, nil) + if err != nil { + return err + } + + db.DB = b + return nil } func (db *DB) Close() error { return db.DB.Close() } -func (db *DB) Set(attr *DbAttr) error { - entry := DbEntry{Key: attr.Key, Tags: attr.Tags} +func (db *DB) List(attr *DbAttr) (DbEntries, error) { + if err := db.Open(); err != nil { + return nil, err + } + defer db.Close() + + var entries DbEntries + var filter *regexp.Regexp if len(attr.Args) > 0 { - entry.Value = attr.Args[0] + filter = regexp.MustCompile(attr.Args[0]) } - // FIXME: check attr.File or STDIN + err := db.DB.View(func(tx *bolt.Tx) error { - return db.DB.Save(&entry) + bucket := tx.Bucket([]byte(BucketData)) + if bucket == nil { + return nil + } + + err := bucket.ForEach(func(key, jsonentry []byte) error { + var entry DbEntry + if err := json.Unmarshal(jsonentry, &entry); err != nil { + return fmt.Errorf("unable to unmarshal json: %s", err) + } + + var include bool + + switch { + case filter != nil: + if filter.MatchString(entry.Value) || + filter.MatchString(entry.Key) || + filter.MatchString(strings.Join(entry.Tags, " ")) { + include = true + } + case len(attr.Tags) > 0: + for _, search := range attr.Tags { + for _, tag := range entry.Tags { + if tag == search { + include = true + break + } + } + + if include { + break + } + } + default: + include = true + } + + if include { + entries = append(entries, entry) + } + + return nil + }) + + return err + }) + return entries, err +} + +func (db *DB) Set(attr *DbAttr) error { + if err := db.Open(); err != nil { + return err + } + defer db.Close() + + if err := attr.ParseKV(); err != nil { + return err + } + + entry := DbEntry{ + Key: attr.Key, + Value: attr.Val, + Bin: attr.Bin, + Tags: attr.Tags, + Created: time.Now(), + } + + err := db.DB.Update(func(tx *bolt.Tx) error { + // insert data + bucket, err := tx.CreateBucketIfNotExists([]byte(BucketData)) + if err != nil { + return fmt.Errorf("create bucket: %s", err) + } + + jsonentry, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("json marshalling failure: %s", err) + } + + err = bucket.Put([]byte(entry.Key), []byte(jsonentry)) + if err != nil { + return fmt.Errorf("insert data: %s", err) + } + + // insert tag, if any + // FIXME: check removed tags + if len(attr.Tags) > 0 { + bucket, err := tx.CreateBucketIfNotExists([]byte(BucketTags)) + if err != nil { + return fmt.Errorf("create bucket: %s", err) + } + + for _, tag := range entry.Tags { + dbtag := &DbTag{} + + jsontag := bucket.Get([]byte(tag)) + if jsontag == nil { + // the tag is empty so far, initialize it + dbtag.Keys = []string{entry.Key} + } else { + if err := json.Unmarshal(jsontag, dbtag); err != nil { + return fmt.Errorf("unable to unmarshal json: %s", err) + } + + if !slices.Contains(dbtag.Keys, entry.Key) { + // current key is not yet assigned to the tag, append it + dbtag.Keys = append(dbtag.Keys, entry.Key) + } + } + + jsontag, err = json.Marshal(dbtag) + if err != nil { + return fmt.Errorf("json marshalling failure: %s", err) + } + + err = bucket.Put([]byte(tag), []byte(jsontag)) + if err != nil { + return fmt.Errorf("insert data: %s", err) + } + } + } + + return nil + }) + if err != nil { + return err + } + + return nil } diff --git a/app/generic.go b/app/generic.go new file mode 100644 index 0000000..c83311d --- /dev/null +++ b/app/generic.go @@ -0,0 +1,10 @@ +package app + +// look if a key in a map exists, generic variant +func Exists[K comparable, V any](m map[K]V, v K) bool { + if _, ok := m[v]; ok { + return true + } + + return false +} diff --git a/cfg/config.go b/cfg/config.go index 8070119..101e873 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -7,6 +7,7 @@ var Version string = "v0.0.1" type Config struct { Debug bool Dbfile string + Mode string // wide, table, yaml, json DB *app.DB File string Tags []string diff --git a/cmd/maincommands.go b/cmd/maincommands.go index 0d7c99c..acd315f 100644 --- a/cmd/maincommands.go +++ b/cmd/maincommands.go @@ -2,10 +2,12 @@ package cmd import ( "errors" + "os" "github.com/spf13/cobra" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" + "github.com/tlinden/anydb/output" ) func Set(conf *cfg.Config) *cobra.Command { @@ -52,7 +54,37 @@ func Del(conf *cfg.Config) *cobra.Command { } func List(conf *cfg.Config) *cobra.Command { - return nil + var ( + attr app.DbAttr + ) + + var cmd = &cobra.Command{ + Use: "list [-t ] [-o ] []", + Short: "List database contents", + Long: `List database contents`, + RunE: func(cmd *cobra.Command, args []string) error { + // errors at this stage do not cause the usage to be shown + cmd.SilenceUsage = true + + if len(args) > 0 { + attr.Args = args + } + + entries, err := conf.DB.List(&attr) + if err != nil { + return err + } + + output.List(os.Stdout, conf, entries) + + return conf.DB.Close() + }, + } + + cmd.PersistentFlags().StringVarP(&conf.Mode, "output-mode", "o", "", "output mode: wide, yaml, json, table") + cmd.PersistentFlags().StringArrayVarP(&attr.Tags, "tags", "t", nil, "tags, multiple allowed") + + return cmd } func Find(conf *cfg.Config) *cobra.Command { diff --git a/cmd/root.go b/cmd/root.go index e284047..d765352 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -66,20 +66,26 @@ func Execute() { }, } + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + // options rootCmd.PersistentFlags().BoolVarP(&ShowVersion, "version", "v", false, "Print program version") rootCmd.PersistentFlags().BoolVarP(&conf.Debug, "debug", "d", false, "Enable debugging") - rootCmd.PersistentFlags().StringVarP(&conf.Dbfile, "dbfile", "f", filepath.Join(os.Getenv("HOME"), ".config", "anydb", "default.db"), "DB file to use") + rootCmd.PersistentFlags().StringVarP(&conf.Dbfile, "dbfile", "f", + filepath.Join(home, ".config", "anydb", "default.db"), "DB file to use") rootCmd.AddCommand(Set(&conf)) + rootCmd.AddCommand(List(&conf)) // rootCmd.AddCommand(Set(&conf)) // rootCmd.AddCommand(Del(&conf)) - // rootCmd.AddCommand(List(&conf)) // rootCmd.AddCommand(Find(&conf)) // rootCmd.AddCommand(Help(&conf)) // rootCmd.AddCommand(Man(&conf)) - err := rootCmd.Execute() + err = rootCmd.Execute() if err != nil { os.Exit(1) } diff --git a/go.mod b/go.mod index 8279fb3..03d447b 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.22.1 require ( github.com/alecthomas/repr v0.4.0 // indirect - github.com/asdine/storm/v3 v3.2.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect go.etcd.io/bbolt v1.3.11 // indirect diff --git a/go.sum b/go.sum index ef56660..d230ada 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,10 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= diff --git a/output/default.go b/output/default.go new file mode 100644 index 0000000..c5f7092 --- /dev/null +++ b/output/default.go @@ -0,0 +1,58 @@ +package output + +import ( + "fmt" + "io" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/tlinden/anydb/app" + "github.com/tlinden/anydb/cfg" +) + +func List(writer io.Writer, conf *cfg.Config, entries app.DbEntries) error { + // FIXME: call sort here + // FIXME: check output mode switch to subs + + tableString := &strings.Builder{} + table := tablewriter.NewWriter(tableString) + + if conf.Mode == "wide" { + table.SetHeader([]string{"KEY", "VALUE", "TAGS", "TIMESTAMP"}) + } else { + table.SetHeader([]string{"KEY", "VALUE"}) + } + + for _, row := range entries { + if row.Value == "" { + row.Value = string(row.Bin)[0:60] + } else if len(row.Value) > 60 { + row.Value = row.Value[0:60] + } + + if conf.Mode == "wide" { + table.Append([]string{row.Key, row.Value, strings.Join(row.Tags, ","), row.Created.Format("02.01.2006T03:04.05")}) + } else { + table.Append([]string{row.Key, row.Value}) + } + } + + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetNoWhiteSpace(true) + + table.SetTablePadding("\t") // pad with tabs + + table.Render() + + fmt.Fprint(writer, tableString.String()) + + return nil +}