diff --git a/docs/docs/documentation/getting-started/api-usage.md b/docs/docs/documentation/getting-started/api-usage.md
index bf00a3977..9323776d1 100644
--- a/docs/docs/documentation/getting-started/api-usage.md
+++ b/docs/docs/documentation/getting-started/api-usage.md
@@ -89,6 +89,26 @@ This filter will find all recipes that don't start with the word "Test":
This filter will find all recipes that have particular slugs:
`slug IN ["pasta-fagioli", "delicious-ramen"]`
+##### Placeholder Keywords
+You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current time.
+
+`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days:
+`lastMade <= "$NOW-30d"`
+
+Supported offsets operations include:
+- `-` for subtracting a time (i.e. in the past)
+- `+` for adding a time (i.e. in the future)
+
+Supported offset intervals include:
+- `y` for years
+- `m` for months
+- `d` for days
+- `H` for hours
+- `M` for minutes
+- `S` for seconds
+
+Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
+
##### Nested Property filters
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user:
`user.username = "SousChef20220320"`
diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
index eed1ec2df..5539f966a 100644
--- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
+++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
@@ -36,7 +36,7 @@
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated";
-import type { QueryFilterJSON } from "~/lib/api/types/response";
+import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
interface Props {
queryFilter?: QueryFilterJSON | null;
diff --git a/frontend/components/Domain/QueryFilterBuilder.vue b/frontend/components/Domain/QueryFilterBuilder.vue
index 85b00ad25..ca330cbb4 100644
--- a/frontend/components/Domain/QueryFilterBuilder.vue
+++ b/frontend/components/Domain/QueryFilterBuilder.vue
@@ -319,7 +319,7 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated";
-import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
+import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
diff --git a/frontend/composables/use-query-filter-builder.ts b/frontend/composables/use-query-filter-builder.ts
index c811d66ae..2b731cff3 100644
--- a/frontend/composables/use-query-filter-builder.ts
+++ b/frontend/composables/use-query-filter-builder.ts
@@ -1,5 +1,5 @@
-import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
-import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
+import { Organizer } from "~/lib/api/types/non-generated";
+import type { LogicalOperator, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
export interface FieldLogicalOperator {
label: string;
diff --git a/frontend/composables/use-users/preferences.ts b/frontend/composables/use-users/preferences.ts
index 103678788..c8df3b011 100644
--- a/frontend/composables/use-users/preferences.ts
+++ b/frontend/composables/use-users/preferences.ts
@@ -1,7 +1,7 @@
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { ActivityKey } from "~/lib/api/types/activity";
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
-import type { QueryFilterJSON } from "~/lib/api/types/response";
+import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
export interface UserPrintPreferences {
imagePosition: string;
diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts
index e93386e0f..36f1c086f 100644
--- a/frontend/lib/api/types/cookbook.ts
+++ b/frontend/lib/api/types/cookbook.ts
@@ -44,6 +44,7 @@ export interface QueryFilterJSONPart {
attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
+ [k: string]: unknown;
}
export interface SaveCookBook {
name: string;
diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts
index aa4f27115..ca07d873e 100644
--- a/frontend/lib/api/types/meal-plan.ts
+++ b/frontend/lib/api/types/meal-plan.ts
@@ -53,6 +53,7 @@ export interface QueryFilterJSONPart {
attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
+ [k: string]: unknown;
}
export interface PlanRulesSave {
day?: PlanRulesDay;
diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts
index 81b81325d..d1acd7d5c 100644
--- a/frontend/lib/api/types/non-generated.ts
+++ b/frontend/lib/api/types/non-generated.ts
@@ -40,3 +40,20 @@ export enum Organizer {
Household = "households",
User = "users",
}
+
+export type LogicalOperator = "AND" | "OR";
+export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
+export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
+
+export interface QueryFilterJSON {
+ parts?: QueryFilterJSONPart[];
+}
+
+export interface QueryFilterJSONPart {
+ leftParenthesis?: string | null;
+ rightParenthesis?: string | null;
+ logicalOperator?: LogicalOperator | null;
+ attributeName?: string | null;
+ relationalOperator?: RelationalKeyword | RelationalOperator | null;
+ value?: string | string[] | null;
+}
diff --git a/frontend/lib/api/types/openai.ts b/frontend/lib/api/types/openai.ts
index 10b506402..a18ae9b3c 100644
--- a/frontend/lib/api/types/openai.ts
+++ b/frontend/lib/api/types/openai.ts
@@ -16,7 +16,7 @@ export interface OpenAIIngredients {
}
export interface OpenAIRecipe {
name: string;
- description: string | null;
+ description?: string | null;
recipe_yield?: string | null;
total_time?: string | null;
prep_time?: string | null;
@@ -37,4 +37,7 @@ export interface OpenAIRecipeNotes {
title?: string | null;
text: string;
}
+export interface OpenAIText {
+ text: string;
+}
export interface OpenAIBase {}
diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts
index 77858e3d6..ca19b1d29 100644
--- a/frontend/lib/api/types/recipe.ts
+++ b/frontend/lib/api/types/recipe.ts
@@ -502,13 +502,16 @@ export interface SaveIngredientUnit {
}
export interface ScrapeRecipe {
includeTags?: boolean;
+ includeCategories?: boolean;
url: string;
}
export interface ScrapeRecipeBase {
includeTags?: boolean;
+ includeCategories?: boolean;
}
export interface ScrapeRecipeData {
includeTags?: boolean;
+ includeCategories?: boolean;
data: string;
url?: string | null;
}
diff --git a/frontend/lib/api/types/response.ts b/frontend/lib/api/types/response.ts
index d77f44084..68f7ba9c6 100644
--- a/frontend/lib/api/types/response.ts
+++ b/frontend/lib/api/types/response.ts
@@ -7,9 +7,6 @@
export type OrderByNullPosition = "first" | "last";
export type OrderDirection = "asc" | "desc";
-export type LogicalOperator = "AND" | "OR";
-export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
-export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface ErrorResponse {
message: string;
@@ -28,17 +25,6 @@ export interface PaginationQuery {
page?: number;
perPage?: number;
}
-export interface QueryFilterJSON {
- parts?: QueryFilterJSONPart[];
-}
-export interface QueryFilterJSONPart {
- leftParenthesis?: string | null;
- rightParenthesis?: string | null;
- logicalOperator?: LogicalOperator | null;
- attributeName?: string | null;
- relationalOperator?: RelationalKeyword | RelationalOperator | null;
- value?: string | string[] | null;
-}
export interface RecipeSearchQuery {
cookbook?: string | null;
requireAllCategories?: boolean;
diff --git a/frontend/pages/g/[groupSlug]/recipes/finder/index.vue b/frontend/pages/g/[groupSlug]/recipes/finder/index.vue
index 39df6cccd..04abd81f0 100644
--- a/frontend/pages/g/[groupSlug]/recipes/finder/index.vue
+++ b/frontend/pages/g/[groupSlug]/recipes/finder/index.vue
@@ -422,7 +422,7 @@ import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
-import type { QueryFilterJSON } from "~/lib/api/types/response";
+import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { useRecipeFinderPreferences } from "~/composables/use-users/preferences";
diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py
index 3565a1b8f..414574105 100644
--- a/mealie/repos/repository_generic.py
+++ b/mealie/repos/repository_generic.py
@@ -24,8 +24,8 @@ from mealie.schema.response.pagination import (
PaginationQuery,
RequestQuery,
)
-from mealie.schema.response.query_filter import QueryFilterBuilder
from mealie.schema.response.query_search import SearchFilter
+from mealie.services.query_filter.builder import QueryFilterBuilder
from ._utils import NOT_SET, NotSet
diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py
index c76489a5f..fe2c6d588 100644
--- a/mealie/repos/repository_recipes.py
+++ b/mealie/repos/repository_recipes.py
@@ -26,7 +26,7 @@ from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationQuery
-from mealie.schema.response.query_filter import QueryFilterBuilder
+from mealie.services.query_filter.builder import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase
from .repository_generic import HouseholdRepositoryGeneric
diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py
index 8d7761e3c..29b543d76 100644
--- a/mealie/schema/cookbook/cookbook.py
+++ b/mealie/schema/cookbook/cookbook.py
@@ -11,7 +11,7 @@ from mealie.db.models.household.cookbook import CookBook
from mealie.db.models.recipe import RecipeModel
from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
-from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
+from mealie.services.query_filter.builder import QueryFilterBuilder, QueryFilterJSON
logger = get_logger()
diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py
index 3358e503c..8e803bd6c 100644
--- a/mealie/schema/meal_plan/plan_rules.py
+++ b/mealie/schema/meal_plan/plan_rules.py
@@ -9,7 +9,7 @@ from mealie.core.root_logger import get_logger
from mealie.db.models.recipe import RecipeModel
from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
-from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
+from mealie.services.query_filter.builder import QueryFilterBuilder, QueryFilterJSON
logger = get_logger()
diff --git a/mealie/schema/openai/__init__.py b/mealie/schema/openai/__init__.py
index e153fb35f..0f67fb866 100644
--- a/mealie/schema/openai/__init__.py
+++ b/mealie/schema/openai/__init__.py
@@ -1,4 +1,5 @@
# This file is auto-generated by gen_schema_exports.py
+from .general import OpenAIText
from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
@@ -9,4 +10,5 @@ __all__ = [
"OpenAIRecipeIngredient",
"OpenAIRecipeInstruction",
"OpenAIRecipeNotes",
+ "OpenAIText",
]
diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py
index 07e1c46b8..a323b6e34 100644
--- a/mealie/schema/response/__init__.py
+++ b/mealie/schema/response/__init__.py
@@ -7,15 +7,6 @@ from .pagination import (
RecipeSearchQuery,
RequestQuery,
)
-from .query_filter import (
- LogicalOperator,
- QueryFilterBuilder,
- QueryFilterBuilderComponent,
- QueryFilterJSON,
- QueryFilterJSONPart,
- RelationalKeyword,
- RelationalOperator,
-)
from .query_search import SearchFilter
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
from .validation import ValidationResponse
@@ -25,13 +16,6 @@ __all__ = [
"FileTokenResponse",
"SuccessResponse",
"SearchFilter",
- "LogicalOperator",
- "QueryFilterBuilder",
- "QueryFilterBuilderComponent",
- "QueryFilterJSON",
- "QueryFilterJSONPart",
- "RelationalKeyword",
- "RelationalOperator",
"OrderByNullPosition",
"OrderDirection",
"PaginationBase",
diff --git a/mealie/services/query_filter/__init__.py b/mealie/services/query_filter/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/mealie/schema/response/query_filter.py b/mealie/services/query_filter/builder.py
similarity index 90%
rename from mealie/schema/response/query_filter.py
rename to mealie/services/query_filter/builder.py
index ef093a89d..3f65aa179 100644
--- a/mealie/schema/response/query_filter.py
+++ b/mealie/services/query_filter/builder.py
@@ -2,7 +2,6 @@ from __future__ import annotations
import re
from collections import deque
-from enum import Enum
from typing import Any, cast
from uuid import UUID
@@ -19,88 +18,8 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
from mealie.schema._mealie.mealie_model import MealieModel
-
-class RelationalKeyword(Enum):
- IS = "IS"
- IS_NOT = "IS NOT"
- IN = "IN"
- NOT_IN = "NOT IN"
- CONTAINS_ALL = "CONTAINS ALL"
- LIKE = "LIKE"
- NOT_LIKE = "NOT LIKE"
-
- @classmethod
- def parse_component(cls, component: str) -> list[str] | None:
- """
- Try to parse a component using a relational keyword
-
- If no matching keyword is found, returns None
- """
-
- # extract the attribute name from the component
- parsed_component = component.split(maxsplit=1)
- if len(parsed_component) < 2:
- return None
-
- # assume the component has already filtered out the value and try to match a keyword
- # if we try to filter out the value without checking first, keywords with spaces won't parse correctly
- possible_keyword = parsed_component[1].strip().lower()
- for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
- if rel_kw.lower() != possible_keyword:
- continue
-
- parsed_component[1] = rel_kw
- return parsed_component
-
- # there was no match, so the component may still have the value in it
- try:
- _possible_keyword, _value = parsed_component[-1].rsplit(maxsplit=1)
- parsed_component = [parsed_component[0], _possible_keyword, _value]
- except ValueError:
- # the component has no value to filter out
- return None
-
- possible_keyword = parsed_component[1].strip().lower()
- for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
- if rel_kw.lower() != possible_keyword:
- continue
-
- parsed_component[1] = rel_kw
- return parsed_component
-
- return None
-
-
-class RelationalOperator(Enum):
- EQ = "="
- NOTEQ = "<>"
- GT = ">"
- LT = "<"
- GTE = ">="
- LTE = "<="
-
- @classmethod
- def parse_component(cls, component: str) -> list[str] | None:
- """
- Try to parse a component using a relational operator
-
- If no matching operator is found, returns None
- """
-
- for rel_op in sorted([operator.value for operator in cls], key=len, reverse=True):
- if rel_op not in component:
- continue
-
- parsed_component = [base_component.strip() for base_component in component.split(rel_op) if base_component]
- parsed_component.insert(1, rel_op)
- return parsed_component
-
- return None
-
-
-class LogicalOperator(Enum):
- AND = "AND"
- OR = "OR"
+from .keywords import PlaceholderKeyword, RelationalKeyword
+from .operators import LogicalOperator, RelationalOperator
class QueryFilterJSONPart(MealieModel):
@@ -161,6 +80,9 @@ class QueryFilterBuilderComponent:
else:
self.value = value
+ # process placeholder keywords
+ self.value = PlaceholderKeyword.parse_value(self.value)
+
def __repr__(self) -> str:
return f"[{self.attribute_name} {self.relationship.value} {self.value}]"
diff --git a/mealie/services/query_filter/keywords.py b/mealie/services/query_filter/keywords.py
new file mode 100644
index 000000000..0aa98ea41
--- /dev/null
+++ b/mealie/services/query_filter/keywords.py
@@ -0,0 +1,154 @@
+from datetime import datetime
+from enum import Enum
+from typing import overload
+
+from dateutil.relativedelta import relativedelta
+
+
+class PlaceholderKeyword(Enum):
+ NOW = "$NOW"
+
+ @classmethod
+ def _parse_now(cls, value: str) -> str:
+ """
+ Parses a NOW value, with optional math using an int or float.
+
+ Operation:
+ - '+'
+ - '-'
+
+ Unit:
+ - 'y' (year)
+ - 'm' (month)
+ - 'd' (day)
+ - 'H' (hour)
+ - 'M' (minute)
+ - 'S' (second)
+
+ Examples:
+ - '$NOW'
+ - '$NOW+30d'
+ - '$NOW-5M'
+ """
+
+ if not value.startswith(cls.NOW.value):
+ return value
+
+ now = datetime.now(tz=None) # noqa: DTZ005
+ remainder = value[len(cls.NOW.value) :]
+
+ if remainder:
+ if len(remainder) < 3:
+ raise ValueError(f"Invalid remainder in NOW string ({value})")
+
+ op = remainder[0]
+ amount_str = remainder[1:-1]
+ unit = remainder[-1]
+
+ try:
+ amount = int(amount_str)
+ except Exception as e:
+ raise ValueError(f"Invalid amount in NOW string ({value})") from e
+
+ if op == "-":
+ amount = -amount
+ elif op != "+":
+ raise ValueError(f"Invalid operator in NOW string ({value})")
+
+ if unit == "y":
+ delta = relativedelta(years=amount)
+ elif unit == "m":
+ delta = relativedelta(months=amount)
+ elif unit == "d":
+ delta = relativedelta(days=amount)
+ elif unit == "H":
+ delta = relativedelta(hours=amount)
+ elif unit == "M":
+ delta = relativedelta(minutes=amount)
+ elif unit == "S":
+ delta = relativedelta(seconds=amount)
+ else:
+ raise ValueError(f"Invalid time unit in NOW string ({value})")
+
+ dt = now + delta
+
+ else:
+ dt = now
+
+ return dt.isoformat()
+
+ @overload
+ @classmethod
+ def parse_value(cls, value: str) -> str: ...
+
+ @overload
+ @classmethod
+ def parse_value(cls, value: list[str]) -> list[str]: ...
+
+ @overload
+ @classmethod
+ def parse_value(cls, value: None) -> None: ...
+
+ @classmethod
+ def parse_value(cls, value: str | list[str] | None) -> str | list[str] | None:
+ if not value:
+ return value
+
+ if isinstance(value, list):
+ return [cls.parse_value(v) for v in value]
+
+ if value.startswith(PlaceholderKeyword.NOW.value):
+ return cls._parse_now(value)
+
+ return value
+
+
+class RelationalKeyword(Enum):
+ IS = "IS"
+ IS_NOT = "IS NOT"
+ IN = "IN"
+ NOT_IN = "NOT IN"
+ CONTAINS_ALL = "CONTAINS ALL"
+ LIKE = "LIKE"
+ NOT_LIKE = "NOT LIKE"
+
+ @classmethod
+ def parse_component(cls, component: str) -> list[str] | None:
+ """
+ Try to parse a component using a relational keyword
+
+ If no matching keyword is found, returns None
+ """
+
+ # extract the attribute name from the component
+ parsed_component = component.split(maxsplit=1)
+ if len(parsed_component) < 2:
+ return None
+
+ # assume the component has already filtered out the value and try to match a keyword
+ # if we try to filter out the value without checking first, keywords with spaces won't parse correctly
+ possible_keyword = parsed_component[1].strip().lower()
+ for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
+ if rel_kw.lower() != possible_keyword:
+ continue
+
+ parsed_component[1] = rel_kw
+ return parsed_component
+
+ # there was no match, so the component may still have the value in it
+ try:
+ _possible_keyword, _value = parsed_component[-1].rsplit(maxsplit=1)
+ parsed_component = [parsed_component[0], _possible_keyword, _value]
+ except ValueError:
+ # the component has no value to filter out
+ return None
+
+ possible_keyword = parsed_component[1].strip().lower()
+ for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
+ if rel_kw.lower() != possible_keyword:
+ continue
+
+ parsed_component[1] = rel_kw
+ return parsed_component
+
+ return None
diff --git a/mealie/services/query_filter/operators.py b/mealie/services/query_filter/operators.py
new file mode 100644
index 000000000..1757c5607
--- /dev/null
+++ b/mealie/services/query_filter/operators.py
@@ -0,0 +1,33 @@
+from enum import Enum
+
+
+class LogicalOperator(Enum):
+ AND = "AND"
+ OR = "OR"
+
+
+class RelationalOperator(Enum):
+ EQ = "="
+ NOTEQ = "<>"
+ GT = ">"
+ LT = "<"
+ GTE = ">="
+ LTE = "<="
+
+ @classmethod
+ def parse_component(cls, component: str) -> list[str] | None:
+ """
+ Try to parse a component using a relational operator
+
+ If no matching operator is found, returns None
+ """
+
+ for rel_op in sorted([operator.value for operator in cls], key=len, reverse=True):
+ if rel_op not in component:
+ continue
+
+ parsed_component = [base_component.strip() for base_component in component.split(rel_op) if base_component]
+ parsed_component.insert(1, rel_op)
+ return parsed_component
+
+ return None
diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py
index ecee158a8..61e216319 100644
--- a/tests/unit_tests/repository_tests/test_pagination.py
+++ b/tests/unit_tests/repository_tests/test_pagination.py
@@ -6,6 +6,7 @@ from random import randint
from urllib.parse import parse_qsl, urlsplit
import pytest
+from dateutil.relativedelta import relativedelta
from fastapi.testclient import TestClient
from humps import camelize
from pydantic import UUID4
@@ -34,6 +35,7 @@ from mealie.schema.response.pagination import (
PaginationQuery,
)
from mealie.schema.user.user import UserRatingUpdate
+from mealie.services.query_filter.builder import PlaceholderKeyword
from mealie.services.seeder.seeder_service import SeederService
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
@@ -1567,3 +1569,195 @@ def test_pagination_filter_by_custom_rating(api_client: TestClient, user_tuple:
recipes_data = response.json()["items"]
assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_2.id)
+
+
+def test_parse_now_with_remainder_too_short():
+ with pytest.raises(ValueError, match="Invalid remainder"):
+ PlaceholderKeyword._parse_now("$NOW+d")
+
+
+def test_parse_now_without_arithmetic():
+ result = PlaceholderKeyword._parse_now("$NOW")
+ assert isinstance(result, str)
+ dt = datetime.fromisoformat(result)
+ assert isinstance(dt, datetime)
+
+
+def test_parse_now_passthrough_non_placeholder():
+ test_string = "2024-01-15"
+ result = PlaceholderKeyword._parse_now(test_string)
+ assert result == test_string
+
+
+def test_parse_now_with_int_amount():
+ before = datetime.now(UTC)
+ result = PlaceholderKeyword._parse_now("$NOW+30d")
+ after = datetime.now(UTC)
+ assert isinstance(result, str)
+ dt = datetime.fromisoformat(result)
+ assert isinstance(dt, datetime)
+ # Verify offset is exactly 30 days from when the function was called
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ expected_min = before + timedelta(days=30)
+ expected_max = after + timedelta(days=30)
+ assert expected_min <= dt <= expected_max
+
+
+def test_parse_now_with_single_digit_int():
+ before = datetime.now(UTC)
+ result = PlaceholderKeyword._parse_now("$NOW+1d")
+ after = datetime.now(UTC)
+ assert isinstance(result, str)
+ dt = datetime.fromisoformat(result)
+ assert isinstance(dt, datetime)
+ # Verify offset is exactly 1 day from when the function was called
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ expected_min = before + timedelta(days=1)
+ expected_max = after + timedelta(days=1)
+ assert expected_min <= dt <= expected_max
+
+
+@pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"])
+def test_parse_now_with_invalid_amount(invalid_amount):
+ with pytest.raises(ValueError, match="Invalid amount"):
+ PlaceholderKeyword._parse_now(f"$NOW+{invalid_amount}d")
+
+
+@pytest.mark.parametrize(
+ "unit,offset_delta",
+ [
+ ("y", relativedelta(years=5)),
+ ("m", relativedelta(months=5)),
+ ("d", timedelta(days=5)),
+ ("H", timedelta(hours=5)),
+ ("M", timedelta(minutes=5)),
+ ("S", timedelta(seconds=5)),
+ ],
+)
+def test_parse_now_with_valid_units(unit, offset_delta):
+ before = datetime.now(UTC)
+ result = PlaceholderKeyword._parse_now(f"$NOW+5{unit}")
+ after = datetime.now(UTC)
+ assert isinstance(result, str)
+ dt = datetime.fromisoformat(result)
+ assert isinstance(dt, datetime)
+ # Verify offset is correct from when the function was called
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ expected_min = before + offset_delta
+ expected_max = after + offset_delta
+ assert expected_min <= dt <= expected_max
+
+
+def test_parse_now_with_invalid_unit():
+ with pytest.raises(ValueError, match="Invalid time unit"):
+ PlaceholderKeyword._parse_now("$NOW+1x")
+
+
+def test_parse_now_with_missing_unit():
+ with pytest.raises(ValueError, match="Invalid remainder"):
+ PlaceholderKeyword._parse_now("$NOW+1")
+
+
+@pytest.mark.parametrize("operation,expected_sign", [("+", 1), ("-", -1)])
+def test_parse_now_with_valid_operations(operation, expected_sign):
+ before = datetime.now(UTC)
+ result = PlaceholderKeyword._parse_now(f"$NOW{operation}5d")
+ after = datetime.now(UTC)
+ assert isinstance(result, str)
+ dt = datetime.fromisoformat(result)
+ assert isinstance(dt, datetime)
+ # Verify offset direction is correct from when the function was called
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ expected_min = before + (timedelta(days=5) * expected_sign)
+ expected_max = after + (timedelta(days=5) * expected_sign)
+ assert expected_min <= dt <= expected_max
+
+
+@pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"])
+def test_parse_now_with_invalid_operations(invalid_operation):
+ with pytest.raises(ValueError, match="Invalid operator"):
+ PlaceholderKeyword._parse_now(f"$NOW{invalid_operation}5d")
+
+
+@pytest.mark.parametrize(
+ "placeholder,included_dates,excluded_dates",
+ [
+ pytest.param(
+ "$NOW",
+ ["today", "tomorrow"],
+ ["yesterday"],
+ id="now_current_day",
+ ),
+ pytest.param(
+ "$NOW+1d",
+ ["tomorrow", "day_after_tomorrow"],
+ ["yesterday", "today"],
+ id="now_plus_one_day",
+ ),
+ ],
+)
+def test_e2e_parse_now_placeholder(
+ api_client: TestClient,
+ unique_user: TestUser,
+ placeholder: str,
+ included_dates: list[str],
+ excluded_dates: list[str],
+):
+ """E2E test for parsing $NOW and $NOW+Xd placeholders in datetime filters"""
+ # Create recipes for testing
+ recipe_yesterday = unique_user.repos.recipes.create(
+ Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
+ )
+ recipe_today = unique_user.repos.recipes.create(
+ Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
+ )
+ recipe_tomorrow = unique_user.repos.recipes.create(
+ Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
+ )
+ recipe_day_after = unique_user.repos.recipes.create(
+ Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
+ )
+
+ date_map = {
+ "yesterday": (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%d"),
+ "today": datetime.now(UTC).strftime("%Y-%m-%d"),
+ "tomorrow": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"),
+ "day_after_tomorrow": (datetime.now(UTC) + timedelta(days=2)).strftime("%Y-%m-%d"),
+ }
+
+ recipe_map = {
+ "yesterday": recipe_yesterday,
+ "today": recipe_today,
+ "tomorrow": recipe_tomorrow,
+ "day_after_tomorrow": recipe_day_after,
+ }
+
+ for date_name, recipe in recipe_map.items():
+ date_str = date_map[date_name]
+ datetime_str = f"{date_str}T12:00:00Z"
+ r = api_client.patch(
+ api_routes.recipes_slug_last_made(recipe.slug),
+ json={"timestamp": datetime_str},
+ headers=unique_user.token,
+ )
+ assert r.status_code == 200
+
+ # Query using placeholder
+ params = {"page": 1, "perPage": -1, "queryFilter": f'lastMade >= "{placeholder}"'}
+ response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
+ assert response.status_code == 200
+ recipes_data = response.json()["items"]
+ result_ids = {recipe["id"] for recipe in recipes_data}
+
+ # Verify included and excluded recipes
+ for date_name in included_dates:
+ recipe_id = str(recipe_map[date_name].id)
+ assert recipe_id in result_ids, f"{date_name} should be included with {placeholder}"
+
+ for date_name in excluded_dates:
+ recipe_id = str(recipe_map[date_name].id)
+ assert recipe_id not in result_ids, f"{date_name} should be excluded with {placeholder}"
diff --git a/tests/unit_tests/repository_tests/test_query_filter_builder.py b/tests/unit_tests/repository_tests/test_query_filter_builder.py
index 9771b1b1b..f930088bf 100644
--- a/tests/unit_tests/repository_tests/test_query_filter_builder.py
+++ b/tests/unit_tests/repository_tests/test_query_filter_builder.py
@@ -1,4 +1,4 @@
-from mealie.schema.response.query_filter import (
+from mealie.services.query_filter.builder import (
LogicalOperator,
QueryFilterBuilder,
QueryFilterJSON,