client.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5// Package cooked calls the Cooked customer API.
  6package cooked
  7
  8import (
  9	"bytes"
 10	"context"
 11	"encoding/json"
 12	"errors"
 13	"fmt"
 14	"io"
 15	"net/http"
 16	"net/http/cookiejar"
 17	"net/url"
 18	"strings"
 19	"sync"
 20	"time"
 21
 22	"golang.org/x/net/publicsuffix"
 23)
 24
 25const maxResponseBytes = 1 << 20
 26
 27// Client is an authenticated Cooked API client.
 28type Client struct {
 29	baseURL  *url.URL
 30	username string
 31	password string
 32	http     *http.Client
 33	userAgent string
 34
 35	requestMu             sync.Mutex
 36	authenticatedUsername string
 37}
 38
 39// ShoppingList is the authenticated user's Cooked shopping list.
 40type ShoppingList struct {
 41	Aisles  []Aisle  `json:"aisles"`
 42	Recipes []string `json:"recipes"`
 43}
 44
 45// Aisle groups shopping-list product groups.
 46type Aisle struct {
 47	ID            string         `json:"aisle-id"`
 48	Name          string         `json:"aisle-name"`
 49	ProductGroups []ProductGroup `json:"product-groups"`
 50}
 51
 52// ProductGroup is an item on the shopping list.
 53type ProductGroup struct {
 54	ID       string `json:"id"`
 55	Name     string `json:"name"`
 56	Quantity string `json:"quantity"`
 57	Selected bool   `json:"selected"`
 58}
 59
 60// AddShoppingListResult is the result of adding ingredients to a shopping list.
 61type AddShoppingListResult struct {
 62	AddedCount  int
 63	Ingredients []string
 64}
 65
 66// ShoppingListProductGroupUpdate is a full shopping-list product-group update.
 67type ShoppingListProductGroupUpdate struct {
 68	Name     string
 69	Quantity string
 70	AisleID  string
 71	Selected bool
 72}
 73
 74// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
 75type RecipeCard struct {
 76	ID           string `json:"id"`
 77	Title        string `json:"title"`
 78	ThumbnailURL string `json:"thumbnail-url,omitempty"`
 79}
 80
 81// RecipeMetadata is metadata for a Cooked recipe.
 82type RecipeMetadata struct {
 83	Title          string   `json:"title"`
 84	ImageURLs      []string `json:"image-urls"`
 85	Owner          string   `json:"owner"`
 86	EditPermission bool     `json:"edit-permission"`
 87}
 88
 89// RecipeContent is the Markdown-style body and portions for a Cooked recipe.
 90type RecipeContent struct {
 91	Content  string  `json:"content"`
 92	Portions float64 `json:"portions"`
 93}
 94
 95// RecipeTextPreview is Cooked's preview of raw recipe text before saving.
 96type RecipeTextPreview struct {
 97	Title    string  `json:"title"`
 98	Markdown string  `json:"markdown"`
 99	Portions float64 `json:"portions"`
100}
101
102// RecipeURLImport is the result of importing a recipe URL.
103type RecipeURLImport struct {
104	RecipeID string
105	DraftID  string
106}
107
108// NewClient returns a Cooked client with an in-memory cookie jar.
109func NewClient(baseURL *url.URL, username, password, userAgent string) (*Client, error) {
110	jar, err := newCookieJar()
111	if err != nil {
112		return nil, fmt.Errorf("create cookie jar: %w", err)
113	}
114
115	return &Client{
116		baseURL:   baseURL,
117		username:  username,
118		password:  password,
119		userAgent: userAgent,
120		http: &http.Client{
121			Jar:     jar,
122			Timeout: 30 * time.Second,
123		},
124	}, nil
125}
126
127// ReadShoppingList logs in when needed and returns the authenticated user's shopping list.
128func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
129	var response shoppingListResponse
130	decodeShoppingList := func(decoder *json.Decoder) error {
131		return decoder.Decode(&response)
132	}
133	if err := c.doAuthenticated(
134		ctx,
135		http.MethodGet,
136		"/api/user/{username}/shopping-list",
137		nil,
138		decodeShoppingList,
139	); err != nil {
140		return ShoppingList{}, err
141	}
142
143	if err := validateShoppingListResponse(response); err != nil {
144		return ShoppingList{}, err
145	}
146
147	shoppingList := *response.ShoppingList
148	shoppingList.Recipes = response.Recipes
149
150	return shoppingList, nil
151}
152
153// ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
154func (c *Client) ClearShoppingList(ctx context.Context) error {
155	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
156}
157
158// AddShoppingListIngredients logs in when needed and adds ingredients to the shopping list.
159func (c *Client) AddShoppingListIngredients(
160	ctx context.Context,
161	ingredients, recipeID string,
162) (AddShoppingListResult, error) {
163	body, err := json.Marshal(addShoppingListRequest{Ingredients: ingredients, RecipeID: recipeID})
164	if err != nil {
165		return AddShoppingListResult{}, fmt.Errorf("encode Cooked shopping-list add request: %w", err)
166	}
167
168	var response addShoppingListResponse
169	decodeAdd := func(decoder *json.Decoder) error {
170		return decoder.Decode(&response)
171	}
172	if err := c.doAuthenticated(ctx, http.MethodPut, "/api/user/shopping-list", body, decodeAdd); err != nil {
173		return AddShoppingListResult{}, err
174	}
175	if response.AddedCount == nil {
176		return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
177	}
178	if strings.TrimSpace(ingredients) != "" && *response.AddedCount == 0 {
179		return AddShoppingListResult{}, fmt.Errorf(
180			"add shopping-list ingredients response returned added-count 0 for nonblank ingredients",
181		)
182	}
183
184	return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
185}
186
187// RemoveShoppingListProductGroups logs in when needed and removes shopping-list product groups.
188func (c *Client) RemoveShoppingListProductGroups(ctx context.Context, ids []string) error {
189	body, err := json.Marshal(removeShoppingListProductGroupsRequest{IDs: ids})
190	if err != nil {
191		return fmt.Errorf("encode Cooked shopping-list remove request: %w", err)
192	}
193
194	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list/product-groups", body, nil)
195}
196
197// ReplaceShoppingListSelection logs in when needed and replaces the selected shopping-list product groups.
198func (c *Client) ReplaceShoppingListSelection(ctx context.Context, ids []string) error {
199	body, err := json.Marshal(replaceShoppingListSelectionRequest{Selected: ids})
200	if err != nil {
201		return fmt.Errorf("encode Cooked shopping-list selection request: %w", err)
202	}
203
204	return c.doAuthenticated(
205		ctx,
206		http.MethodPut,
207		"/api/user/{username}/shopping-list/product-groups/selection",
208		body,
209		nil,
210	)
211}
212
213// UpdateShoppingListProductGroup logs in when needed and updates a shopping-list product group.
214func (c *Client) UpdateShoppingListProductGroup(
215	ctx context.Context,
216	productGroupID string,
217	update ShoppingListProductGroupUpdate,
218) error {
219	body, err := json.Marshal(updateShoppingListProductGroupRequest(update))
220	if err != nil {
221		return fmt.Errorf("encode Cooked shopping-list product-group update request: %w", err)
222	}
223
224	path := "/api/user/{username}/shopping-list/product-groups/" + url.PathEscape(productGroupID)
225
226	return c.doAuthenticated(ctx, http.MethodPut, path, body, nil)
227}
228
229// ListRecipes logs in when needed and returns one page of saved recipes.
230func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
231	query := url.Values{}
232	query.Set("page", fmt.Sprintf("%d", page))
233	query.Set("page-count", fmt.Sprintf("%d", limit))
234
235	return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
236}
237
238// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
239func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
240	query := url.Values{}
241	query.Set("q", queryText)
242	query.Set("page", fmt.Sprintf("%d", page))
243
244	return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
245}
246
247// ReadRecipeMetadata logs in when needed and returns recipe metadata.
248func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
249	var metadata RecipeMetadata
250	decodeMetadata := func(decoder *json.Decoder) error {
251		return decoder.Decode(&metadata)
252	}
253	path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
254	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
255		return RecipeMetadata{}, err
256	}
257
258	return metadata, nil
259}
260
261// ReadRecipeContent logs in when needed and returns recipe content and portions.
262func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
263	var content RecipeContent
264	decodeContent := func(decoder *json.Decoder) error {
265		return decoder.Decode(&content)
266	}
267	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
268	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
269		return RecipeContent{}, err
270	}
271	if err := validateRecipeContent(content); err != nil {
272		return RecipeContent{}, err
273	}
274
275	return content, nil
276}
277
278// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
279func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
280	body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
281	if err != nil {
282		return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
283	}
284
285	var preview RecipeTextPreview
286	decodePreview := func(decoder *json.Decoder) error {
287		return decoder.Decode(&preview)
288	}
289	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
290		return RecipeTextPreview{}, err
291	}
292
293	return preview, nil
294}
295
296// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
297func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error) {
298	body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
299	if err != nil {
300		return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
301	}
302
303	var response saveRecipeResponse
304	decodeSave := func(decoder *json.Decoder) error {
305		return decoder.Decode(&response)
306	}
307	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil {
308		return "", err
309	}
310	if strings.TrimSpace(response.RecipeID) == "" {
311		return "", fmt.Errorf("save prepared recipe response missing recipe ID")
312	}
313
314	return response.RecipeID, nil
315}
316
317// SaveRecipeDraft logs in when needed and saves a reviewed URL import draft.
318func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error) {
319	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
320	if err != nil {
321		return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err)
322	}
323
324	var response saveRecipeResponse
325	decodeSave := func(decoder *json.Decoder) error {
326		return decoder.Decode(&response)
327	}
328	path := "/api/extract/" + url.PathEscape(draftID) + "/save"
329	if err := c.doAuthenticated(ctx, http.MethodPost, path, body, decodeSave); err != nil {
330		return "", err
331	}
332	if strings.TrimSpace(response.RecipeID) == "" {
333		return "", fmt.Errorf("save recipe draft response missing recipe ID")
334	}
335
336	return response.RecipeID, nil
337}
338
339// UpdateRecipeContent logs in when needed and updates an existing recipe's content.
340func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error {
341	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
342	if err != nil {
343		return fmt.Errorf("encode Cooked recipe update request: %w", err)
344	}
345
346	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
347
348	return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
349}
350
351// ImportRecipeURL logs in when needed and starts a recipe URL import.
352func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) {
353	body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL})
354	if err != nil {
355		return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err)
356	}
357
358	var response importRecipeURLResponse
359	decodeImport := func(decoder *json.Decoder) error {
360		return decoder.Decode(&response)
361	}
362	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil {
363		return RecipeURLImport{}, err
364	}
365
366	if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" {
367		return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
368	}
369
370	return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil
371}
372
373// DeleteRecipe logs in when needed and deletes an existing recipe.
374func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
375	path := "/api/recipe/" + url.PathEscape(recipeID)
376
377	return c.doAuthenticated(ctx, http.MethodDelete, path, nil, nil)
378}
379
380func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
381	var recipes []RecipeCard
382	decodeRecipes := func(decoder *json.Decoder) error {
383		return decoder.Decode(&recipes)
384	}
385	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
386		return nil, err
387	}
388	if err := validateRecipeCards(recipes); err != nil {
389		return nil, err
390	}
391
392	return recipes, nil
393}
394
395func validateRecipeCards(recipes []RecipeCard) error {
396	if recipes == nil {
397		return fmt.Errorf("cooked recipe list response missing recipes")
398	}
399
400	for index, recipe := range recipes {
401		if strings.TrimSpace(recipe.ID) == "" {
402			return fmt.Errorf("cooked recipe list response recipe %d missing id", index)
403		}
404		if strings.TrimSpace(recipe.Title) == "" {
405			return fmt.Errorf("cooked recipe list response recipe %d missing title", index)
406		}
407	}
408
409	return nil
410}
411
412func validateRecipeContent(content RecipeContent) error {
413	if strings.TrimSpace(content.Content) == "" {
414		return fmt.Errorf("cooked recipe content response missing content")
415	}
416	if content.Portions < 1 {
417		return fmt.Errorf("cooked recipe content response missing or invalid portions")
418	}
419
420	return nil
421}
422
423func validateShoppingListResponse(response shoppingListResponse) error {
424	if response.ShoppingList == nil {
425		return fmt.Errorf("cooked shopping-list response missing shopping-list")
426	}
427	if response.ShoppingList.Aisles == nil {
428		return fmt.Errorf("cooked shopping-list response missing aisles")
429	}
430
431	for aisleIndex, aisle := range response.ShoppingList.Aisles {
432		if err := validateShoppingListAisle(aisleIndex, aisle); err != nil {
433			return err
434		}
435	}
436
437	return nil
438}
439
440func validateShoppingListAisle(aisleIndex int, aisle Aisle) error {
441	if strings.TrimSpace(aisle.ID) == "" {
442		return fmt.Errorf("cooked shopping-list response aisle %d missing id", aisleIndex)
443	}
444	if strings.TrimSpace(aisle.Name) == "" {
445		return fmt.Errorf("cooked shopping-list response aisle %d missing name", aisleIndex)
446	}
447	if aisle.ProductGroups == nil {
448		return fmt.Errorf("cooked shopping-list response aisle %d missing product groups", aisleIndex)
449	}
450
451	for productIndex, product := range aisle.ProductGroups {
452		if err := validateShoppingListProductGroup(aisleIndex, productIndex, product); err != nil {
453			return err
454		}
455	}
456
457	return nil
458}
459
460func validateShoppingListProductGroup(aisleIndex, productIndex int, product ProductGroup) error {
461	if strings.TrimSpace(product.ID) == "" {
462		return fmt.Errorf(
463			"cooked shopping-list response aisle %d product group %d missing id",
464			aisleIndex,
465			productIndex,
466		)
467	}
468	if strings.TrimSpace(product.Name) == "" {
469		return fmt.Errorf(
470			"cooked shopping-list response aisle %d product group %d missing name",
471			aisleIndex,
472			productIndex,
473		)
474	}
475
476	return nil
477}
478
479func (c *Client) doAuthenticated(
480	ctx context.Context,
481	method, path string,
482	body []byte,
483	decodeResponse responseDecoder,
484) error {
485	c.requestMu.Lock()
486	defer c.requestMu.Unlock()
487
488	if err := c.ensureLogin(ctx); err != nil {
489		return err
490	}
491
492	err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
493	if !isUnauthorized(err) {
494		return err
495	}
496
497	c.resetSession()
498	if err := c.ensureLogin(ctx); err != nil {
499		return err
500	}
501
502	return c.do(ctx, method, c.userPath(path), body, decodeResponse)
503}
504
505func (c *Client) ensureLogin(ctx context.Context) error {
506	authenticated := c.authenticatedUsername != ""
507	if authenticated {
508		return nil
509	}
510
511	body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
512	if err != nil {
513		return fmt.Errorf("encode Cooked login request: %w", err)
514	}
515
516	var login loginResponse
517	decodeLogin := func(decoder *json.Decoder) error {
518		return decoder.Decode(&login)
519	}
520	if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
521		return fmt.Errorf("authenticate with Cooked: %w", err)
522	}
523	if login.Username == "" {
524		return fmt.Errorf("authenticate with Cooked: missing username in login response")
525	}
526
527	c.authenticatedUsername = login.Username
528
529	return nil
530}
531
532func (c *Client) resetSession() {
533	jar, err := newCookieJar()
534	if err != nil {
535		return
536	}
537
538	c.authenticatedUsername = ""
539	c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
540}
541
542func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
543	request, err := c.newRequest(ctx, method, path, body)
544	if err != nil {
545		return err
546	}
547
548	response, err := c.http.Do(request)
549	if err != nil {
550		return fmt.Errorf("call Cooked: %w", err)
551	}
552	defer func() {
553		_ = response.Body.Close()
554	}()
555
556	if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
557		return decodeError(response)
558	}
559
560	if decodeResponse == nil {
561		return nil
562	}
563	if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
564		return fmt.Errorf("decode Cooked response: %w", err)
565	}
566
567	return nil
568}
569
570func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
571	relativeURL, err := url.Parse(path)
572	if err != nil {
573		return nil, fmt.Errorf("parse Cooked request path: %w", err)
574	}
575	requestURL := c.baseURL.ResolveReference(relativeURL)
576
577	var reader io.Reader
578	if body != nil {
579		reader = bytes.NewReader(body)
580	}
581
582	request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
583	if err != nil {
584		return nil, fmt.Errorf("build Cooked request: %w", err)
585	}
586	request.Header.Set("Accept", "application/json")
587	if body != nil {
588		request.Header.Set("Content-Type", "application/json")
589	}
590	request.Header.Set("User-Agent", c.userAgent)
591
592	return request, nil
593}
594
595func (c *Client) userPath(path string) string {
596	username := c.authenticatedUsername
597
598	return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
599}
600
601type responseDecoder func(*json.Decoder) error
602
603type shoppingListResponse struct {
604	ShoppingList *ShoppingList `json:"shopping-list"`
605	Recipes      []string      `json:"recipes"`
606}
607
608type addShoppingListRequest struct {
609	Ingredients string `json:"ingredients"`
610	RecipeID    string `json:"recipe-id,omitempty"`
611}
612
613type addShoppingListResponse struct {
614	AddedCount  *int     `json:"added-count"`
615	Ingredients []string `json:"ingredients"`
616}
617
618type removeShoppingListProductGroupsRequest struct {
619	IDs []string `json:"ids"`
620}
621
622type replaceShoppingListSelectionRequest struct {
623	Selected []string `json:"selected"`
624}
625
626type updateShoppingListProductGroupRequest struct {
627	Name     string `json:"name"`
628	Quantity string `json:"quantity"`
629	AisleID  string `json:"aisle-id"`
630	Selected bool   `json:"selected"`
631}
632
633type previewRecipeTextRequest struct {
634	RecipeName string `json:"recipe-name"`
635	RecipeText string `json:"recipe-text"`
636}
637
638type savePreparedRecipeRequest struct {
639	Title       string  `json:"title"`
640	Description string  `json:"description"`
641	Portions    float64 `json:"portions"`
642}
643
644type recipeContentRequest struct {
645	Description string  `json:"description"`
646	Portions    float64 `json:"portions"`
647}
648
649type importRecipeURLRequest struct {
650	URL string `json:"url"`
651}
652
653type importRecipeURLResponse struct {
654	RecipeID     string `json:"recipe-id"`
655	ExtractionID string `json:"extraction-id"`
656}
657
658type saveRecipeResponse struct {
659	RecipeID string `json:"recipe-id"`
660}
661
662type loginRequest struct {
663	Username string `json:"username"`
664	Password string `json:"password"`
665}
666
667type loginResponse struct {
668	Username string `json:"username"`
669}
670
671type cookedError struct {
672	StatusCode int
673	Code       string
674	Message    string
675}
676
677func (e cookedError) Error() string {
678	var parts []string
679	parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
680	if e.Code != "" {
681		parts = append(parts, e.Code)
682	}
683	if e.Message != "" {
684		parts = append(parts, e.Message)
685	}
686
687	return strings.Join(parts, ": ")
688}
689
690func decodeError(response *http.Response) error {
691	var payload struct {
692		Code    string `json:"code"`
693		Message string `json:"message"`
694	}
695	_ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)
696
697	return cookedError{
698		StatusCode: response.StatusCode,
699		Code:       payload.Code,
700		Message:    payload.Message,
701	}
702}
703
704func isUnauthorized(err error) bool {
705	if err == nil {
706		return false
707	}
708
709	var cookedErr cookedError
710	if !errors.As(err, &cookedErr) {
711		return false
712	}
713
714	return cookedErr.StatusCode == http.StatusUnauthorized
715}
716
717func newCookieJar() (*cookiejar.Jar, error) {
718	return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
719}