Eric Bower
·
19 Aug 24
cli.go
1package pgs
2
3import (
4 "cmp"
5 "errors"
6 "fmt"
7 "log/slog"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/lipgloss/table"
14 "github.com/picosh/pico/db"
15 "github.com/picosh/pico/shared"
16 "github.com/picosh/pico/shared/storage"
17 "github.com/picosh/pico/tui/common"
18)
19
20func projectTable(styles common.Styles, projects []*db.Project, width int) *table.Table {
21 headers := []string{
22 "Name",
23 "Last Updated",
24 "Links To",
25 "ACL Type",
26 "ACL",
27 "Blocked",
28 }
29 data := [][]string{}
30 for _, project := range projects {
31 row := []string{
32 project.Name,
33 project.UpdatedAt.Format("2006-01-02 15:04:05"),
34 }
35 links := ""
36 if project.ProjectDir != project.Name {
37 links = project.ProjectDir
38 }
39 row = append(row, links)
40 row = append(row,
41 project.Acl.Type,
42 strings.Join(project.Acl.Data, " "),
43 )
44 row = append(row, project.Blocked)
45 data = append(data, row)
46 }
47
48 t := table.New().
49 Width(width).
50 Headers(headers...).
51 Rows(data...)
52 return t
53}
54
55func getHelpText(styles common.Styles, userName string, width int) string {
56 helpStr := "Commands: [help, stats, ls, rm, link, unlink, prune, retain, depends, acl]\n"
57 helpStr += styles.Note.Render("NOTICE:") + " *must* append with `--write` for the changes to persist.\n"
58
59 projectName := "projA"
60 headers := []string{"Cmd", "Description"}
61 data := [][]string{
62 {
63 "help",
64 "prints this screen",
65 },
66 {
67 "stats",
68 "usage statistics",
69 },
70 {
71 fmt.Sprintf("stats %s", projectName),
72 fmt.Sprintf("site analytics for `%s`", projectName),
73 },
74 {
75 "ls",
76 "lists projects",
77 },
78 {
79 fmt.Sprintf("rm %s", projectName),
80 fmt.Sprintf("delete %s", projectName),
81 },
82 {
83 fmt.Sprintf("link %s --to projB", projectName),
84 fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
85 },
86 {
87 fmt.Sprintf("unlink %s", projectName),
88 fmt.Sprintf("removes symbolic link for `%s`", projectName),
89 },
90 {
91 fmt.Sprintf("prune %s", projectName),
92 fmt.Sprintf("removes projects that match prefix `%s`", projectName),
93 },
94 {
95 fmt.Sprintf("retain %s", projectName),
96 "alias to `prune` but keeps last N projects",
97 },
98 {
99 fmt.Sprintf("depends %s", projectName),
100 fmt.Sprintf("lists all projects linked to `%s`", projectName),
101 },
102 {
103 fmt.Sprintf("acl %s", projectName),
104 fmt.Sprintf("access control for `%s`", projectName),
105 },
106 }
107
108 t := table.New().
109 Width(width).
110 Border(lipgloss.RoundedBorder()).
111 Headers(headers...).
112 Rows(data...)
113
114 helpStr += t.String()
115 return helpStr
116}
117
118type Cmd struct {
119 User *db.User
120 Session shared.CmdSession
121 Log *slog.Logger
122 Store storage.StorageServe
123 Dbpool db.DB
124 Write bool
125 Styles common.Styles
126 Width int
127 Height int
128}
129
130func (c *Cmd) output(out string) {
131 _, _ = c.Session.Write([]byte(out + "\r\n"))
132}
133
134func (c *Cmd) error(err error) {
135 _, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
136 _ = c.Session.Exit(1)
137 _ = c.Session.Close()
138}
139
140func (c *Cmd) bail(err error) {
141 if err == nil {
142 return
143 }
144 c.Log.Error(err.Error())
145 c.error(err)
146}
147
148func (c *Cmd) notice() {
149 if !c.Write {
150 c.output("\nNOTICE: changes not commited, use `--write` to save operation")
151 }
152}
153
154func (c *Cmd) RmProjectAssets(projectName string) error {
155 bucketName := shared.GetAssetBucketName(c.User.ID)
156 bucket, err := c.Store.GetBucket(bucketName)
157 if err != nil {
158 return err
159 }
160 c.output(fmt.Sprintf("removing project assets (%s)", projectName))
161
162 fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
163 if err != nil {
164 return err
165 }
166
167 if len(fileList) == 0 {
168 c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
169 return nil
170 }
171 c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
172
173 for _, file := range fileList {
174 intent := fmt.Sprintf("deleted (%s)", file.Name())
175 c.Log.Info(
176 "attempting to delete file",
177 "user", c.User.Name,
178 "bucket", bucket.Name,
179 "filename", file.Name(),
180 )
181 if c.Write {
182 err = c.Store.DeleteObject(
183 bucket,
184 filepath.Join(projectName, file.Name()),
185 )
186 if err == nil {
187 c.output(intent)
188 } else {
189 return err
190 }
191 } else {
192 c.output(intent)
193 }
194 }
195 return nil
196}
197
198func (c *Cmd) help() {
199 c.output(getHelpText(c.Styles, c.User.Name, c.Width))
200}
201
202func (c *Cmd) statsByProject(projectName string) error {
203 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
204 if err != nil {
205 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
206 }
207
208 opts := &db.SummaryOpts{
209 FkID: project.ID,
210 By: "project_id",
211 Interval: "day",
212 Origin: shared.StartOfMonth(),
213 }
214
215 summary, err := c.Dbpool.VisitSummary(opts)
216 if err != nil {
217 return err
218 }
219
220 c.output("Top URLs")
221 topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
222 c.output(topUrlsTbl.Width(c.Width).String())
223
224 c.output("Top Referers")
225 topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
226 c.output(topRefsTbl.Width(c.Width).String())
227
228 uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
229 c.output("Unique Visitors this Month")
230 c.output(uniqueTbl.Width(c.Width).String())
231
232 return nil
233}
234
235func (c *Cmd) statsSites() error {
236 opts := &db.SummaryOpts{
237 FkID: c.User.ID,
238 By: "user_id",
239 Interval: "day",
240 Origin: shared.StartOfMonth(),
241 }
242
243 summary, err := c.Dbpool.VisitSummary(opts)
244 if err != nil {
245 return err
246 }
247
248 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
249 if err != nil {
250 return err
251 }
252
253 c.output("\nVisitor Analytics this Month\n")
254
255 c.output("Top URLs")
256 topUrlsTbl := common.VisitUrlsWithProjectTbl(projects, summary.TopUrls)
257
258 c.output(topUrlsTbl.Width(c.Width).String())
259
260 c.output("Top Referers")
261 topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
262 c.output(topRefsTbl.Width(c.Width).String())
263
264 mapper := map[string]*db.VisitInterval{}
265 result := []*db.VisitUrl{}
266 // combine visitors by project_id
267 for _, interval := range summary.Intervals {
268 if interval.ProjectID == "" {
269 continue
270 }
271 if _, ok := mapper[interval.ProjectID]; !ok {
272 mapper[interval.ProjectID] = interval
273 }
274 mapper[interval.ProjectID].Visitors += interval.Visitors
275 }
276
277 for _, val := range mapper {
278 projectName := ""
279 for _, project := range projects {
280 if project.ID == val.ProjectID {
281 projectName = project.Name
282 }
283 }
284 result = append(result, &db.VisitUrl{
285 Url: projectName,
286 Count: val.Visitors,
287 })
288 }
289
290 slices.SortFunc(result, func(a, b *db.VisitUrl) int {
291 return cmp.Compare(b.Count, a.Count)
292 })
293
294 uniqueTbl := common.VisitUrlsTbl(result)
295 c.output("Unique Visitors by Site")
296 c.output(uniqueTbl.Width(c.Width).String())
297
298 return nil
299}
300
301func (c *Cmd) stats(cfgMaxSize uint64) error {
302 ff, err := c.Dbpool.FindFeatureForUser(c.User.ID, "plus")
303 if err != nil {
304 ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0)
305 }
306 // this is jank
307 ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
308 storageMax := ff.Data.StorageMax
309
310 bucketName := shared.GetAssetBucketName(c.User.ID)
311 bucket, err := c.Store.UpsertBucket(bucketName)
312 if err != nil {
313 return err
314 }
315
316 totalFileSize, err := c.Store.GetBucketQuota(bucket)
317 if err != nil {
318 return err
319 }
320
321 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
322 if err != nil {
323 return err
324 }
325
326 headers := []string{"Used (GB)", "Quota (GB)", "Used (%)", "Projects (#)"}
327 data := []string{
328 fmt.Sprintf("%.4f", shared.BytesToGB(int(totalFileSize))),
329 fmt.Sprintf("%.4f", shared.BytesToGB(int(storageMax))),
330 fmt.Sprintf("%.4f", (float32(totalFileSize)/float32(storageMax))*100),
331 fmt.Sprintf("%d", len(projects)),
332 }
333
334 t := table.New().
335 Width(c.Width).
336 Border(lipgloss.RoundedBorder()).
337 Headers(headers...).
338 Rows(data)
339 c.output(t.String())
340
341 return c.statsSites()
342}
343
344func (c *Cmd) ls() error {
345 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
346 if err != nil {
347 return err
348 }
349
350 if len(projects) == 0 {
351 c.output("no projects found")
352 }
353
354 t := projectTable(c.Styles, projects, c.Width)
355 c.output(t.String())
356
357 return nil
358}
359
360func (c *Cmd) unlink(projectName string) error {
361 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
362 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
363 if err != nil {
364 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
365 }
366
367 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
368 if err != nil {
369 return err
370 }
371 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
372
373 return nil
374}
375
376func (c *Cmd) link(projectName, linkTo string) error {
377 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
378
379 projectDir := linkTo
380 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
381 if err != nil {
382 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
383 return e
384 }
385
386 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
387 projectID := ""
388 if err == nil {
389 projectID = project.ID
390 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
391 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
392 if err != nil {
393 return err
394 }
395 } else {
396 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
397 if !c.Write {
398 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
399 c.output(out)
400 return nil
401 }
402 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
403 if err != nil {
404 return err
405 }
406 projectID = id
407 }
408
409 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
410 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
411 if err != nil {
412 return err
413 }
414
415 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
416 c.output(out)
417
418 err = c.RmProjectAssets(projectName)
419 if err != nil {
420 return err
421 }
422
423 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
424 c.output(out)
425 return nil
426}
427
428func (c *Cmd) depends(projectName string) error {
429 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
430 if err != nil {
431 return err
432 }
433
434 if len(projects) == 0 {
435 out := fmt.Sprintf("no projects linked to (%s)", projectName)
436 c.output(out)
437 return nil
438 }
439
440 t := projectTable(c.Styles, projects, c.Width)
441 c.output(t.String())
442
443 return nil
444}
445
446// delete all the projects and associated assets matching prefix
447// but keep the latest N records.
448func (c *Cmd) prune(prefix string, keepNumLatest int) error {
449 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
450 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
451
452 if prefix == "" || prefix == "*" {
453 e := fmt.Errorf("must provide valid prefix")
454 return e
455 }
456
457 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
458 if err != nil {
459 return err
460 }
461
462 if len(projects) == 0 {
463 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
464 return nil
465 }
466
467 rmProjects := []*db.Project{}
468 for _, project := range projects {
469 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
470 if err != nil {
471 return err
472 }
473
474 if len(links) == 0 {
475 rmProjects = append(rmProjects, project)
476 } else {
477 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
478 c.output(out)
479 }
480 }
481
482 goodbye := rmProjects
483 if keepNumLatest > 0 {
484 pmax := len(rmProjects) - (keepNumLatest)
485 if pmax <= 0 {
486 out := fmt.Sprintf(
487 "no projects available to prune (retention policy: %d, total: %d)",
488 keepNumLatest,
489 len(rmProjects),
490 )
491 c.output(out)
492 return nil
493 }
494 goodbye = rmProjects[:pmax]
495 }
496
497 for _, project := range goodbye {
498 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
499 c.output(out)
500 err = c.RmProjectAssets(project.Name)
501 if err != nil {
502 return err
503 }
504
505 out = fmt.Sprintf("(%s) removing", project.Name)
506 c.output(out)
507
508 if c.Write {
509 c.Log.Info("removing project", "project", project.Name)
510 err = c.Dbpool.RemoveProject(project.ID)
511 if err != nil {
512 return err
513 }
514 }
515 }
516
517 c.output("\nsummary")
518 c.output("=======")
519 for _, project := range goodbye {
520 c.output(fmt.Sprintf("project (%s) removed", project.Name))
521 }
522
523 return nil
524}
525
526func (c *Cmd) rm(projectName string) error {
527 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
528 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
529 if err == nil {
530 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
531
532 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
533 if err != nil {
534 return err
535 }
536
537 if len(links) > 0 {
538 e := fmt.Errorf("project (%s) has (%d) projects linking to it, cannot delete project until they have been unlinked or removed, aborting", projectName, len(links))
539 return e
540 }
541
542 out := fmt.Sprintf("(%s) removing", project.Name)
543 c.output(out)
544 if c.Write {
545 c.Log.Info("removing project", "project", project.Name)
546 err = c.Dbpool.RemoveProject(project.ID)
547 if err != nil {
548 return err
549 }
550 }
551 } else {
552 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
553 c.output(msg)
554 }
555
556 err = c.RmProjectAssets(projectName)
557 return err
558}
559
560func (c *Cmd) acl(projectName, aclType string, acls []string) error {
561 c.Log.Info(
562 "user running `acl` command",
563 "user", c.User.Name,
564 "project", projectName,
565 "actType", aclType,
566 "acls", acls,
567 )
568 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
569 acl := db.ProjectAcl{
570 Type: aclType,
571 Data: acls,
572 }
573 if c.Write {
574 return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
575 }
576 return nil
577}