github.com/soulteary/pocket-bookcase@v0.0.0-20240428065142-0b5a9a0fc98a/internal/cmd/update.go (about)

     1  package cmd
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"sort"
     7  	"strings"
     8  	"sync"
     9  
    10  	"github.com/soulteary/pocket-bookcase/internal/core"
    11  	"github.com/soulteary/pocket-bookcase/internal/database"
    12  	"github.com/soulteary/pocket-bookcase/internal/model"
    13  	"github.com/spf13/cobra"
    14  )
    15  
    16  func updateCmd() *cobra.Command {
    17  	cmd := &cobra.Command{
    18  		Use:   "update [indices]",
    19  		Short: "Update the saved bookmarks",
    20  		Long: "Update fields and archive of an existing bookmark. " +
    21  			"Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), " +
    22  			"hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). " +
    23  			"If no arguments, ALL bookmarks will be updated. Update works differently depending on the flags:\n" +
    24  			"- If indices are passed without any flags (--url, --title, --tag and --excerpt), read the URLs from database and update titles from web.\n" +
    25  			"- If --url is passed (and --title is omitted), update the title from web using the URL. While using this flag, update only accept EXACTLY one index.\n" +
    26  			"While updating bookmark's tags, you can use - to remove tag (e.g. -nature to remove nature tag from this bookmark).",
    27  		Run: updateHandler,
    28  	}
    29  
    30  	cmd.Flags().StringP("url", "u", "", "New URL for this bookmark")
    31  	cmd.Flags().StringP("title", "i", "", "New title for this bookmark")
    32  	cmd.Flags().StringP("excerpt", "e", "", "New excerpt for this bookmark")
    33  	cmd.Flags().StringSliceP("tags", "t", []string{}, "Comma-separated tags for this bookmark")
    34  	cmd.Flags().BoolP("offline", "o", false, "Update bookmark without fetching data from internet")
    35  	cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and update ALL bookmarks")
    36  	cmd.Flags().Bool("keep-metadata", false, "Keep existing metadata. Useful when only want to update bookmark's content")
    37  	cmd.Flags().BoolP("no-archival", "a", false, "Update bookmark without updating offline archive")
    38  	cmd.Flags().Bool("log-archival", false, "Log the archival process")
    39  
    40  	return cmd
    41  }
    42  
    43  func updateHandler(cmd *cobra.Command, args []string) {
    44  	cfg, deps := initShiori(cmd.Context(), cmd)
    45  
    46  	// Parse flags
    47  	url, _ := cmd.Flags().GetString("url")
    48  	title, _ := cmd.Flags().GetString("title")
    49  	excerpt, _ := cmd.Flags().GetString("excerpt")
    50  	tags, _ := cmd.Flags().GetStringSlice("tags")
    51  	offline, _ := cmd.Flags().GetBool("offline")
    52  	skipConfirm, _ := cmd.Flags().GetBool("yes")
    53  	noArchival, _ := cmd.Flags().GetBool("no-archival")
    54  	logArchival, _ := cmd.Flags().GetBool("log-archival")
    55  	keep_metadata := cmd.Flags().Changed("keep-metadata")
    56  
    57  	// If no arguments (i.e all bookmarks going to be updated), confirm to user
    58  	if len(args) == 0 && !skipConfirm {
    59  		confirmUpdate := ""
    60  		fmt.Print("Update ALL bookmarks? (y/N): ")
    61  		fmt.Scanln(&confirmUpdate)
    62  
    63  		if confirmUpdate != "y" {
    64  			fmt.Println("No bookmarks updated")
    65  			return
    66  		}
    67  	}
    68  
    69  	// Convert args to ids
    70  	ids, err := parseStrIndices(args)
    71  	if err != nil {
    72  		cError.Printf("Failed to parse args: %v\n", err)
    73  		os.Exit(1)
    74  	}
    75  
    76  	// Clean up new parameter from flags
    77  	title = validateTitle(title, "")
    78  	excerpt = normalizeSpace(excerpt)
    79  
    80  	if cmd.Flags().Changed("url") {
    81  		// Clean up bookmark URL
    82  		url, err = core.RemoveUTMParams(url)
    83  		if err != nil {
    84  			panic(fmt.Errorf("failed to clean URL: %v", err))
    85  		}
    86  
    87  		// Since user uses custom URL, make sure there is only one ID to update
    88  		if len(ids) != 1 {
    89  			cError.Println("Update only accepts one index while using --url flag")
    90  			os.Exit(1)
    91  		}
    92  	}
    93  
    94  	// Fetch bookmarks from database
    95  	filterOptions := database.GetBookmarksOptions{
    96  		IDs: ids,
    97  	}
    98  
    99  	bookmarks, err := deps.Database.GetBookmarks(cmd.Context(), filterOptions)
   100  	if err != nil {
   101  		cError.Printf("Failed to get bookmarks: %v\n", err)
   102  		os.Exit(1)
   103  	}
   104  
   105  	if len(bookmarks) == 0 {
   106  		cError.Println("No matching index found")
   107  		os.Exit(1)
   108  	}
   109  
   110  	// Check if user really want to batch update archive
   111  	if nBook := len(bookmarks); nBook > 5 && !offline && !noArchival && !skipConfirm {
   112  		fmt.Printf("This update will generate offline archive for %d bookmark(s).\n", nBook)
   113  		fmt.Println("This might take a long time and uses lot of your network bandwidth.")
   114  
   115  		confirmUpdate := ""
   116  		fmt.Printf("Continue update and archival process ? (y/N): ")
   117  		fmt.Scanln(&confirmUpdate)
   118  
   119  		if confirmUpdate != "y" {
   120  			fmt.Println("No bookmarks updated")
   121  			return
   122  
   123  		}
   124  	}
   125  
   126  	// If it's not offline mode, fetch data from internet
   127  	idWithProblems := []int{}
   128  
   129  	if !offline {
   130  		mx := sync.RWMutex{}
   131  		wg := sync.WaitGroup{}
   132  		chDone := make(chan struct{})
   133  		chProblem := make(chan int, 10)
   134  		chMessage := make(chan interface{}, 10)
   135  		semaphore := make(chan struct{}, 10)
   136  
   137  		cInfo.Println("Downloading article(s)...")
   138  
   139  		for i, book := range bookmarks {
   140  			wg.Add(1)
   141  
   142  			// Mark whether book will be archived
   143  			book.CreateArchive = !noArchival
   144  
   145  			// If used, use submitted URL
   146  			if url != "" {
   147  				book.URL = url
   148  			}
   149  
   150  			go func(i int, book model.BookmarkDTO) {
   151  				// Make sure to finish the WG
   152  				defer wg.Done()
   153  
   154  				// Register goroutine to semaphore
   155  				semaphore <- struct{}{}
   156  				defer func() {
   157  					<-semaphore
   158  				}()
   159  
   160  				// Download data from internet
   161  				content, contentType, err := core.DownloadBookmark(book.URL)
   162  				if err != nil {
   163  					chProblem <- book.ID
   164  					chMessage <- fmt.Errorf("failed to download %s: %v", book.URL, err)
   165  					return
   166  				}
   167  
   168  				request := core.ProcessRequest{
   169  					DataDir:     cfg.Storage.DataDir,
   170  					Bookmark:    book,
   171  					Content:     content,
   172  					ContentType: contentType,
   173  					KeepTitle:   keep_metadata,
   174  					KeepExcerpt: keep_metadata,
   175  					LogArchival: logArchival,
   176  				}
   177  
   178  				book, _, err = core.ProcessBookmark(deps, request)
   179  				content.Close()
   180  
   181  				if err != nil {
   182  					chProblem <- book.ID
   183  					chMessage <- fmt.Errorf("failed to process %s: %v", book.URL, err)
   184  					return
   185  				}
   186  
   187  				// Send success message
   188  				chMessage <- fmt.Sprintf("Downloaded %s", book.URL)
   189  
   190  				// Save parse result to bookmark
   191  				mx.Lock()
   192  				bookmarks[i] = book
   193  				mx.Unlock()
   194  			}(i, book)
   195  		}
   196  
   197  		// Print log message
   198  		go func(nBookmark int) {
   199  			logIndex := 0
   200  
   201  			for {
   202  				select {
   203  				case <-chDone:
   204  					cInfo.Println("Download finished")
   205  					return
   206  				case id := <-chProblem:
   207  					idWithProblems = append(idWithProblems, id)
   208  				case msg := <-chMessage:
   209  					logIndex++
   210  
   211  					switch msg.(type) {
   212  					case error:
   213  						cError.Printf("[%d/%d] %v\n", logIndex, nBookmark, msg)
   214  					case string:
   215  						cInfo.Printf("[%d/%d] %s\n", logIndex, nBookmark, msg)
   216  					}
   217  				}
   218  			}
   219  		}(len(bookmarks))
   220  
   221  		// Wait until all download finished
   222  		wg.Wait()
   223  		close(chDone)
   224  	}
   225  
   226  	// Map which tags is new or deleted from flag --tags
   227  	addedTags := make(map[string]struct{})
   228  	deletedTags := make(map[string]struct{})
   229  	for _, tag := range tags {
   230  		tagName := strings.ToLower(tag)
   231  		tagName = strings.TrimSpace(tagName)
   232  
   233  		if strings.HasPrefix(tagName, "-") {
   234  			tagName = strings.TrimPrefix(tagName, "-")
   235  			deletedTags[tagName] = struct{}{}
   236  		} else {
   237  			addedTags[tagName] = struct{}{}
   238  		}
   239  	}
   240  
   241  	// Attach user submitted value to the bookmarks
   242  	for i, book := range bookmarks {
   243  		// If user submit his own title or excerpt, use it
   244  		if title != "" {
   245  			book.Title = title
   246  		}
   247  
   248  		if excerpt != "" {
   249  			book.Excerpt = excerpt
   250  		}
   251  
   252  		// If user submits url, use it
   253  		if url != "" {
   254  			book.URL = url
   255  		}
   256  
   257  		// Make sure title is valid and not empty
   258  		book.Title = validateTitle(book.Title, book.URL)
   259  
   260  		// Generate new tags
   261  		tmpAddedTags := make(map[string]struct{})
   262  		for key, value := range addedTags {
   263  			tmpAddedTags[key] = value
   264  		}
   265  
   266  		newTags := []model.Tag{}
   267  		for _, tag := range book.Tags {
   268  			if _, isDeleted := deletedTags[tag.Name]; isDeleted {
   269  				tag.Deleted = true
   270  			}
   271  
   272  			if _, alreadyExist := addedTags[tag.Name]; alreadyExist {
   273  				delete(tmpAddedTags, tag.Name)
   274  			}
   275  
   276  			newTags = append(newTags, tag)
   277  		}
   278  
   279  		for tag := range tmpAddedTags {
   280  			newTags = append(newTags, model.Tag{Name: tag})
   281  		}
   282  
   283  		book.Tags = newTags
   284  
   285  		// Set bookmark's new data
   286  		bookmarks[i] = book
   287  	}
   288  
   289  	// Save bookmarks to database
   290  	bookmarks, err = deps.Database.SaveBookmarks(cmd.Context(), false, bookmarks...)
   291  	if err != nil {
   292  		cError.Printf("Failed to save bookmark: %v\n", err)
   293  		os.Exit(1)
   294  	}
   295  
   296  	// Print updated bookmarks
   297  	fmt.Println()
   298  	printBookmarks(bookmarks...)
   299  
   300  	var code int
   301  	if len(idWithProblems) > 0 {
   302  		code = 1
   303  		sort.Ints(idWithProblems)
   304  
   305  		cError.Println("Encountered error while downloading some bookmark(s):")
   306  		for _, id := range idWithProblems {
   307  			cError.Printf("%d ", id)
   308  		}
   309  		fmt.Println()
   310  	}
   311  	os.Exit(code)
   312  }