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 = "e.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 = "e.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 = "e.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 }