src

Go monorepo.
git clone git://code.dwrz.net/src
Log | Files | Refs

wisdom.go (7078B)


      1 package wisdom
      2 
      3 import (
      4 	"bufio"
      5 	"encoding/json"
      6 	"fmt"
      7 	"io"
      8 	"os"
      9 	"os/exec"
     10 	"path/filepath"
     11 	"strings"
     12 	"unicode"
     13 
     14 	"github.com/google/uuid"
     15 	"github.com/mattn/go-runewidth"
     16 
     17 	"code.dwrz.net/src/pkg/log"
     18 	"code.dwrz.net/src/pkg/store"
     19 	"code.dwrz.net/src/pkg/terminal"
     20 	"code.dwrz.net/src/pkg/text"
     21 	"code.dwrz.net/src/pkg/wisdom/quote"
     22 )
     23 
     24 const coll = "data"
     25 
     26 type Parameters struct {
     27 	Log  *log.Logger
     28 	Path string
     29 	Size terminal.Size
     30 }
     31 
     32 type Wisdom struct {
     33 	log   *log.Logger
     34 	store *store.Store
     35 	size  terminal.Size
     36 }
     37 
     38 func New(p Parameters) (*Wisdom, error) {
     39 	store, err := store.New(store.Parameters{
     40 		Log:  p.Log,
     41 		Path: p.Path,
     42 	})
     43 	if err != nil {
     44 		return nil, fmt.Errorf("failed to create store: %v", err)
     45 	}
     46 
     47 	if err := store.NewCollection(coll); err != nil {
     48 		return nil, fmt.Errorf("failed to create collection: %v", err)
     49 	}
     50 
     51 	return &Wisdom{
     52 		log:   p.Log,
     53 		store: store,
     54 		size:  p.Size,
     55 	}, nil
     56 }
     57 
     58 func (w *Wisdom) Command(args []string) error {
     59 	// Show a random quote by default.
     60 	if len(args) == 0 {
     61 		d, err := w.store.Collection(coll).Random()
     62 		if err != nil {
     63 			return fmt.Errorf("failed to load quote: %v", err)
     64 		}
     65 
     66 		var q quote.Quote
     67 		if err := d.Unmarshal(&q); err != nil {
     68 			return fmt.Errorf(
     69 				"failed to unmarshal document: %v", err,
     70 			)
     71 		}
     72 
     73 		fmt.Println(q.Render())
     74 
     75 		return nil
     76 	}
     77 
     78 	command, rest := args[0], args[1:]
     79 	switch command {
     80 	case "add":
     81 		if err := w.add(); err != nil {
     82 			return fmt.Errorf("failed to add quote: %v", err)
     83 		}
     84 
     85 	case "edit":
     86 		if err := w.edit(rest); err != nil {
     87 			return fmt.Errorf("failed to edit quote: %v", err)
     88 		}
     89 
     90 	case "show":
     91 		if err := w.show(rest); err != nil {
     92 			return fmt.Errorf("failed to show quote: %v", err)
     93 		}
     94 
     95 	case "list":
     96 		if err := w.list(); err != nil {
     97 			return fmt.Errorf("failed to list quotes: %v", err)
     98 		}
     99 
    100 	case "remove":
    101 		if err := w.remove(rest); err != nil {
    102 			return fmt.Errorf("failed to remove quote: %v", err)
    103 		}
    104 
    105 	default:
    106 		return fmt.Errorf("unrecognized command: %v", command)
    107 	}
    108 
    109 	return nil
    110 }
    111 
    112 func (w *Wisdom) add() error {
    113 	var (
    114 		in  = bufio.NewReader(os.Stdin)
    115 		str strings.Builder
    116 		q   = quote.Quote{
    117 			Tags: map[string]struct{}{},
    118 		}
    119 	)
    120 
    121 	// Text
    122 	fmt.Print("Text (Ctrl-D for EOF):")
    123 	if _, err := io.Copy(&str, os.Stdin); err != nil {
    124 		if err != io.EOF {
    125 			return fmt.Errorf("failed to read: %v", err)
    126 		}
    127 	}
    128 	q.Text = strings.TrimRightFunc(str.String(), unicode.IsSpace)
    129 	if q.Text == "" {
    130 		return fmt.Errorf("missing text")
    131 	}
    132 	str.Reset()
    133 
    134 	// Author
    135 	fmt.Print("Author: ")
    136 	line, err := in.ReadString('\n')
    137 	if err != nil {
    138 		return fmt.Errorf("failed to read: %v", err)
    139 	}
    140 	q.Author = strings.TrimSpace(line)
    141 
    142 	// Source
    143 	fmt.Print("Source: ")
    144 	line, err = in.ReadString('\n')
    145 	if err != nil {
    146 		return fmt.Errorf("failed to read: %v", err)
    147 	}
    148 	q.Source = strings.TrimSpace(line)
    149 
    150 	// Tags
    151 	fmt.Print("Tags (comma delimited): ")
    152 	line, err = in.ReadString('\n')
    153 	if err != nil {
    154 		return fmt.Errorf("failed to read: %v", err)
    155 	}
    156 	for _, t := range strings.Split(strings.TrimSpace(line), ",") {
    157 		if t == "" {
    158 			continue
    159 		}
    160 		q.Tags[t] = struct{}{}
    161 	}
    162 
    163 	// Comment
    164 	fmt.Print("Comment (Ctrl-D for EOF): ")
    165 	if _, err := io.Copy(&str, os.Stdin); err != nil {
    166 		if err != io.EOF {
    167 			return fmt.Errorf("failed to read: %v", err)
    168 		}
    169 	}
    170 	q.Comment = strings.TrimSpace(str.String())
    171 	str.Reset()
    172 
    173 	if _, err := w.store.Collection(coll).Create(
    174 		uuid.NewString(), q,
    175 	); err != nil {
    176 		return fmt.Errorf("failed to create: %v", err)
    177 	}
    178 
    179 	return nil
    180 }
    181 
    182 func (w *Wisdom) edit(args []string) error {
    183 	if len(args) == 0 {
    184 		return fmt.Errorf("missing id")
    185 	}
    186 
    187 	editor := strings.Split(os.Getenv("EDITOR"), " ")
    188 	if len(editor) == 0 {
    189 		return fmt.Errorf("missing $EDITOR")
    190 	}
    191 
    192 	path, err := exec.LookPath(editor[0])
    193 	if err != nil {
    194 		return fmt.Errorf(
    195 			"failed to find $EDITOR %s: %w", editor, err,
    196 		)
    197 	}
    198 
    199 	for _, id := range args {
    200 		d, err := w.store.Collection(coll).FindId(id)
    201 		if err != nil {
    202 			return fmt.Errorf("failed to load quote: %v", err)
    203 		}
    204 
    205 		temp := filepath.Join(os.TempDir(), "wisdom", id)
    206 		if err := os.WriteFile(temp, d.Data, 0600); err != nil {
    207 			return err
    208 		}
    209 
    210 		cmd := exec.Cmd{
    211 			Path:   path,
    212 			Args:   append(editor, temp),
    213 			Stdin:  os.Stdin,
    214 			Stdout: os.Stdout,
    215 			Stderr: os.Stderr,
    216 		}
    217 		if err := cmd.Run(); err != nil {
    218 			return err
    219 		}
    220 
    221 		data, err := os.ReadFile(temp)
    222 		if err != nil {
    223 			return fmt.Errorf(
    224 				"failed to read file %s: %w", temp, err,
    225 			)
    226 		}
    227 
    228 		var q = &quote.Quote{}
    229 		if err := json.Unmarshal(data, q); err != nil {
    230 			return fmt.Errorf(
    231 				"failed to json unmarshal %s: %w", temp, err,
    232 			)
    233 		}
    234 
    235 		if err := os.Remove(temp); err != nil {
    236 			return fmt.Errorf("failed to remove temp file: %v", err)
    237 		}
    238 
    239 		d, err = w.store.Collection(coll).Create(uuid.NewString(), q)
    240 		if err != nil {
    241 			return fmt.Errorf(
    242 				"failed to create new document: %v", err,
    243 			)
    244 		}
    245 
    246 		if err := w.store.Collection(coll).Delete(id); err != nil {
    247 			return fmt.Errorf(
    248 				"failed to delete original document: %v", err,
    249 			)
    250 		}
    251 	}
    252 
    253 	return nil
    254 }
    255 
    256 func (w *Wisdom) list() error {
    257 	docs, err := w.store.Collection(coll).All()
    258 	if err != nil {
    259 		return fmt.Errorf("failed to load quotes: %v", err)
    260 	}
    261 
    262 	var output strings.Builder
    263 	output.WriteString("id,author,source,text\n")
    264 	for _, d := range docs {
    265 		var q = &quote.Quote{}
    266 
    267 		if err := d.Unmarshal(q); err != nil {
    268 			w.log.Error.Printf(
    269 				"failed to unmarshal document %s: %v",
    270 				d.Id, err,
    271 			)
    272 			continue
    273 		}
    274 
    275 		var (
    276 			line strings.Builder
    277 			// Account for the final newline.
    278 			space = int(w.size.Columns)
    279 		)
    280 		// Id
    281 		line.WriteString(text.Truncate(d.Id, space))
    282 		space -= runewidth.StringWidth(d.Id)
    283 		line.WriteByte(',')
    284 		space -= 1
    285 
    286 		// Author
    287 		line.WriteString(text.Truncate(q.Author, space))
    288 		space -= runewidth.StringWidth(q.Author)
    289 		line.WriteByte(',')
    290 		space -= 1
    291 
    292 		// Source
    293 		line.WriteString(text.Truncate(q.Source, space))
    294 		space -= runewidth.StringWidth(q.Source)
    295 		line.WriteByte(',')
    296 		space -= 1
    297 
    298 		// Text
    299 		t := strings.ReplaceAll(q.Text, "\n", " ")
    300 		switch {
    301 		case space < 1:
    302 			// Don't output text.
    303 		case len(t) > space:
    304 			line.WriteString(text.Truncate(
    305 				t,
    306 				space-runewidth.RuneWidth('…'),
    307 			))
    308 			line.WriteRune('…')
    309 		default:
    310 			line.WriteString(t)
    311 		}
    312 		line.WriteByte('\n')
    313 
    314 		output.WriteString(line.String())
    315 	}
    316 
    317 	fmt.Println(output.String())
    318 
    319 	return nil
    320 }
    321 
    322 func (w *Wisdom) remove(args []string) error {
    323 	if len(args) == 0 {
    324 		return fmt.Errorf("missing quote filename")
    325 	}
    326 
    327 	for _, name := range args {
    328 		if err := w.store.Collection(coll).Delete(name); err != nil {
    329 			return fmt.Errorf("failed to delete %s: %v", name, err)
    330 		}
    331 	}
    332 
    333 	return nil
    334 }
    335 
    336 func (w *Wisdom) show(args []string) error {
    337 	for _, id := range args {
    338 		d, err := w.store.Collection(coll).FindId(id)
    339 		if err != nil {
    340 			return fmt.Errorf("failed to load quotes: %v", err)
    341 		}
    342 
    343 		var q = &quote.Quote{}
    344 		if err := d.Unmarshal(q); err != nil {
    345 			return fmt.Errorf(
    346 				"failed to unmarshal document %s: %v",
    347 				d.Id, err,
    348 			)
    349 		}
    350 
    351 		fmt.Println(q.Render())
    352 	}
    353 
    354 	return nil
    355 }