mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-10 02:50:19 -04:00
Compare commits
19 Commits
chore/docs
...
mealie-nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc50145ec | ||
|
|
fe9fb77316 | ||
|
|
204bd2aeb3 | ||
|
|
b8d5375478 | ||
|
|
4289860ffd | ||
|
|
a1c724fac4 | ||
|
|
ac0bb4fb2c | ||
|
|
040ec56c18 | ||
|
|
f025bbce57 | ||
|
|
47c6d01617 | ||
|
|
653be9a604 | ||
|
|
2d8b74282a | ||
|
|
48752bcd06 | ||
|
|
a46620d236 | ||
|
|
3bde6df958 | ||
|
|
e1ddc06eff | ||
|
|
262b531add | ||
|
|
364af97060 | ||
|
|
7b0d1fde64 |
@@ -44,6 +44,7 @@
|
||||
8000, // used by mkdocs
|
||||
9000,
|
||||
9091, // used by docker production
|
||||
51204, // used for test coverage report
|
||||
24678 // used by nuxt when hot-reloading using polling
|
||||
],
|
||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -63,7 +63,7 @@ task setup # Install all dependencies (Python + Node)
|
||||
task dev:services # Start Postgres & Mailpit containers
|
||||
task py # Start FastAPI backend (port 9000)
|
||||
task ui # Start Nuxt frontend (port 3000)
|
||||
task docs # Start Zensical documentation server
|
||||
task docs # Start MkDocs documentation server
|
||||
```
|
||||
|
||||
**Code generation (REQUIRED after schema changes):**
|
||||
|
||||
6
.github/workflows/docs.yml
vendored
6
.github/workflows/docs.yml
vendored
@@ -20,6 +20,10 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Install from the committed lockfile; never re-resolve (see pyproject
|
||||
# [tool.uv] exclude-newer cooling window).
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -30,7 +34,7 @@ jobs:
|
||||
run: uv sync --only-group docs --no-install-project
|
||||
|
||||
- name: Build docs
|
||||
run: uv run --no-project zensical build
|
||||
run: uv run --no-project mkdocs build -d site
|
||||
working-directory: docs
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
4
.github/workflows/locale-sync.yml
vendored
4
.github/workflows/locale-sync.yml
vendored
@@ -14,6 +14,10 @@ permissions:
|
||||
jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Install from the committed lockfile; never re-resolve (see pyproject
|
||||
# [tool.uv] exclude-newer cooling window).
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
|
||||
49
.github/workflows/pull-request-lint.yml
vendored
49
.github/workflows/pull-request-lint.yml
vendored
@@ -3,7 +3,7 @@ name: Pull Request Linter
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [edited] # This captures the PR title changing
|
||||
types: [edited, reopened] # This captures the PR title/body changing
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
@@ -41,3 +41,50 @@ jobs:
|
||||
ignoreLabels: |
|
||||
bot
|
||||
ignore-semantic-pull-request
|
||||
|
||||
validate-template:
|
||||
name: Validate PR template
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check required PR template sections
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
console.log("Skipping template check for bot");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: ".github/pull_request_template.md",
|
||||
});
|
||||
|
||||
const template = Buffer.from(response.data.content, "base64").toString("utf8");
|
||||
const lines = template.split("\n");
|
||||
|
||||
const requiredHeadings = [];
|
||||
let lastHeading = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
lastHeading = line.trim();
|
||||
} else if (line.trim() === "_(REQUIRED)_" && lastHeading) {
|
||||
requiredHeadings.push(lastHeading);
|
||||
lastHeading = null;
|
||||
}
|
||||
}
|
||||
|
||||
const body = pr.body || "";
|
||||
const missing = requiredHeadings.filter(h => !body.includes(h));
|
||||
|
||||
if (missing.length > 0) {
|
||||
core.setFailed(`Missing headings:\n${missing.join("\n")}`);
|
||||
} else {
|
||||
console.log("All required headings present");
|
||||
}
|
||||
|
||||
2
.github/workflows/pull-requests.yml
vendored
2
.github/workflows/pull-requests.yml
vendored
@@ -18,6 +18,8 @@ jobs:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
backend-tests:
|
||||
name: "Backend Server Tests"
|
||||
|
||||
4
.github/workflows/test-backend.yml
vendored
4
.github/workflows/test-backend.yml
vendored
@@ -13,6 +13,10 @@ jobs:
|
||||
|
||||
env:
|
||||
PRODUCTION: false
|
||||
# Install from the committed lockfile; never re-resolve. The rolling
|
||||
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
|
||||
# every uv command re-resolve and fail on in-window pins.
|
||||
UV_FROZEN: "1"
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
||||
30
.vscode/test-block.code-snippets
vendored
Normal file
30
.vscode/test-block.code-snippets
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Test Block": {
|
||||
"prefix": "mtest",
|
||||
"body": [
|
||||
"import { mount } from \"@vue/test-utils\";",
|
||||
"import { describe, expect, test, vi } from \"vitest\";",
|
||||
"import { makeWrapper } from \"~/tests/utils\";",
|
||||
"",
|
||||
"const wrapper = () => makeWrapper(() => {",
|
||||
" return ${1:composable}();",
|
||||
"});",
|
||||
"",
|
||||
"describe(\"${TM_FILENAME_BASE/(.*)\\..+$/$1/}\", () => {",
|
||||
" describe(\"${2:method}\", () => {",
|
||||
" test(\"It does the thing\", () => {",
|
||||
" const { ${2:method} } = wrapper();",
|
||||
" const result = ${2:method}();",
|
||||
" expect(result).toBe(EXPECTED);",
|
||||
" });",
|
||||
" });",
|
||||
"});",
|
||||
"",
|
||||
],
|
||||
"description": "Insert a test block",
|
||||
"scope": "typescript",
|
||||
"include": [
|
||||
"**/*.test.{ts,tsx,vue}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ env:
|
||||
DEFAULT_GROUP: Home
|
||||
DEFAULT_HOUSEHOLD: Family
|
||||
PRODUCTION: false
|
||||
# Install from the committed lockfile; never re-resolve. Required because the
|
||||
# rolling `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise
|
||||
# make every `uv run`/`uv sync` re-resolve and fail on in-window pins.
|
||||
UV_FROZEN: "1"
|
||||
API_PORT: 9000
|
||||
API_DOCS: True
|
||||
TOKEN_TIME: 256 # hours
|
||||
@@ -29,7 +33,7 @@ tasks:
|
||||
desc: runs the documentation server
|
||||
dir: docs
|
||||
cmds:
|
||||
- uv run zensical serve
|
||||
- uv run python -m mkdocs serve
|
||||
|
||||
setup:ui:
|
||||
desc: setup frontend dependencies
|
||||
|
||||
@@ -52,6 +52,11 @@ RUN apt-get update \
|
||||
|
||||
RUN pip install uv
|
||||
|
||||
# Install from the committed lockfile; never re-resolve. The rolling
|
||||
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
|
||||
# `uv export` below re-resolve and fail on in-window pins.
|
||||
ENV UV_FROZEN=1
|
||||
|
||||
WORKDIR /mealie
|
||||
|
||||
# copy project files here to ensure they will be cached.
|
||||
|
||||
@@ -17,31 +17,6 @@
|
||||
--md-default-accent-bg-color: #1f1e1e;
|
||||
}
|
||||
|
||||
/*
|
||||
* Zensical's "modern" theme ships a flat header that uses the page background
|
||||
* color. Restore the classic Material colored app-bar (and matching nav tabs)
|
||||
* driven by the brand orange, and brand the link color to match.
|
||||
*/
|
||||
.md-header,
|
||||
.md-tabs {
|
||||
background-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-bg-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* Brand the accent colors. The modern theme leaves links, the nav active-item
|
||||
* pill, and the announce banner on its default indigo accent (the latter two
|
||||
* via --md-accent-fg-color--transparent), which clashes with the orange. The
|
||||
* [scheme][primary] selector matches the theme's own (0,2,0) accent rules so
|
||||
* the override wins in both light (mealie) and dark (slate) modes.
|
||||
*/
|
||||
[data-md-color-scheme="mealie"][data-md-color-primary="indigo"],
|
||||
[data-md-color-scheme="slate"][data-md-color-primary="indigo"] {
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--md-accent-fg-color: #e58325;
|
||||
--md-accent-fg-color--transparent: #e583251a;
|
||||
}
|
||||
|
||||
/* frontpage elements */
|
||||
.tx-hero h1 {
|
||||
font-size: 2.41rem !important;
|
||||
@@ -89,7 +64,7 @@ th {
|
||||
}
|
||||
|
||||
.announce-left > a {
|
||||
color: var(--md-default-fg-color);
|
||||
color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ If you have another provider you'd like to use, such as Azure, you can configure
|
||||
|
||||
Note that some models are capable of handling multiple features (e.g. `gpt-5` can handle both normal chat requests and image recognition requests). You may configure one provider for multiple provider features.
|
||||
|
||||
While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./backend-config.md).
|
||||
While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./installation/ai-providers.md).
|
||||
|
||||
## AI Features
|
||||
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
|
||||
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
|
||||
| SECURITY_USER_LOCKOUT_TIME | 24 | Time in hours for how long a users account is locked |
|
||||
| ALLOWED_IFRAME_HOSTS | `""` | Comma-separated extra hostnames allowed as `<iframe>` sources in recipe content. Extends the built-in list of trusted video providers (YouTube, Vimeo). Subdomains are included automatically. Only `https` sources are permitted. Adding hosts here opts into rendering embeds from those origins to all viewers, including the public, so add only origins you trust. |
|
||||
|
||||
### Database
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
class="md-button md-button--primary">
|
||||
Get started
|
||||
</a>
|
||||
<a href="{{ config.extra.demo_url }}" title="View the Mealie demo" target="_blank" class="md-button">
|
||||
<a href="{{ config.demo_url }}" title="{{ lang.t('source.link.title') }}" target="_blank" class="md-button">
|
||||
View the Demo
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
site_name: Mealie
|
||||
demo_url: https://demo.mealie.io
|
||||
site_url: https://docs.mealie.io
|
||||
use_directory_urls: true
|
||||
theme:
|
||||
@@ -15,7 +16,7 @@ theme:
|
||||
toggle:
|
||||
icon: material/weather-sunny
|
||||
name: Switch to light mode
|
||||
custom_dir: overrides
|
||||
custom_dir: docs/overrides
|
||||
features:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
@@ -27,9 +28,6 @@ theme:
|
||||
- navigation.tabs.sticky
|
||||
favicon: assets/img/favicon.png
|
||||
name: material
|
||||
font:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
icon:
|
||||
logo: material/silverware-variant
|
||||
|
||||
@@ -52,8 +50,6 @@ markdown_extensions:
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.details
|
||||
extra:
|
||||
demo_url: https://demo.mealie.io
|
||||
extra_css:
|
||||
- assets/stylesheets/custom.css
|
||||
extra_javascript:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>
|
||||
To harden Mealie against malicious content, <code><iframe></code> embeds in recipe
|
||||
instructions, notes, and descriptions are now restricted to a trusted set of hosts.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
By default, embeds are allowed only from well-known video providers:
|
||||
<ul class="ml-6">
|
||||
<li>YouTube</li>
|
||||
<li>Vimeo</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
Existing recipes that embed content from <strong>other</strong> hosts will no longer render
|
||||
those embeds. The rest of the recipe is unaffected.
|
||||
</p>
|
||||
<div v-if="user?.admin">
|
||||
<hr class="mt-2 mb-4">
|
||||
<p>
|
||||
As an admin, you can allow additional hosts with the <code>ALLOWED_IFRAME_HOSTS</code>
|
||||
environment variable (comma-separated). It extends the built-in defaults, and only
|
||||
<code>https</code> sources are permitted. See the configuration docs for details:
|
||||
<br>
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
color="primary"
|
||||
href="https://docs.mealie.io/documentation/getting-started/installation/backend-config/"
|
||||
target="_blank"
|
||||
>
|
||||
Backend Configuration
|
||||
</v-btn>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||
|
||||
const { user } = useMealieAuth();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const meta: AnnouncementMeta = {
|
||||
title: "Recipe embeds restricted to trusted hosts",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
p {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -59,9 +59,10 @@
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||
<p v-if="deleteTarget" class="mt-4 mb-0 font-weight-bold">
|
||||
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||
</p>
|
||||
<slot name="delete-dialog-bottom" />
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -88,6 +89,7 @@
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
<slot name="delete-dialog-bottom" />
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -151,7 +153,7 @@ const createDialog = defineModel("createDialog", { type: Boolean, default: false
|
||||
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -185,6 +187,10 @@ defineProps({
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
onDeleteDialogOpen: {
|
||||
type: Function as PropType<(items: any[]) => Promise<void>>,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -212,8 +218,11 @@ const editEventHandler = (item: any) => {
|
||||
const deleteTarget = ref<any>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function deleteEventHandler(item: any) {
|
||||
async function deleteEventHandler(item: any) {
|
||||
deleteTarget.value = item;
|
||||
if (props.onDeleteDialogOpen) {
|
||||
await props.onDeleteDialogOpen([item]);
|
||||
}
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
@@ -222,8 +231,11 @@ function deleteEventHandler(item: any) {
|
||||
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||
const bulkDeleteDialog = ref(false);
|
||||
|
||||
function bulkDeleteEventHandler(items: Array<any>) {
|
||||
async function bulkDeleteEventHandler(items: Array<any>) {
|
||||
bulkDeleteTarget.value = items;
|
||||
if (props.onDeleteDialogOpen) {
|
||||
await props.onDeleteDialogOpen(items);
|
||||
}
|
||||
bulkDeleteDialog.value = true;
|
||||
console.log("Bulk Delete Event Handler", items);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { truncateText as truncatePlainText } from "~/lib/sanitize/text";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
@@ -50,10 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
defineEmits(["item-selected"]);
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
if (!props.truncate) return text;
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = text;
|
||||
const content = node.textContent || "";
|
||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
||||
return truncatePlainText(text, length, clamp);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
}
|
||||
import { sanitizeMarkdownHtml } from "~/lib/sanitize/markdown";
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
@@ -18,48 +14,11 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const ALLOWED_STYLE_TAGS = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
],
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
const { $appInfo } = useNuxtApp();
|
||||
|
||||
const value = computed(() => {
|
||||
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
||||
return sanitizeMarkdown(rawHtml);
|
||||
return sanitizeMarkdownHtml(rawHtml, $appInfo?.allowedIframeHosts ?? []);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IngredientFood, RecipeSummary, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
|
||||
|
||||
export const MOCK_ITEM: ShoppingListItemOut = {
|
||||
shoppingListId: "",
|
||||
id: "",
|
||||
groupId: "",
|
||||
householdId: "",
|
||||
display: "MOCK_ITEM",
|
||||
updatedAt: "100",
|
||||
position: 1,
|
||||
checked: false,
|
||||
createdAt: "100",
|
||||
};
|
||||
|
||||
export const MOCK_RECIPE: RecipeSummary = {
|
||||
id: "recipe-id",
|
||||
name: "Recipe!",
|
||||
};
|
||||
|
||||
export const MOCK_RECIPE2: RecipeSummary = {
|
||||
...MOCK_RECIPE,
|
||||
id: undefined,
|
||||
name: "Recipe 2!",
|
||||
};
|
||||
|
||||
export const MOCK_FOOD: IngredientFood = {
|
||||
id: "1",
|
||||
name: "food 1",
|
||||
};
|
||||
|
||||
export const MOCK_FOOD2: IngredientFood = {
|
||||
id: "2",
|
||||
name: "food 2",
|
||||
};
|
||||
|
||||
export const MOCK_LABEL: ShoppingListMultiPurposeLabelOut = {
|
||||
shoppingListId: "",
|
||||
labelId: "",
|
||||
id: "",
|
||||
label: {
|
||||
name: "MOCK_LABEL",
|
||||
groupId: "",
|
||||
id: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_LABEL2: ShoppingListMultiPurposeLabelOut = {
|
||||
shoppingListId: "",
|
||||
labelId: "",
|
||||
id: "",
|
||||
label: {
|
||||
name: "MOCK_LABEL2",
|
||||
groupId: "",
|
||||
id: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_SHOPPING_LIST: ShoppingListOut = {
|
||||
groupId: "",
|
||||
userId: "",
|
||||
id: "",
|
||||
householdId: "",
|
||||
labelSettings: [
|
||||
MOCK_LABEL,
|
||||
MOCK_LABEL2,
|
||||
],
|
||||
listItems: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
recipeReferences: [{
|
||||
id: "",
|
||||
shoppingListId: "",
|
||||
recipeId: "",
|
||||
recipeQuantity: 0,
|
||||
recipe: MOCK_RECIPE,
|
||||
}, {
|
||||
id: "",
|
||||
shoppingListId: "",
|
||||
recipeId: "",
|
||||
recipeQuantity: 0,
|
||||
recipe: MOCK_RECIPE2,
|
||||
}],
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as vueusecore from "@vueuse/core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListCopy } from "../use-shopping-list-copy";
|
||||
import { MOCK_ITEM } from "./mocks";
|
||||
|
||||
vi.mock("@vueuse/core", { spy: true });
|
||||
|
||||
const mockCopy = vi.fn().mockImplementation(args => new Promise(resolve => resolve(args)));
|
||||
|
||||
vi.mocked(vueusecore.useClipboard).mockImplementation(() => {
|
||||
return {
|
||||
isSupported: computed(() => true),
|
||||
copied: computed(() => true),
|
||||
text: computed(() => ""),
|
||||
copy: mockCopy,
|
||||
};
|
||||
});
|
||||
const wrapper = () => makeWrapper(useShoppingListCopy);
|
||||
|
||||
const TEST_HEADER = "SPECIAL HEADER!";
|
||||
|
||||
const MOCK_LIST: { [key: string]: ShoppingListItemOut[] } = {
|
||||
[TEST_HEADER]: [MOCK_ITEM],
|
||||
[TEST_HEADER + "2"]: [MOCK_ITEM],
|
||||
};
|
||||
|
||||
describe("Shopping list copy composable", () => {
|
||||
describe("copyListItems", () => {
|
||||
test("copies markdown lists correctly", () => {
|
||||
const { copyListItems } = wrapper();
|
||||
copyListItems(MOCK_LIST, "markdown");
|
||||
const expected = [
|
||||
"# SPECIAL HEADER!",
|
||||
"- [ ] MOCK_ITEM",
|
||||
"",
|
||||
"# SPECIAL HEADER!2",
|
||||
"- [ ] MOCK_ITEM",
|
||||
].join("\n");
|
||||
|
||||
expect(mockCopy).toBeCalledWith(expected);
|
||||
});
|
||||
test("copies plain text lists correctly", () => {
|
||||
const { copyListItems } = wrapper();
|
||||
copyListItems(MOCK_LIST, "plain");
|
||||
const expected = [
|
||||
"[SPECIAL HEADER!]",
|
||||
"MOCK_ITEM",
|
||||
"",
|
||||
"[SPECIAL HEADER!2]",
|
||||
"MOCK_ITEM",
|
||||
].join("\n");
|
||||
|
||||
expect(mockCopy).toBeCalledWith(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCopiedLabelHeading", () => {
|
||||
test("copies markdown headers correctly", () => {
|
||||
const { formatCopiedLabelHeading } = wrapper();
|
||||
const header = formatCopiedLabelHeading("markdown", TEST_HEADER);
|
||||
expect(header).toEqual(`# ${TEST_HEADER}`);
|
||||
});
|
||||
test("copies plain text headers correctly", () => {
|
||||
const { formatCopiedLabelHeading } = wrapper();
|
||||
const header = formatCopiedLabelHeading("plain", TEST_HEADER);
|
||||
expect(header).toEqual(`[${TEST_HEADER}]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCopiedListItem", () => {
|
||||
test("copies markdown items correctly", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("markdown", MOCK_ITEM);
|
||||
expect(header).toEqual(`- [ ] ${MOCK_ITEM.display}`);
|
||||
});
|
||||
test("copies plain text items correctly", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("plain", MOCK_ITEM);
|
||||
expect(header).toEqual(MOCK_ITEM.display);
|
||||
});
|
||||
test("copies items without a display as empty", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("plain", { ...MOCK_ITEM, display: undefined });
|
||||
expect(header).toEqual("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListSorting } from "../use-shopping-list-sorting";
|
||||
import { MOCK_FOOD, MOCK_FOOD2, MOCK_ITEM, MOCK_LABEL, MOCK_LABEL2, MOCK_SHOPPING_LIST } from "./mocks";
|
||||
|
||||
const wrapper = () => makeWrapper(() => {
|
||||
const { t } = useI18n();
|
||||
return {
|
||||
t,
|
||||
...useShoppingListSorting(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("use-shopping-list-sorting", () => {
|
||||
describe("sortItems", () => {
|
||||
const { sortItems } = wrapper();
|
||||
test("sorts by position first", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, position: 0 }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("sorts by createdAt next", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: "0" });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, createdAt: "0" }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("sorts similar items into the same spot", () => {
|
||||
const result = sortItems(MOCK_ITEM, MOCK_ITEM);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: undefined });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, position: undefined }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: undefined });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, createdAt: undefined }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
});
|
||||
describe("sortListItems", () => {
|
||||
const { sortListItems } = wrapper();
|
||||
test("sorts by position first", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: [MOCK_ITEM, { ...MOCK_ITEM, position: 0 }, { ...MOCK_ITEM, createdAt: "0" }] };
|
||||
sortListItems(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, position: 0 },
|
||||
{ ...MOCK_ITEM, createdAt: "0" },
|
||||
MOCK_ITEM,
|
||||
]);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: undefined };
|
||||
sortListItems(sortedList);
|
||||
expect(sortedList.listItems).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
describe("updateItemsByLabel", () => {
|
||||
const { updateItemsByLabel, t } = wrapper();
|
||||
test("sorts by group", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
test("ignores checked items", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
test("returns unordered labels if no ordering is specified", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST,
|
||||
labelSettings: undefined,
|
||||
listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("groupAndSortListItemsByFood", () => {
|
||||
const { groupAndSortListItemsByFood } = wrapper();
|
||||
test("sorts by group", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST };
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual(MOCK_SHOPPING_LIST.listItems);
|
||||
});
|
||||
test("groups checked items together", () => {
|
||||
const sortedList: ShoppingListOut = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2 },
|
||||
],
|
||||
};
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2, position: 1 },
|
||||
]);
|
||||
});
|
||||
test("populates position and created at if not present", () => {
|
||||
const sortedList: ShoppingListOut = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD, position: undefined },
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
|
||||
],
|
||||
};
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD, position: 1 },
|
||||
]);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const sortedList: ShoppingListOut = { ...MOCK_SHOPPING_LIST, listItems: undefined };
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListState } from "../use-shopping-list-state";
|
||||
import { MOCK_ITEM, MOCK_RECIPE, MOCK_RECIPE2, MOCK_SHOPPING_LIST } from "./mocks";
|
||||
|
||||
const wrapper = (list: ShoppingListOut = MOCK_SHOPPING_LIST) => makeWrapper(() => {
|
||||
const { shoppingList, ...state } = useShoppingListState();
|
||||
shoppingList.value = list;
|
||||
return {
|
||||
shoppingList,
|
||||
...state,
|
||||
};
|
||||
});
|
||||
|
||||
describe("use-shopping-list-state", () => {
|
||||
describe("checked items are sorted", () => {
|
||||
const { sortCheckedItems } = wrapper();
|
||||
|
||||
test("by timestamp", () => {
|
||||
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "200" });
|
||||
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "0" });
|
||||
expect(sorted).toBe(1);
|
||||
expect(sorted2).toBe(-1);
|
||||
});
|
||||
test("by position if timestamps match", () => {
|
||||
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 2 });
|
||||
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
|
||||
const sorted3 = sortCheckedItems({ ...MOCK_ITEM, position: undefined }, { ...MOCK_ITEM, position: undefined });
|
||||
expect(sorted).toBe(1);
|
||||
expect(sorted2).toBe(-1);
|
||||
expect(sorted3).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipeMap", () => {
|
||||
test("Updates to match shopping list recipe references", () => {
|
||||
const { recipeMap } = wrapper();
|
||||
expect(recipeMap).toEqual(new Map([
|
||||
[MOCK_RECIPE.id, MOCK_RECIPE],
|
||||
["", MOCK_RECIPE2],
|
||||
]));
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const { recipeMap } = wrapper({ ...MOCK_SHOPPING_LIST, recipeReferences: undefined });
|
||||
expect(recipeMap).toEqual(new Map([]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("checked and unchecked items", () => {
|
||||
test("update appropriately", () => {
|
||||
const mockCheckedItem = { ...MOCK_ITEM, checked: true };
|
||||
const { listItems: { checked, unchecked } } = wrapper({
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
mockCheckedItem,
|
||||
],
|
||||
});
|
||||
expect(unchecked[0]).toEqual(MOCK_ITEM);
|
||||
expect(checked[0]).toEqual(mockCheckedItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenčina (Slovak)",
|
||||
value: "sk-SK",
|
||||
progress: 60,
|
||||
progress: 62,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -143,7 +143,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 72,
|
||||
progress: 73,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 41,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
||||
"plantoeat": {
|
||||
"title": "Plan to Eat",
|
||||
"description-long": "Mealie can import recipies from Plan to Eat."
|
||||
"description-long": "Mealie can import recipes from Plan to Eat. Upload a ZIP archive, CSV, or TXT file exported from Plan to Eat."
|
||||
},
|
||||
"myrecipebox": {
|
||||
"title": "My Recipe Box",
|
||||
@@ -1144,6 +1144,8 @@
|
||||
},
|
||||
"data-pages": {
|
||||
"foods": {
|
||||
"delete-affects-recipes": "Warning: this food is used in {count} recipe(s). Deleting it will leave an empty ingredient in the recipe(s).",
|
||||
"delete-affects-recipes-more": "View all {count} recipes",
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface AppInfo {
|
||||
oidcRedirect: boolean;
|
||||
oidcProviderName: string;
|
||||
tokenTime: number;
|
||||
allowedIframeHosts?: string[];
|
||||
}
|
||||
export interface AppStartupInfo {
|
||||
isFirstLogin: boolean;
|
||||
|
||||
74
frontend/app/lib/sanitize/markdown.test.ts
Normal file
74
frontend/app/lib/sanitize/markdown.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { sanitizeMarkdownHtml } from "./markdown";
|
||||
|
||||
describe("sanitizeMarkdownHtml", () => {
|
||||
test("returns empty string for nullish input", () => {
|
||||
expect(sanitizeMarkdownHtml(null)).toEqual("");
|
||||
expect(sanitizeMarkdownHtml(undefined)).toEqual("");
|
||||
expect(sanitizeMarkdownHtml("")).toEqual("");
|
||||
});
|
||||
|
||||
test("keeps allowed formatting tags", () => {
|
||||
const html = sanitizeMarkdownHtml("<p>Mix <strong>flour</strong> and <em>water</em></p>");
|
||||
expect(html).toContain("<strong>flour</strong>");
|
||||
expect(html).toContain("<em>water</em>");
|
||||
});
|
||||
|
||||
test("strips script tags and event handlers", () => {
|
||||
const html = sanitizeMarkdownHtml("<p onclick=\"alert(1)\">hi</p><script>alert(1)</script>");
|
||||
expect(html).not.toContain("script");
|
||||
expect(html).not.toContain("onclick");
|
||||
expect(html).not.toContain("alert");
|
||||
});
|
||||
|
||||
test("strips img onerror payloads", () => {
|
||||
const html = sanitizeMarkdownHtml("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("onerror");
|
||||
});
|
||||
|
||||
// Form controls must never render in user content.
|
||||
test("strips form, input, and button elements", () => {
|
||||
const html = sanitizeMarkdownHtml("<form action=/x><input name=p><button>go</button></form>");
|
||||
expect(html).not.toContain("<form");
|
||||
expect(html).not.toContain("<input");
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
test("strips iframes when no allowed hosts are configured", () => {
|
||||
const html = sanitizeMarkdownHtml("<iframe src=\"https://evil.example/x\"></iframe>", []);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("strips iframes whose src host is not allowlisted", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://evil.example/x\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("strips non-https iframes even for an allowlisted host", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"http://www.youtube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("keeps iframes from an allowlisted host (incl. subdomains)", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://www.youtube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).toContain("<iframe");
|
||||
expect(html).toContain("https://www.youtube.com/embed/abc");
|
||||
});
|
||||
|
||||
test("does not allow a lookalike host to pass the suffix check", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://notyoutube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
});
|
||||
98
frontend/app/lib/sanitize/markdown.ts
Normal file
98
frontend/app/lib/sanitize/markdown.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
AfterSanitizeAttributes = "afterSanitizeAttributes",
|
||||
}
|
||||
|
||||
const ALLOWED_STYLE_PROPERTIES = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
const BASE_ALLOWED_TAGS = [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
];
|
||||
|
||||
const BASE_ALLOWED_ATTR = [
|
||||
"href", "src", "alt", "height", "width", "class", "title",
|
||||
"cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
];
|
||||
|
||||
// Attributes only meaningful on an <iframe>; added to the allowlist solely when iframe embeds
|
||||
// are enabled via a configured host allowlist.
|
||||
const IFRAME_ALLOWED_ATTR = ["allow", "allowfullscreen", "frameborder", "scrolling"];
|
||||
|
||||
/**
|
||||
* Returns true if an iframe `src` points at one of the allowed hosts. Only https URLs are
|
||||
* accepted, and a configured host matches the URL's hostname exactly or as a parent domain
|
||||
* (e.g. "youtube.com" matches "www.youtube.com").
|
||||
*/
|
||||
function isAllowedIframeSrc(src: string, allowedHosts: string[]): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src);
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
return allowedHosts.some((host) => {
|
||||
const allowed = host.toLowerCase();
|
||||
return hostname === allowed || hostname.endsWith(`.${allowed}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes pre-rendered HTML (from markdown) for display in user content such as recipe
|
||||
* instructions, notes, and descriptions.
|
||||
*
|
||||
* Only the tags in `BASE_ALLOWED_TAGS` and attributes in `BASE_ALLOWED_ATTR` survive; everything
|
||||
* else (scripts, event handlers, form controls, ...) is dropped. `style` attributes are filtered
|
||||
* down to the properties in `ALLOWED_STYLE_PROPERTIES`. `<iframe>` is only kept when
|
||||
* `allowedIframeHosts` is non-empty, and even then any iframe whose `src` is not an https URL on
|
||||
* the host allowlist is removed.
|
||||
*/
|
||||
export function sanitizeMarkdownHtml(rawHtml: string | null | undefined, allowedIframeHosts: string[] = []): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const allowIframe = allowedIframeHosts.length > 0;
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (_node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_PROPERTIES.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
if (allowIframe) {
|
||||
DOMPurify.addHook(DOMPurifyHook.AfterSanitizeAttributes, (node) => {
|
||||
if (node.nodeName === "IFRAME" && !isAllowedIframeSrc(node.getAttribute("src") || "", allowedIframeHosts)) {
|
||||
node.parentNode?.removeChild(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: allowIframe ? [...BASE_ALLOWED_TAGS, "iframe"] : BASE_ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: allowIframe ? [...BASE_ALLOWED_ATTR, ...IFRAME_ALLOWED_ATTR] : BASE_ALLOWED_ATTR,
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
35
frontend/app/lib/sanitize/text.test.ts
Normal file
35
frontend/app/lib/sanitize/text.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { truncateText } from "./text";
|
||||
|
||||
describe("truncateText", () => {
|
||||
test("returns short text unchanged", () => {
|
||||
expect(truncateText("Dinner")).toEqual("Dinner");
|
||||
});
|
||||
|
||||
test("truncates long text with clamp", () => {
|
||||
expect(truncateText("a".repeat(25))).toEqual(`${"a".repeat(20)}...`);
|
||||
});
|
||||
|
||||
test("respects custom length and clamp", () => {
|
||||
expect(truncateText("abcdef", 3, "~")).toEqual("abc~");
|
||||
});
|
||||
|
||||
test("does not clamp text exactly at the length boundary", () => {
|
||||
expect(truncateText("abcde", 5)).toEqual("abcde");
|
||||
expect(truncateText("abcdef", 5)).toEqual("abcde...");
|
||||
});
|
||||
|
||||
// Markup in the input must be treated as plain text and never parsed into the live document.
|
||||
test("does not parse or execute HTML payloads", () => {
|
||||
const createElement = vi.spyOn(document, "createElement");
|
||||
const payload = "<img src=x onerror=alert(1)>";
|
||||
|
||||
const result = truncateText(payload);
|
||||
|
||||
// The payload is returned verbatim (truncated only by length), proving it is treated as text.
|
||||
expect(result).toEqual(`${payload.slice(0, 20)}...`);
|
||||
// No DOM element is constructed, so no <img> can fire its onerror handler.
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
createElement.mockRestore();
|
||||
});
|
||||
});
|
||||
9
frontend/app/lib/sanitize/text.ts
Normal file
9
frontend/app/lib/sanitize/text.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Truncates plain text to `length` characters, appending `clamp` when truncated.
|
||||
*
|
||||
* The input is treated strictly as text and is never parsed as HTML, so markup in the input is
|
||||
* returned verbatim rather than interpreted.
|
||||
*/
|
||||
export function truncateText(text: string, length = 20, clamp = "..."): string {
|
||||
return text.length > length ? text.slice(0, length) + clamp : text;
|
||||
}
|
||||
@@ -69,11 +69,7 @@
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<v-alert
|
||||
v-if="foods && foods.length > 0"
|
||||
type="error"
|
||||
class="mb-0 text-body-2"
|
||||
>
|
||||
<v-alert v-if="foods && foods.length > 0" type="error" class="mb-0 text-body-2">
|
||||
{{ $t("data-pages.foods.seed-dialog-warning") }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
@@ -112,11 +108,7 @@
|
||||
:label="$t('data-pages.foods.food-label')"
|
||||
/>
|
||||
<v-card variant="outlined">
|
||||
<v-virtual-scroll
|
||||
height="400"
|
||||
item-height="25"
|
||||
:items="bulkAssignTarget"
|
||||
>
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkAssignTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||
@@ -141,6 +133,7 @@
|
||||
]"
|
||||
:create-form="createForm"
|
||||
:edit-form="editForm"
|
||||
:on-delete-dialog-open="onDeleteDialogOpen"
|
||||
@create-one="handleCreate"
|
||||
@edit-one="handleEdit"
|
||||
@delete-one="foodStore.actions.deleteOne"
|
||||
@@ -151,15 +144,12 @@
|
||||
<template #icon>
|
||||
{{ $globals.icons.externalLink }}
|
||||
</template>
|
||||
{{ $t('data-pages.combine') }}
|
||||
{{ $t("data-pages.combine") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #[`item.label`]="{ item }">
|
||||
<MultiPurposeLabel
|
||||
v-if="item.label"
|
||||
:label="item.label"
|
||||
>
|
||||
<MultiPurposeLabel v-if="item.label" :label="item.label">
|
||||
{{ item.label.name }}
|
||||
</MultiPurposeLabel>
|
||||
</template>
|
||||
@@ -171,7 +161,7 @@
|
||||
</template>
|
||||
|
||||
<template #[`item.createdAt`]="{ item }">
|
||||
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }}
|
||||
{{ item.createdAt ? $d(new Date(item.createdAt)) : "" }}
|
||||
</template>
|
||||
|
||||
<template #table-button-bottom>
|
||||
@@ -179,18 +169,33 @@
|
||||
<template #icon>
|
||||
{{ $globals.icons.database }}
|
||||
</template>
|
||||
{{ $t('data-pages.seed') }}
|
||||
{{ $t("data-pages.seed") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #edit-dialog-custom-action>
|
||||
<BaseButton
|
||||
edit
|
||||
@click="aliasManagerDialog = true"
|
||||
>
|
||||
{{ $t('data-pages.manage-aliases') }}
|
||||
<BaseButton edit @click="aliasManagerDialog = true">
|
||||
{{ $t("data-pages.manage-aliases") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #delete-dialog-bottom>
|
||||
<v-alert v-if="affectedRecipes.length > 0" type="warning" density="compact" class="mt-4 mb-0">
|
||||
{{ $t("data-pages.foods.delete-affects-recipes", { count: affectedRecipesTotal }) }}
|
||||
<ul class="mt-1 pl-5 mb-0">
|
||||
<li v-for="recipe in affectedRecipes.slice(0, 5)" :key="recipe.slug">
|
||||
<NuxtLink :to="recipe.url" class="text-white">{{ recipe.name }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink
|
||||
v-if="affectedRecipesTotal > 5"
|
||||
:to="affectedRecipesMoreLink"
|
||||
class="text-white d-inline-block mt-1"
|
||||
>
|
||||
{{ $t("data-pages.foods.delete-affects-recipes-more", { count: affectedRecipesTotal }) }}
|
||||
</NuxtLink>
|
||||
</v-alert>
|
||||
</template>
|
||||
</GroupDataPage>
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +223,7 @@ interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
|
||||
interface IngredientFoodWithOnHand extends IngredientFood {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
const userApi = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
@@ -274,11 +280,14 @@ const tableHeaders: TableHeaders[] = [
|
||||
];
|
||||
|
||||
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
|
||||
const userGroup = computed(() => auth.user.value?.groupSlug || "");
|
||||
const foodStore = useFoodStore();
|
||||
const foods = computed(() => foodStore.store.value.map((food) => {
|
||||
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
|
||||
return { ...food, onHand } as IngredientFoodWithOnHand;
|
||||
}));
|
||||
const foods = computed(() =>
|
||||
foodStore.store.value.map((food) => {
|
||||
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
|
||||
return { ...food, onHand } as IngredientFoodWithOnHand;
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Labels
|
||||
@@ -383,6 +392,9 @@ async function handleBulkAction(event: string, items: IngredientFoodWithOnHand[]
|
||||
if (event === "delete-selected") {
|
||||
const ids = items.map(item => item.id);
|
||||
await foodStore.actions.deleteMany(ids);
|
||||
affectedRecipes.value = [];
|
||||
affectedRecipesTotal.value = 0;
|
||||
affectedRecipesMoreLink.value = "";
|
||||
}
|
||||
else if (event === "assign-selected") {
|
||||
bulkAssignEventHandler(items);
|
||||
@@ -401,6 +413,26 @@ function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
|
||||
aliasManagerDialog.value = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Delete Foods
|
||||
|
||||
// fetch affected recipes before confirming deletion
|
||||
const affectedRecipes = ref<{ name: string; slug: string; url: string }[]>([]);
|
||||
const affectedRecipesTotal = ref(0);
|
||||
const affectedRecipesMoreLink = ref("");
|
||||
|
||||
async function onDeleteDialogOpen(items: IngredientFoodWithOnHand[]) {
|
||||
const ids = items.map(item => item.id);
|
||||
const { data } = await userApi.recipes.search({ foods: ids, perPage: 5 });
|
||||
affectedRecipes.value = (data?.items ?? []).map(r => ({
|
||||
name: r.name ?? "",
|
||||
slug: r.slug ?? "",
|
||||
url: `/g/${userGroup.value}/r/${r.slug}`,
|
||||
}));
|
||||
affectedRecipesTotal.value = data?.total ?? 0;
|
||||
affectedRecipesMoreLink.value = `/g/${userGroup.value}?${ids.map(id => `foods=${id}`).join("&")}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Merge Foods
|
||||
|
||||
|
||||
@@ -337,16 +337,8 @@ const _content: Record<string, MigrationContent> = {
|
||||
},
|
||||
[MIGRATIONS.plantoeat]: {
|
||||
text: i18n.t("migration.plantoeat.description-long"),
|
||||
acceptedFileType: ".zip",
|
||||
tree: [
|
||||
{
|
||||
icon: $globals.icons.zip,
|
||||
title: "plantoeat-recipes-508318_10-13-2023.zip",
|
||||
children: [
|
||||
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
|
||||
],
|
||||
},
|
||||
],
|
||||
acceptedFileType: ".zip,.csv,.txt",
|
||||
tree: false,
|
||||
},
|
||||
[MIGRATIONS.recipekeeper]: {
|
||||
text: i18n.t("migration.recipekeeper.description-long"),
|
||||
|
||||
18
frontend/app/tests/setup.ts
Normal file
18
frontend/app/tests/setup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { config } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
function loadEnLocales() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require("../lang/messages/en-US.json") as Record<string, string>;
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: "en-US",
|
||||
messages: {
|
||||
"en-US": loadEnLocales(),
|
||||
},
|
||||
});
|
||||
|
||||
config.global.plugins = [...(config.global.plugins ?? []), i18n];
|
||||
|
||||
export { i18n };
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
function loadEnLocales() {
|
||||
@@ -14,3 +15,12 @@ export function stubI18n() {
|
||||
});
|
||||
return i18n.global;
|
||||
}
|
||||
|
||||
export const makeWrapper = <T>(setup: () => T) => {
|
||||
const Wrapper = {
|
||||
template: "<div />",
|
||||
setup,
|
||||
};
|
||||
const { vm } = mount(Wrapper);
|
||||
return vm as unknown as ReturnType<typeof Wrapper.setup>;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"lint:log": "yarn lint:js --debug",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"cleanup": "nuxt cleanup"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -48,6 +50,9 @@
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.0.2",
|
||||
"eslint-plugin-format": "^1.0.1",
|
||||
@@ -57,8 +62,9 @@
|
||||
"prettier": "^3.5.2",
|
||||
"sass-embedded": "^1.85.1",
|
||||
"typescript": "^5.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"vite-plugin-commonjs": "^0.10.4",
|
||||
"vitest": "^3.0.7"
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import path from "path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
|
||||
export default {
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ["vue", "@vueuse/core", "vue-i18n"],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./app/tests/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["app/{lib,components,composables,layouts,pages}/**/*.{ts,tsx,vue}"],
|
||||
exclude: ["**/*.test.*", "node_modules/**", "dist/**", "coverage/**", "**/__tests__/**"],
|
||||
reporter: ["html", "text-summary"],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
2380
frontend/yarn.lock
2380
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,7 @@ def validate_file_token(token: str | None = None) -> Path:
|
||||
@contextmanager
|
||||
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
|
||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
|
||||
temp_path = app_dirs.TEMP_DIR / f"{uuid4().hex}.zip"
|
||||
try:
|
||||
yield temp_path
|
||||
finally:
|
||||
|
||||
@@ -33,6 +33,16 @@ class FeatureDetails(NamedTuple):
|
||||
return s
|
||||
|
||||
|
||||
DEFAULT_ALLOWED_IFRAME_HOSTS = [
|
||||
"youtube.com",
|
||||
"youtube-nocookie.com",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
]
|
||||
"""Secure-by-default hostnames permitted as `<iframe>` sources in user content. Limited to
|
||||
well-known video providers. Subdomains of these hosts are also allowed (e.g. `www.youtube.com`)."""
|
||||
|
||||
|
||||
MaskedNoneString = Annotated[
|
||||
str | None,
|
||||
PlainSerializer(lambda x: None if x is None else "*****", return_type=str | None),
|
||||
@@ -150,6 +160,19 @@ class AppSettings(AppLoggingSettings):
|
||||
ALLOW_SIGNUP: bool = False
|
||||
ALLOW_PASSWORD_LOGIN: bool = True
|
||||
|
||||
ALLOWED_IFRAME_HOSTS: str = ""
|
||||
"""Comma-separated list of additional hostnames allowed as `<iframe>` sources in user content
|
||||
(recipe instructions, notes, descriptions). Extends `DEFAULT_ALLOWED_IFRAME_HOSTS`. Subdomains of
|
||||
a listed host are also allowed. Adding hosts is opt-in to riskier behavior; the defaults are
|
||||
limited to well-known video providers."""
|
||||
|
||||
@property
|
||||
def allowed_iframe_hosts(self) -> list[str]:
|
||||
"""The full set of hostnames permitted as `<iframe>` sources, secure defaults plus any
|
||||
admin-configured additions via `ALLOWED_IFRAME_HOSTS`."""
|
||||
extra = [host.strip().lower() for host in self.ALLOWED_IFRAME_HOSTS.split(",") if host.strip()]
|
||||
return list(dict.fromkeys(DEFAULT_ALLOWED_IFRAME_HOSTS + extra))
|
||||
|
||||
DAILY_SCHEDULE_TIME: str = "23:45"
|
||||
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""
|
||||
|
||||
|
||||
@@ -38,5 +38,7 @@ class RecipeInstruction(SqlAlchemyBase):
|
||||
)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, ingredient_references, session, **_) -> None:
|
||||
self.ingredient_references = [RecipeIngredientRefLink(**ref, session=session) for ref in ingredient_references]
|
||||
def __init__(self, session, ingredient_references=None, **_) -> None:
|
||||
self.ingredient_references = [
|
||||
RecipeIngredientRefLink(**ref, session=session) for ref in (ingredient_references or [])
|
||||
]
|
||||
|
||||
@@ -43,6 +43,7 @@ def get_app_info(session: Session = Depends(generate_session)):
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
|
||||
token_time=settings.TOKEN_TIME,
|
||||
allowed_iframe_hosts=settings.allowed_iframe_hosts,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,13 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if file.exists():
|
||||
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
|
||||
# Force download and disable MIME sniffing so uploaded assets cannot be
|
||||
# served as active content in Mealie's origin.
|
||||
return FileResponse(
|
||||
file,
|
||||
filename=file.name,
|
||||
content_disposition_type="attachment",
|
||||
headers={"X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@@ -22,6 +22,7 @@ class AppInfo(MealieModel):
|
||||
oidc_redirect: bool
|
||||
oidc_provider_name: str
|
||||
token_time: int
|
||||
allowed_iframe_hosts: list[str] = []
|
||||
|
||||
|
||||
class AppTheme(MealieModel):
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.settings.static import APP_VERSION
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||
from mealie.services.backups_v2.backup_file import BackupFile
|
||||
@@ -43,8 +44,15 @@ class BackupV2(BaseService):
|
||||
def backup(self) -> Path:
|
||||
# sourcery skip: merge-nested-ifs, reintroduce-else, remove-redundant-continue
|
||||
timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y.%m.%d.%H.%M.%S")
|
||||
short_hash = self.settings.GIT_COMMIT_HASH[:7]
|
||||
|
||||
if APP_VERSION == "develop":
|
||||
backup_name = f"mealie_dev-{short_hash}_{timestamp}.zip"
|
||||
elif APP_VERSION == "nightly":
|
||||
backup_name = f"mealie_nightly-{short_hash}_{timestamp}.zip"
|
||||
else:
|
||||
backup_name = f"mealie_{APP_VERSION}_{timestamp}.zip"
|
||||
|
||||
backup_name = f"mealie_{timestamp}.zip"
|
||||
backup_file = self.directories.BACKUP_DIR / backup_name
|
||||
|
||||
database_json = self.db_exporter.dump()
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.pkgs.cache import cache_key
|
||||
from mealie.schema.reports.reports import ReportEntryCreate
|
||||
from mealie.services.scraper import cleaner
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
@@ -15,15 +16,23 @@ from .utils.migration_helpers import scrape_image, split_by_comma
|
||||
|
||||
|
||||
def plantoeat_recipes(file: Path):
|
||||
"""Yields all recipes inside the export file as dict"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with zipfile.ZipFile(file) as zip_file:
|
||||
zip_file.extractall(tmpdir)
|
||||
"""Yields all recipes inside the export file as dict.
|
||||
|
||||
for name in Path(tmpdir).glob("**/[!.]*.csv"):
|
||||
with open(name, newline="") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file.
|
||||
"""
|
||||
if zipfile.is_zipfile(file):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with zipfile.ZipFile(file) as zip_file:
|
||||
zip_file.extractall(tmpdir)
|
||||
|
||||
for name in Path(tmpdir).glob("**/[!.]*.csv"):
|
||||
with open(name, newline="") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
else:
|
||||
with open(file, newline="", encoding="utf-8", errors="ignore") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
|
||||
|
||||
def get_value_as_string_or_none(dictionary: dict, key: str):
|
||||
@@ -112,7 +121,32 @@ class PlanToEatMigrator(BaseMigrator):
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def _validate_archive(self) -> bool:
|
||||
"""Returns False and appends a failure report entry if the file is not a ZIP, CSV, or TXT."""
|
||||
if zipfile.is_zipfile(self.archive):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(self.archive, encoding="utf-8", errors="strict") as f:
|
||||
f.read(512)
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
self.report_entries.append(
|
||||
ReportEntryCreate(
|
||||
report_id=self.report_id,
|
||||
success=False,
|
||||
message="Unsupported file format. Please upload a ZIP archive, CSV file, or TXT file.",
|
||||
exception="",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
def _migrate(self) -> None:
|
||||
if not self._validate_archive():
|
||||
return
|
||||
|
||||
recipe_image_urls = {}
|
||||
|
||||
recipes = []
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[build]
|
||||
publish = "docs/site/"
|
||||
command = """
|
||||
pip3 install zensical &&
|
||||
pip3 install mkdocs-material &&
|
||||
cd docs &&
|
||||
zensical build
|
||||
mkdocs build
|
||||
"""
|
||||
|
||||
[[headers]]
|
||||
|
||||
@@ -12,9 +12,7 @@ dependencies = [
|
||||
"SQLAlchemy==2.0.50",
|
||||
"aiofiles==25.1.0",
|
||||
"alembic==1.18.4",
|
||||
"aniso8601==10.0.1",
|
||||
"appdirs==1.4.4",
|
||||
"apprise==1.10.0",
|
||||
"apprise==1.11.0",
|
||||
"bcrypt==5.0.0",
|
||||
"extruct==0.18.0",
|
||||
"fastapi==0.136.3",
|
||||
@@ -26,12 +24,12 @@ dependencies = [
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.2.2",
|
||||
"python-ldap==3.4.7",
|
||||
"python-multipart==0.0.29",
|
||||
"python-multipart==0.0.32",
|
||||
"python-slugify==8.0.4",
|
||||
"recipe-scrapers==15.11.0",
|
||||
"requests==2.34.2",
|
||||
"tzdata==2026.2",
|
||||
"uvicorn[standard]==0.48.0",
|
||||
"uvicorn[standard]==0.49.0",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"isodate==0.7.2",
|
||||
"text-unidecode==1.3",
|
||||
@@ -42,7 +40,7 @@ dependencies = [
|
||||
"pydantic-settings==2.14.1",
|
||||
"pillow-heif==1.3.0",
|
||||
"pyjwt==2.13.0",
|
||||
"openai==2.38.0",
|
||||
"openai==2.41.0",
|
||||
"typing-extensions==4.15.0",
|
||||
"itsdangerous==2.2.0",
|
||||
"yt-dlp==2026.3.17",
|
||||
@@ -61,19 +59,19 @@ pgsql = [
|
||||
|
||||
[dependency-groups]
|
||||
docs = [
|
||||
"zensical==0.0.42",
|
||||
"mkdocs-material==9.7.6",
|
||||
]
|
||||
dev = [
|
||||
"coverage==7.14.1",
|
||||
"coveragepy-lcov==0.1.2",
|
||||
"zensical==0.0.42",
|
||||
"mkdocs-material==9.7.6",
|
||||
"mypy==2.1.0",
|
||||
"pre-commit==4.6.0",
|
||||
"pylint==4.0.5",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"rich==15.0.0",
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.16",
|
||||
"types-PyYAML==6.0.12.20260518",
|
||||
"types-python-dateutil==2.9.0.20260518",
|
||||
"types-python-slugify==8.0.2.20240310",
|
||||
@@ -178,3 +176,7 @@ max-complexity = 24 # Default is 10.
|
||||
|
||||
[tool.uv]
|
||||
add-bounds = "exact"
|
||||
# Cooling period: ignore package releases newer than 5 days to mitigate
|
||||
# supply-chain attacks (compromised releases are usually caught and yanked
|
||||
# within days). Evaluated at resolve time as a rolling window.
|
||||
exclude-newer = "5 days"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"minimumReleaseAge": "5 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"addLabels": [
|
||||
"dependencies"
|
||||
],
|
||||
@@ -51,8 +53,7 @@
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"automergeStrategy": "squash",
|
||||
"minimumReleaseAge": "5 days"
|
||||
"automergeStrategy": "squash"
|
||||
},
|
||||
{
|
||||
"description": "Auto-merge Docker digest and patch updates",
|
||||
@@ -66,8 +67,7 @@
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"automergeStrategy": "squash",
|
||||
"minimumReleaseAge": "5 days"
|
||||
"automergeStrategy": "squash"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ migrations_tandoor = CWD / "migrations/tandoor.zip"
|
||||
|
||||
migrations_plantoeat = CWD / "migrations/plantoeat.zip"
|
||||
|
||||
migrations_plantoeat_csv = CWD / "migrations/plantoeat.csv"
|
||||
|
||||
migrations_myrecipebox = CWD / "migrations/myrecipebox.csv"
|
||||
|
||||
migrations_recipekeeper = CWD / "migrations/recipekeeper.zip"
|
||||
|
||||
13
tests/data/migrations/plantoeat.csv
Normal file
13
tests/data/migrations/plantoeat.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
Title,Course,Cuisine,Main Ingredient,Description,Source,Url,Url Host,Prep Time,Cook Time,Total Time,Servings,Yield,Ingredients,Directions,Tags,Rating,Public Url,Photo Url,Private,Nutritional Score (generic),Calories,Fat,Saturated Fat,Cholesterol,Sodium,Sugar,Carbohydrate,Fiber,Protein,Cost,Created At,Updated At
|
||||
Test Recipe,Main Course,American,Beans,"This is a description.
|
||||
Here is new line.",Manually entered source,https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/,,75,75,150,7,1 loaf,", Heading
|
||||
2 itm Test, note
|
||||
, Heading2
|
||||
3 pkg Two, note2
|
||||
|
||||
","Directions.
|
||||
Will go here.","Allergen-Friendly, Cheap, Test",3,https://app.plantoeat.com/recipes/38843883,https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591,yes,,13,16,17,18,19,22,20,21,23,,2023-10-13 20:29:29,2023-10-13 20:32:48
|
||||
Test Recipe2,,,,,,,,,,,,,"2 itm Test, note
|
||||
3 pkg Two, note2
|
||||
","Directions.
|
||||
Will go here.",,,,,,,,,,,,,,,,,2023-10-13 20:29:29,2023-10-13 20:32:48
|
||||
|
@@ -94,6 +94,15 @@ test_cases = [
|
||||
"transFatContent",
|
||||
},
|
||||
),
|
||||
MigrationTestData(
|
||||
typ=SupportedMigrations.plantoeat,
|
||||
archive=test_data.migrations_plantoeat_csv,
|
||||
search_slug="test-recipe",
|
||||
nutrition_filter={
|
||||
"unsaturatedFatContent",
|
||||
"transFatContent",
|
||||
},
|
||||
),
|
||||
MigrationTestData(
|
||||
typ=SupportedMigrations.myrecipebox,
|
||||
archive=test_data.migrations_myrecipebox,
|
||||
@@ -124,6 +133,7 @@ test_ids = [
|
||||
"mealie_alpha_archive",
|
||||
"tandoor_archive",
|
||||
"plantoeat_archive",
|
||||
"plantoeat_csv",
|
||||
"myrecipebox_csv",
|
||||
"recipekeeper_archive",
|
||||
"cookn_archive",
|
||||
@@ -190,6 +200,30 @@ def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUse
|
||||
# TODO: validate other types of content
|
||||
|
||||
|
||||
def test_plantoeat_rejects_invalid_file_type(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Simulate uploading a binary file (e.g. PDF) that is neither ZIP nor CSV/TXT
|
||||
binary_content = bytes(range(256)) * 4 # arbitrary binary data that is not valid UTF-8
|
||||
payload = {"migration_type": SupportedMigrations.plantoeat.value}
|
||||
file_payload = {"archive": binary_content}
|
||||
|
||||
response = api_client.post(
|
||||
api_routes.groups_migrations,
|
||||
data=payload,
|
||||
files=file_payload,
|
||||
headers=unique_user.token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
report_id = response.json()["id"]
|
||||
|
||||
response = api_client.get(api_routes.groups_reports_item_id(report_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
report = response.json()
|
||||
assert report["entries"]
|
||||
assert not report["entries"][0]["success"]
|
||||
assert "ZIP" in report["entries"][0]["message"] or "CSV" in report["entries"][0]["message"]
|
||||
|
||||
|
||||
def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
with ZipFile(test_data.migrations_mealie) as zf:
|
||||
|
||||
@@ -1389,6 +1389,29 @@ def test_patch_recipe_after_name_changes_without_slug_update(api_client: TestCli
|
||||
assert patched_recipe["description"] == "Translated without changing the stored slug"
|
||||
|
||||
|
||||
def test_patch_recipe_instructions_without_ingredient_references(api_client: TestClient, unique_user: TestUser):
|
||||
response = api_client.post(
|
||||
api_routes.recipes,
|
||||
json={"name": "Patch instructions without refs"},
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
slug = response.json()
|
||||
|
||||
response = api_client.patch(
|
||||
api_routes.recipes_slug(slug),
|
||||
json={"recipeInstructions": [{"text": "Step one."}, {"text": "Step two."}]},
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe = response.json()
|
||||
assert [step["text"] for step in recipe["recipeInstructions"]] == ["Step one.", "Step two."]
|
||||
assert all(step["ingredientReferences"] == [] for step in recipe["recipeInstructions"])
|
||||
|
||||
|
||||
def test_put_recipe_name_change_updates_slug(api_client: TestClient, unique_user: TestUser):
|
||||
original_name = "Original Recipe Name"
|
||||
renamed_name = "Renamed Recipe Name"
|
||||
|
||||
@@ -104,6 +104,33 @@ def test_recipe_asset_dangerous_extension_blocked(
|
||||
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
|
||||
|
||||
|
||||
def test_recipe_asset_served_as_attachment(
|
||||
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
|
||||
):
|
||||
"""Assets must be served as downloads with MIME sniffing disabled so uploaded files cannot
|
||||
execute as active content in Mealie's origin."""
|
||||
recipe = recipe_ingredient_only
|
||||
payload = {"name": random_string(10), "icon": "mdi-file", "extension": "txt"}
|
||||
file_payload = {"file": b"<script>alert(1)</script>"}
|
||||
|
||||
response = api_client.post(
|
||||
f"/api/recipes/{recipe.slug}/assets",
|
||||
data=payload,
|
||||
files=file_payload,
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe_response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token).json()
|
||||
recipe_id = recipe_response["id"]
|
||||
file_name = recipe_response["assets"][0]["fileName"]
|
||||
|
||||
media_response = api_client.get(f"/api/media/recipes/{recipe_id}/assets/{file_name}")
|
||||
assert media_response.status_code == 200
|
||||
assert "attachment" in media_response.headers["content-disposition"].lower()
|
||||
assert media_response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
|
||||
data_payload = {"extension": "jpg"}
|
||||
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
||||
|
||||
@@ -101,9 +101,8 @@ def test_brute_parser(
|
||||
comment: str,
|
||||
):
|
||||
with session_context() as session:
|
||||
loop = asyncio.get_event_loop()
|
||||
parser = get_parser(RegisteredParser.brute, unique_local_group_id, session, get_locale_provider())
|
||||
parsed = loop.run_until_complete(parser.parse_one(input))
|
||||
parsed = asyncio.run(parser.parse_one(input))
|
||||
ing = parsed.ingredient
|
||||
|
||||
if ing.quantity:
|
||||
@@ -145,15 +144,8 @@ def test_brute_parser_confidence(
|
||||
input_str = f"1 {unit} {food}"
|
||||
|
||||
with session_context() as session:
|
||||
original_loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
parser = get_parser(RegisteredParser.brute, unique_local_group_id, session, get_locale_provider())
|
||||
parsed = loop.run_until_complete(parser.parse_one(input_str))
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(original_loop)
|
||||
parser = get_parser(RegisteredParser.brute, unique_local_group_id, session, get_locale_provider())
|
||||
parsed = asyncio.run(parser.parse_one(input_str))
|
||||
|
||||
conf = parsed.confidence
|
||||
|
||||
|
||||
@@ -50,9 +50,8 @@ def normalize(val: str) -> str:
|
||||
)
|
||||
def test_nlp_parser(unique_local_group_id: UUID4, test_ingredient: TestIngredient):
|
||||
with session_context() as session:
|
||||
loop = asyncio.get_event_loop()
|
||||
parser = get_parser(RegisteredParser.nlp, unique_local_group_id, session, get_locale_provider())
|
||||
parsed = loop.run_until_complete(parser.parse_one(test_ingredient.input))
|
||||
parsed = asyncio.run(parser.parse_one(test_ingredient.input))
|
||||
ing = parsed.ingredient
|
||||
|
||||
assert ing.quantity == pytest.approx(test_ingredient.quantity)
|
||||
|
||||
@@ -56,11 +56,10 @@ def test_openai_parser(
|
||||
monkeypatch.setattr(OpenAIService, "__init__", mock_openai_init)
|
||||
|
||||
with session_context() as session:
|
||||
loop = asyncio.get_event_loop()
|
||||
parser = get_parser(RegisteredParser.openai, unique_local_group_id, session, get_locale_provider())
|
||||
|
||||
inputs = [random_string() for _ in range(ingredient_count)]
|
||||
parsed = loop.run_until_complete(parser.parse(inputs))
|
||||
parsed = asyncio.run(parser.parse(inputs))
|
||||
|
||||
# since OpenAI is mocked, we don't need to validate the data, we just need to make sure parsing works
|
||||
# and that it preserves order
|
||||
@@ -109,10 +108,10 @@ def test_openai_parser_sanitize_output(
|
||||
monkeypatch.setattr(OpenAIService, "__init__", mock_openai_init)
|
||||
|
||||
with session_context() as session:
|
||||
loop = asyncio.get_event_loop()
|
||||
parser = get_parser(RegisteredParser.openai, unique_local_group_id, session, get_locale_provider())
|
||||
|
||||
parsed = loop.run_until_complete(parser.parse([""]))
|
||||
parsed = asyncio.run(parser.parse([""]))
|
||||
|
||||
assert len(parsed) == 1
|
||||
parsed_ing = cast(ParsedIngredient, parsed[0])
|
||||
assert parsed_ing.ingredient.food
|
||||
|
||||
@@ -27,6 +27,31 @@ def test_non_default_settings(monkeypatch):
|
||||
assert app_settings.DOCS_URL is None
|
||||
|
||||
|
||||
def test_allowed_iframe_hosts_defaults(monkeypatch):
|
||||
monkeypatch.delenv("ALLOWED_IFRAME_HOSTS", raising=False)
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
# Secure defaults are always present and never empty (empty would disable iframe embeds).
|
||||
assert "youtube.com" in app_settings.allowed_iframe_hosts
|
||||
assert "vimeo.com" in app_settings.allowed_iframe_hosts
|
||||
|
||||
|
||||
def test_allowed_iframe_hosts_extends_defaults(monkeypatch):
|
||||
monkeypatch.setenv("ALLOWED_IFRAME_HOSTS", " Example.com , trusted.tld ,, ")
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
hosts = app_settings.allowed_iframe_hosts
|
||||
# Configured hosts are normalized, blanks dropped, and defaults retained.
|
||||
assert "example.com" in hosts
|
||||
assert "trusted.tld" in hosts
|
||||
assert "youtube.com" in hosts
|
||||
assert "" not in hosts
|
||||
# No duplicates.
|
||||
assert len(hosts) == len(set(hosts))
|
||||
|
||||
|
||||
def test_default_connection_args(monkeypatch):
|
||||
monkeypatch.setenv("DB_ENGINE", "sqlite")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
371
uv.lock
generated
371
uv.lock
generated
@@ -2,6 +2,10 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P5D"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
@@ -25,15 +29,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aniso8601"
|
||||
version = "10.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/52179c4e3f1978d3d9a285f98c706642522750ef343e9738286130423730/aniso8601-10.0.1.tar.gz", hash = "sha256:25488f8663dd1528ae1f54f94ac1ea51ae25b4d531539b8bc707fed184d16845", size = 47190, upload-time = "2025-04-18T17:29:42.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/75/e0e10dc7ed1408c28e03a6cb2d7a407f99320eb953f229d008a7a6d05546/aniso8601-10.0.1-py2.py3-none-any.whl", hash = "sha256:eb19717fd4e0db6de1aab06f12450ab92144246b257423fe020af5748c0cb89e", size = 52848, upload-time = "2025-04-18T17:29:41.492Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@@ -65,18 +60,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "appdirs"
|
||||
version = "1.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -87,9 +73,9 @@ dependencies = [
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/74/9c16829d3e7e45ce7daf1b704687fa4fde7ea00d72eafe8de18c72bf5995/apprise-1.10.0.tar.gz", hash = "sha256:b768f32d99e45ed5f4c3eef1f67903e803c97f97ba61a531a5d0a45d40df90a8", size = 2188611, upload-time = "2026-04-26T14:23:51.928Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/f8/83f4e2aaaa0342dc67f783bf84427d9ff5cfa4ecde3a52d9b587740a91ee/apprise-1.11.0.tar.gz", hash = "sha256:3b1e6f5365b302d1fae270c0c8007958e54224b9b7808acec69006ea27f5b8a2", size = 2337248, upload-time = "2026-05-29T17:52:29.198Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f9/177a73589d34e676d10bc4c6a8328710e28af5907234e9f25bb149a04eec/apprise-1.10.0-py3-none-any.whl", hash = "sha256:e685303d3568bb7a057d6ddeafd27ee12fff183ca36483ad4bacc0b9b4efa82c", size = 1632292, upload-time = "2026-04-26T14:23:49.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/79/1483693160917522703708e45b4045946d86be634b8af2c2ac9f361f8fa3/apprise-1.11.0-py3-none-any.whl", hash = "sha256:6abc6d9f8dddedfe14b2918c3553146894ac6e3dad401deaf854b20b4c455d38", size = 1712975, upload-time = "2026-05-29T17:52:26.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -138,6 +124,26 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "babel"
|
||||
version = "2.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backrefs"
|
||||
version = "7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
@@ -377,15 +383,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepmerge"
|
||||
version = "2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
@@ -397,11 +394,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -450,11 +447,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.0"
|
||||
version = "3.29.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -493,6 +490,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghp-import"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.1"
|
||||
@@ -621,11 +630,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.16"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -727,14 +736,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.8"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/2f590052b55cbdd0ace470ee7ee1f685f6882051be93a9374891005623e2/joserfc-1.7.0.tar.gz", hash = "sha256:4aced6ab0c47846f0a531402aec2419a874b91e918df9c4c9da8a82fb559d6c4", size = 232967, upload-time = "2026-06-02T09:59:34.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/83/b6b62a66a06ce872d9429a5eb5ee20b2002fd9c331b953c94381c1f7c9f9/joserfc-1.7.0-py3-none-any.whl", hash = "sha256:17e5d7a5a35e65442b05efc435a3d5d46696ffa2c8a2ed0eea6f63fc268e3224", size = 70387, upload-time = "2026-06-02T09:59:33.264Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -879,8 +888,6 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "alembic" },
|
||||
{ name = "aniso8601" },
|
||||
{ name = "appdirs" },
|
||||
{ name = "apprise" },
|
||||
{ name = "authlib" },
|
||||
{ name = "bcrypt" },
|
||||
@@ -932,6 +939,7 @@ dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "coveragepy-lcov" },
|
||||
{ name = "freezegun" },
|
||||
{ name = "mkdocs-material" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pydantic-to-typescript2" },
|
||||
@@ -945,19 +953,16 @@ dev = [
|
||||
{ name = "types-pyyaml" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "types-urllib3" },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "zensical" },
|
||||
{ name = "mkdocs-material" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = "==25.1.0" },
|
||||
{ name = "alembic", specifier = "==1.18.4" },
|
||||
{ name = "aniso8601", specifier = "==10.0.1" },
|
||||
{ name = "appdirs", specifier = "==1.4.4" },
|
||||
{ name = "apprise", specifier = "==1.10.0" },
|
||||
{ name = "apprise", specifier = "==1.11.0" },
|
||||
{ name = "authlib", specifier = "==1.7.2" },
|
||||
{ name = "bcrypt", specifier = "==5.0.0" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
@@ -971,7 +976,7 @@ requires-dist = [
|
||||
{ name = "itsdangerous", specifier = "==2.2.0" },
|
||||
{ name = "jinja2", specifier = "==3.1.6" },
|
||||
{ name = "lxml", specifier = "==6.1.1" },
|
||||
{ name = "openai", specifier = "==2.38.0" },
|
||||
{ name = "openai", specifier = "==2.41.0" },
|
||||
{ name = "orjson", specifier = "==3.11.9" },
|
||||
{ name = "paho-mqtt", specifier = "==1.6.1" },
|
||||
{ name = "pillow", specifier = "==12.2.0" },
|
||||
@@ -985,7 +990,7 @@ requires-dist = [
|
||||
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-ldap", specifier = "==3.4.7" },
|
||||
{ name = "python-multipart", specifier = "==0.0.29" },
|
||||
{ name = "python-multipart", specifier = "==0.0.32" },
|
||||
{ name = "python-slugify", specifier = "==8.0.4" },
|
||||
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||
{ name = "rapidfuzz", specifier = "==3.14.5" },
|
||||
@@ -995,7 +1000,7 @@ requires-dist = [
|
||||
{ name = "text-unidecode", specifier = "==1.3" },
|
||||
{ name = "typing-extensions", specifier = "==4.15.0" },
|
||||
{ name = "tzdata", specifier = "==2026.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.48.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.49.0" },
|
||||
{ name = "yt-dlp", specifier = "==2026.3.17" },
|
||||
]
|
||||
provides-extras = ["pgsql"]
|
||||
@@ -1005,22 +1010,31 @@ dev = [
|
||||
{ name = "coverage", specifier = "==7.14.1" },
|
||||
{ name = "coveragepy-lcov", specifier = "==0.1.2" },
|
||||
{ name = "freezegun", specifier = "==1.5.5" },
|
||||
{ name = "mkdocs-material", specifier = "==9.7.6" },
|
||||
{ name = "mypy", specifier = "==2.1.0" },
|
||||
{ name = "pre-commit", specifier = "==4.6.0" },
|
||||
{ name = "pydantic-to-typescript2", specifier = "==1.0.6" },
|
||||
{ name = "pylint", specifier = "==4.0.5" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.3.0" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.4.0" },
|
||||
{ name = "rich", specifier = "==15.0.0" },
|
||||
{ name = "ruff", specifier = "==0.15.14" },
|
||||
{ name = "ruff", specifier = "==0.15.16" },
|
||||
{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" },
|
||||
{ name = "types-python-slugify", specifier = "==8.0.2.20240310" },
|
||||
{ name = "types-pyyaml", specifier = "==6.0.12.20260518" },
|
||||
{ name = "types-requests", specifier = "==2.33.0.20260518" },
|
||||
{ name = "types-urllib3", specifier = "==1.26.25.14" },
|
||||
{ name = "zensical", specifier = "==0.0.42" },
|
||||
]
|
||||
docs = [{ name = "zensical", specifier = "==0.0.42" }]
|
||||
docs = [{ name = "mkdocs-material", specifier = "==9.7.6" }]
|
||||
|
||||
[[package]]
|
||||
name = "mergedeep"
|
||||
version = "1.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mf2py"
|
||||
@@ -1036,6 +1050,75 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/88/b1d83c9e71cbdaefcec38ea350d2bd6360a9d1e030b090ad4b0fcc421ca1/mf2py-2.0.1-py3-none-any.whl", hash = "sha256:092806e17f1a93db4aafa5e8d3c4124b5e42cd89027e2db48a5248ef4eabde03", size = 25767, upload-time = "2023-12-08T03:41:58.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs"
|
||||
version = "1.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "ghp-import" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "mergedeep" },
|
||||
{ name = "mkdocs-get-deps" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "pyyaml-env-tag" },
|
||||
{ name = "watchdog" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-get-deps"
|
||||
version = "0.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mergedeep" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "9.7.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "babel" },
|
||||
{ name = "backrefs" },
|
||||
{ name = "colorama" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ name = "mkdocs" },
|
||||
{ name = "mkdocs-material-extensions" },
|
||||
{ name = "paginate" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material-extensions"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.1.0"
|
||||
@@ -1122,7 +1205,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.38.0"
|
||||
version = "2.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1134,9 +1217,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1171,6 +1254,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paginate"
|
||||
version = "0.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paho-mqtt"
|
||||
version = "1.6.1"
|
||||
@@ -1240,11 +1332,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1491,15 +1583,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1516,15 +1608,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1548,11 +1640,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/b2/f4/60edeb794bbc9ed0f
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.29"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1585,6 +1677,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml-env-tag"
|
||||
version = "1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.5"
|
||||
@@ -1697,27 +1801,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.14"
|
||||
version = "0.15.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1769,15 +1873,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1789,24 +1893,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
@@ -1917,15 +2003,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.48.0"
|
||||
version = "0.49.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1955,7 +2041,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.3.3"
|
||||
version = "21.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
@@ -1963,9 +2049,9 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1977,6 +2063,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
@@ -2037,33 +2144,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.42"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "deepmerge" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/dd/04e89ab92aed1ef9e36c76ef095fb587ffcbe4162aa7f3fe6d63aafade4a/zensical-0.0.42.tar.gz", hash = "sha256:cc346b833868a59412fe8d8498a152be90be9f3d8fb87e1f1a1c2e1146cbae1b", size = 3931093, upload-time = "2026-05-15T10:22:45.354Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/19/2ca4e52769307959f7485d4c5da7b24787339787c1cbc371885cef448e50/zensical-0.0.42-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bffd7a34b570fa3ccadf1d23babb0f7c4851c6b626e4fc8ed9f21c2eaae85968", size = 12705326, upload-time = "2026-05-15T10:22:07.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/82/0832b0d2c0c2800174141d5519a017105d3dace9194e2c29730e7a676adf/zensical-0.0.42-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ee1a79789f9462ef44a4b6ebbfc8b5bf4b2447607da8bc5b35bc9c4ce4ea2370", size = 12568663, upload-time = "2026-05-15T10:22:11.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/87/272b3998322958ca38f09323d2347cb121dfc851477c36962b71319242a5/zensical-0.0.42-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e9a5d508ce8d1b07d8417f0623be476f6b37d445ab4356481a71e613a7979d6", size = 12948460, upload-time = "2026-05-15T10:22:13.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/1b/e5f153401f162f48cae2d58e96b95fd39ba5bd1728fb5881a60e502f4e1d/zensical-0.0.42-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fbc0951a676e48afe7df3a9b2a30958dcf9c426ed2480972d3c04d6de485ba3", size = 12913460, upload-time = "2026-05-15T10:22:16.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/4f/5186b4204bdfdf132851b7515a37b9602bfc153fb601db5fb244339bae52/zensical-0.0.42-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f0e96e53f39b9e4b929a25d9df70bd7fa8217166a854e2c8f3185983dd01500", size = 13276704, upload-time = "2026-05-15T10:22:19.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/df/b57b5fcc631ac7a4b4c6834d8cf0b88d3fca37c9db42fc6bbf9f097200ed/zensical-0.0.42-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7d586e57436d603e88acd856864f99f0771aef24bf6560b2de238417bd3817c", size = 12987069, upload-time = "2026-05-15T10:22:22.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/3a/b326a44a065d98e89b472645ad33037201e3385340c2e6e35627b18ab3fa/zensical-0.0.42-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3c026f023330d67f986a94b68ffd36dc5066882e697e1125c37308d8d684135c", size = 13124195, upload-time = "2026-05-15T10:22:25.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1e/823740a662e357a8826dc8eeb87e06705e64219b2774430bc555f7c53d57/zensical-0.0.42-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e5908bc09cf5c1c50c9504241e37f89955daf3e89ba1b9d71c17972578b24804", size = 13182981, upload-time = "2026-05-15T10:22:28.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/6d/9fe261267ac36a7d57051d790022408e9043bc925c9ad21971a1e5b6c3e8/zensical-0.0.42-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0bf96b55f0a44e8716bcb334a16380ed56772b555145da775a7d8ac8678cb6f", size = 13332666, upload-time = "2026-05-15T10:22:32.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/57/9b0e4f131a7ad15cf1aca081748ea7336c084fb8e16be202a6bed32f595c/zensical-0.0.42-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47cd99583738a8ab03fac4080741275c56e741a06dc8edfb541f4c1649a5ae69", size = 13270817, upload-time = "2026-05-15T10:22:35.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/fd/bdb85cc444e4146e8970a22e48a903bfed5bf83276ad7d755caa415dda64/zensical-0.0.42-cp310-abi3-win32.whl", hash = "sha256:83090e53fba061967ecb3dff81500b1900f288bae108bf54084a2aeb6648ebd0", size = 12256227, upload-time = "2026-05-15T10:22:38.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/b9/09d1f735c8e6d3eb61d176ed5ebcf658b65b126d7d4bbc03a7d366a1e17d/zensical-0.0.42-cp310-abi3-win_amd64.whl", hash = "sha256:2e4304e103f9cd5c637045bbae1ff29de3009ab01b16e99c2fd6d4bbceb7a3ee", size = 12486598, upload-time = "2026-05-15T10:22:42.158Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user