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 }