@@ -98,6 +98,12 @@ type RecipeTextPreview struct {
Portions int `json:"portions"`
}
+// RecipeURLImport is the result of importing a recipe URL.
+type RecipeURLImport struct {
+ RecipeID string
+ DraftID string
+}
+
// NewClient returns a Cooked client with an in-memory cookie jar.
func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
jar, err := newCookieJar()
@@ -301,6 +307,28 @@ func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown str
return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
}
+// ImportRecipeURL logs in when needed and starts a recipe URL import.
+func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) {
+ body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL})
+ if err != nil {
+ return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err)
+ }
+
+ var response importRecipeURLResponse
+ decodeImport := func(decoder *json.Decoder) error {
+ return decoder.Decode(&response)
+ }
+ if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil {
+ return RecipeURLImport{}, err
+ }
+
+ if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" {
+ return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
+ }
+
+ return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil
+}
+
// DeleteRecipe logs in when needed and deletes an existing recipe.
func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
path := "/api/recipe/" + url.PathEscape(recipeID)
@@ -493,6 +521,15 @@ type updateRecipeContentRequest struct {
Portions int `json:"portions"`
}
+type importRecipeURLRequest struct {
+ URL string `json:"url"`
+}
+
+type importRecipeURLResponse struct {
+ RecipeID string `json:"recipe-id"`
+ ExtractionID string `json:"extraction-id"`
+}
+
type saveRecipeResponse struct {
RecipeID string `json:"recipe-id"`
}
@@ -159,6 +159,52 @@ func TestUpdateRecipeContentACIDRecipesSave8And9And13UpdatesExistingRecipe(t *te
}
}
+func TestImportRecipeURLACIDRecipesSave3And4ImportsURLAndReturnsSavedRecipeID(t *testing.T) {
+ var importCalls int
+ client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{
+ RecipeID: "recipe-1",
+ }))
+ defer closeServer()
+
+ result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe")
+ if err != nil {
+ t.Fatalf("ImportRecipeURL() error = %v", err)
+ }
+
+ if importCalls != 1 {
+ t.Fatalf("import calls = %d, want 1", importCalls)
+ }
+ if result.RecipeID != "recipe-1" {
+ t.Fatalf("recipe ID = %q, want recipe-1", result.RecipeID)
+ }
+ if result.DraftID != "" {
+ t.Fatalf("draft ID = %q, want empty", result.DraftID)
+ }
+}
+
+func TestImportRecipeURLACIDRecipesSave3And5And6ImportsURLAndReturnsDraftIDWithoutSaving(t *testing.T) {
+ var importCalls int
+ client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{
+ ExtractionID: "draft-1",
+ }))
+ defer closeServer()
+
+ result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe")
+ if err != nil {
+ t.Fatalf("ImportRecipeURL() error = %v", err)
+ }
+
+ if importCalls != 1 {
+ t.Fatalf("import calls = %d, want 1", importCalls)
+ }
+ if result.RecipeID != "" {
+ t.Fatalf("recipe ID = %q, want empty", result.RecipeID)
+ }
+ if result.DraftID != "draft-1" {
+ t.Fatalf("draft ID = %q, want draft-1", result.DraftID)
+ }
+}
+
func TestDeleteRecipeDeletesSavedRecipe(t *testing.T) {
var deleteCalls int
client, closeServer := newTestClient(t, deleteRecipeTestHandler(t, &deleteCalls))
@@ -465,6 +511,31 @@ func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.Handler
}
}
+func importRecipeURLTestHandler(
+ t *testing.T,
+ importCalls *int,
+ response importRecipeURLResponse,
+) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/new":
+ requireMethod(t, r, http.MethodPost, "import recipe URL")
+ requireSessionCookie(t, r)
+ requireImportRecipeURLRequest(t, r)
+ (*importCalls)++
+ writeJSON(t, w, response)
+ case "/api/extract/draft-1/save":
+ t.Fatal("unexpected draft save call")
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }
+}
+
func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
t.Helper()
@@ -704,6 +775,18 @@ func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) {
}
}
+func requireImportRecipeURLRequest(t *testing.T, r *http.Request) {
+ t.Helper()
+
+ var request importRecipeURLRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Fatalf("decode import recipe URL request: %v", err)
+ }
+ if request.URL != "https://example.com/recipe" {
+ t.Fatalf("import recipe URL = %q, want configured URL", request.URL)
+ }
+}
+
func requireLoginRequest(t *testing.T, r *http.Request) {
t.Helper()
requireMethod(t, r, http.MethodPost, "login")
@@ -798,7 +881,8 @@ func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
}
func writeJSON[
- T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
+ T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview |
+ saveRecipeResponse | importRecipeURLResponse,
](
t *testing.T,
w http.ResponseWriter,